From 5ece467c9d4db9e2d0949713e983bad7523c240e Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sat, 31 Aug 2024 05:45:31 +0900 Subject: [PATCH] Support CSS nesting --- package-lock.json | 83 +++++++++++++++++++- package.json | 5 +- src/marpit.js | 10 ++- src/postcss/nesting.js | 44 +++++++++++ src/theme.js | 18 +++-- src/theme_set.js | 107 +++++++++++++++----------- test/_supports/selector_normalizer.js | 25 ++++++ test/marpit.js | 74 ++++++++++++++++++ test/postcss/root/replace.js | 3 + 9 files changed, 311 insertions(+), 58 deletions(-) create mode 100644 src/postcss/nesting.js create mode 100644 test/_supports/selector_normalizer.js diff --git a/package-lock.json b/package-lock.json index ccb03e85..30d1066e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "3.0.0", "license": "MIT", "dependencies": { + "@csstools/postcss-is-pseudo-class": "^5.0.0", "cssesc": "^3.0.0", "js-yaml": "^4.1.0", "lodash.kebabcase": "^4.1.1", "markdown-it": "^14.1.0", "markdown-it-front-matter": "^0.2.4", - "postcss": "^8.4.41" + "postcss": "^8.4.41", + "postcss-nesting": "^13.0.0" }, "devDependencies": { "@babel/cli": "^7.25.6", @@ -37,6 +39,7 @@ "jsdoc": "^4.0.3", "npm-check-updates": "^17.1.0", "npm-run-all2": "^6.2.2", + "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", "rimraf": "^6.0.1", "sass": "1.77.8", @@ -2014,11 +2017,58 @@ "@csstools/css-tokenizer": "^3.0.1" } }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.0.tgz", + "integrity": "sha512-E/CjrT03BL06WmrjupnrT0VUBTvxJdoW1hRVeXFa9qatWtvcLLw0j8hP372G4A9PpSGEMXi3/AoHzPf7DNryCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^4.0.0", + "postcss-selector-parser": "^6.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz", + "integrity": "sha512-oklSrRvOxNeeOW1yARd4WNCs/D09cQjunGZUgSq6vM8GpzFswN+8rBZyJA29YFZhOTQ6GFzxgLDNtVbt9wPZMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.1.0" + } + }, "node_modules/@csstools/selector-specificity": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz", "integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==", - "dev": true, "funding": [ { "type": "github", @@ -9608,6 +9658,33 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-nesting": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.0.tgz", + "integrity": "sha512-TCGQOizyqvEkdeTPM+t6NYwJ3EJszYE/8t8ILxw/YoeUvz2rz7aM8XTAmBWh9/DJjfaaabL88fWrsVHSPF2zgA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-resolve-nested": "^2.0.0", + "@csstools/selector-specificity": "^4.0.0", + "postcss-selector-parser": "^6.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, "node_modules/postcss-normalize-charset": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", @@ -9865,7 +9942,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -11493,7 +11569,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { diff --git a/package.json b/package.json index 35b643c0..68629613 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "jsdoc": "^4.0.3", "npm-check-updates": "^17.1.0", "npm-run-all2": "^6.2.2", + "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", "rimraf": "^6.0.1", "sass": "1.77.8", @@ -88,12 +89,14 @@ "ws": "^8.18.0" }, "dependencies": { + "@csstools/postcss-is-pseudo-class": "^5.0.0", "cssesc": "^3.0.0", "js-yaml": "^4.1.0", "lodash.kebabcase": "^4.1.1", "markdown-it": "^14.1.0", "markdown-it-front-matter": "^0.2.4", - "postcss": "^8.4.41" + "postcss": "^8.4.41", + "postcss-nesting": "^13.0.0" }, "publishConfig": { "access": "public" diff --git a/src/marpit.js b/src/marpit.js index 9175f709..4b91f3a9 100644 --- a/src/marpit.js +++ b/src/marpit.js @@ -23,6 +23,7 @@ const defaultOptions = { anchor: true, container: marpitContainer, cssContainerQuery: false, + cssNesting: true, headingDivider: false, lang: undefined, looseYAML: false, @@ -73,6 +74,9 @@ class Marpit { * to enable CSS container query (`@container`). By setting the string or * string array, you can specify the container name(s) for the CSS * container. + * @param {boolean} [opts.cssNesting=false] Enable CSS nesting support. If + * enabled, Marpit will try to make flatten the CSS with nested rules + * before rendering, to make it compatible with Marpit preprocessing. * @param {false|number|number[]} [opts.headingDivider=false] Start a new * slide page at before of headings. it would apply to headings whose * larger than or equal to the specified level if a number is given, or @@ -128,7 +132,9 @@ class Marpit { /** * @type {ThemeSet} */ - this.themeSet = new ThemeSet() + this.themeSet = new ThemeSet({ + cssNesting: this.options.cssNesting, + }) this.applyMarkdownItPlugins( (() => { @@ -267,9 +273,9 @@ class Marpit { ...wrapArray(this.options.container), ...wrapArray(this.options.slideContainer), ], + containerQuery: this.options.cssContainerQuery, inlineSVG: this.inlineSVGOptions, printable: this.options.printable, - containerQuery: this.options.cssContainerQuery, } } diff --git a/src/postcss/nesting.js b/src/postcss/nesting.js new file mode 100644 index 00000000..97fc1470 --- /dev/null +++ b/src/postcss/nesting.js @@ -0,0 +1,44 @@ +/** @module */ +import postcssPlugin from '../helpers/postcss_plugin' +import postcssNesting from 'postcss-nesting' +import postcssIsPseudoClass from '@csstools/postcss-is-pseudo-class' + +const { Rule: applyPostCSSNesting } = postcssNesting() + +const matcher = /:is\((?:section|:root)\b/ + +export const nesting = postcssPlugin( + 'marpit-postcss-nesting', + () => (root, helpers) => { + const rules = [] + + // Note: Use walk instead of walkRules to include nested rules + root.walk((node) => { + if (node.type !== 'rule') return + + rules.push(node) + node.__marpitNestingOriginalSelector = node.selector + }) + + // Apply postcss-nesting + root.walkRules((rule) => applyPostCSSNesting(rule, helpers)) + + const { Rule: applyPostCSSIsPseudoClass } = postcssIsPseudoClass({ + onComplexSelector: 'warning', + }).prepare() + + for (const rule of rules) { + if ( + rule.__marpitNestingOriginalSelector !== rule.selector && + matcher.test(rule.selector) + ) { + // Apply postcss-is-pseudo-class only to transformed rules that is + // including `:is() selector starting from `section` element or `:root` + // pseudo-class + applyPostCSSIsPseudoClass(rule, helpers) + } + } + }, +) + +export default nesting diff --git a/src/theme.js b/src/theme.js index 142b7bca..12919270 100644 --- a/src/theme.js +++ b/src/theme.js @@ -1,6 +1,7 @@ import postcss from 'postcss' import postcssImportParse from './postcss/import/parse' import postcssMeta from './postcss/meta' +import postcssNesting from './postcss/nesting' import { pseudoClass } from './postcss/root/increasing_specificity' import postcssRootReplace from './postcss/root/replace' import postcssSectionSize from './postcss/section_size' @@ -13,6 +14,7 @@ const absoluteUnits = { pc: (v) => v * 16, pt: (v) => (v * 4) / 3, px: (v) => v, + // q: (v) => (v * 24) / 25.4, } const convertToPixel = (value) => { @@ -95,16 +97,20 @@ class Theme { * `@theme` meta comment. * @param {Object} [opts] * @param {Object} [opts.metaType] An object for defined types for metadata. + * @param {Object} [opts.cssNesting] Enable support for CSS nesting. */ static fromCSS(cssString, opts = {}) { const metaType = { ...(opts.metaType || {}), ...reservedMetaType } - const { css, result } = postcss([ - postcssMeta({ metaType }), - postcssRootReplace({ pseudoClass }), - postcssSectionSize({ preferedPseudoClass: pseudoClass }), - postcssImportParse, - ]).process(cssString) + const { css, result } = postcss( + [ + !!opts.cssNesting && postcssNesting, + postcssMeta({ metaType }), + postcssRootReplace({ pseudoClass }), + postcssSectionSize({ preferedPseudoClass: pseudoClass }), + postcssImportParse, + ].filter((p) => p), + ).process(cssString) if (!opts[skipThemeValidationSymbol] && !result.marpitMeta.theme) throw new Error('Marpit theme CSS requires @theme meta.') diff --git a/src/theme_set.js b/src/theme_set.js index a45ad153..2f8b50bd 100644 --- a/src/theme_set.js +++ b/src/theme_set.js @@ -7,6 +7,7 @@ import postcssContainerQuery, { import postcssImportHoisting from './postcss/import/hoisting' import postcssImportReplace from './postcss/import/replace' import postcssImportSuppress from './postcss/import/suppress' +import postcssNesting from './postcss/nesting' import postcssPagination from './postcss/pagination' import postcssPrintable, { postprocess as postcssPrintablePostProcess, @@ -23,6 +24,10 @@ import postcssSVGBackdrop from './postcss/svg_backdrop' import Theme from './theme' import scaffold from './theme/scaffold' +const defaultOptions = { + cssNesting: false, +} + /** * Marpit theme set class. */ @@ -30,7 +35,7 @@ class ThemeSet { /** * Create a ThemeSet instance. */ - constructor() { + constructor(opts = defaultOptions) { /** * An instance of default theme. * @@ -84,6 +89,14 @@ class ThemeSet { */ this.metaType = {} + /** + * A boolean value indicating whether the theme set should enable CSS + * nesting or not. + * + * @type {boolean} + */ + this.cssNesting = !!opts.cssNesting + Object.defineProperty(this, 'themeMap', { value: new Map() }) } @@ -106,7 +119,10 @@ class ThemeSet { * metadata. */ add(css) { - const theme = Theme.fromCSS(css, { metaType: this.metaType }) + const theme = Theme.fromCSS(css, { + metaType: this.metaType, + cssNesting: this.cssNesting, + }) this.addTheme(theme) return theme @@ -256,11 +272,16 @@ class ThemeSet { slideElements.unshift({ tag: 'svg' }, { tag: 'foreignObject' }) } + const runPostCSS = (css, plugins) => + postcss( + [this.cssNesting && postcssNesting(), ...plugins].filter((p) => p), + ).process(css).css + const additionalCSS = (css) => { if (!css) return undefined try { - return postcss([postcssImportSuppress(this)]).process(css).css + return runPostCSS(css, [postcssImportSuppress(this)]) } catch { return undefined } @@ -275,48 +296,44 @@ class ThemeSet { ? opts.containerQuery : undefined - const packer = postcss( - [ - before && - postcssPlugin( - 'marpit-pack-before', - () => (css) => css.first.before(before), - ), - after && - postcssPlugin('marpit-pack-after', () => (css) => { - css.last.after(after) - }), - opts.containerQuery && postcssContainerQuery(containerName), - postcssImportHoisting, - postcssImportReplace(this), - opts.printable && - postcssPrintable({ - width: this.getThemeProp(theme, 'width'), - height: this.getThemeProp(theme, 'height'), - }), - theme !== scaffold && - postcssPlugin( - 'marpit-pack-scaffold', - () => (css) => css.first.before(scaffold.css), - ), - inlineSVGOpts.enabled && postcssAdvancedBackground, - inlineSVGOpts.enabled && - inlineSVGOpts.backdropSelector && - postcssSVGBackdrop, - postcssPagination, - postcssRootReplace({ pseudoClass }), - postcssRootFontSize, - postcssPseudoPrepend, - postcssPseudoReplace(opts.containers, slideElements), - postcssRootIncreasingSpecificity, - opts.printable && postcssPrintablePostProcess, - opts.containerQuery && postcssContainerQueryPostProcess, - postcssRem, - postcssImportHoisting, - ].filter((p) => p), - ) - - return packer.process(theme.css).css + return runPostCSS(theme.css, [ + before && + postcssPlugin( + 'marpit-pack-before', + () => (css) => css.first.before(before), + ), + after && + postcssPlugin('marpit-pack-after', () => (css) => { + css.last.after(after) + }), + opts.containerQuery && postcssContainerQuery(containerName), + postcssImportHoisting, + postcssImportReplace(this), + opts.printable && + postcssPrintable({ + width: this.getThemeProp(theme, 'width'), + height: this.getThemeProp(theme, 'height'), + }), + theme !== scaffold && + postcssPlugin( + 'marpit-pack-scaffold', + () => (css) => css.first.before(scaffold.css), + ), + inlineSVGOpts.enabled && postcssAdvancedBackground, + inlineSVGOpts.enabled && + inlineSVGOpts.backdropSelector && + postcssSVGBackdrop, + postcssPagination, + postcssRootReplace({ pseudoClass }), + postcssRootFontSize, + postcssPseudoPrepend, + postcssPseudoReplace(opts.containers, slideElements), + postcssRootIncreasingSpecificity, + opts.printable && postcssPrintablePostProcess, + opts.containerQuery && postcssContainerQueryPostProcess, + postcssRem, + postcssImportHoisting, + ]) } /** diff --git a/test/_supports/selector_normalizer.js b/test/_supports/selector_normalizer.js new file mode 100644 index 00000000..9ada98ab --- /dev/null +++ b/test/_supports/selector_normalizer.js @@ -0,0 +1,25 @@ +import postcss from 'postcss' +import parser from 'postcss-selector-parser' + +const selectorProcessor = parser() + +/** + * @param {string} selector Selector string. + */ +export const selectorNormalizer = (selector) => + selectorProcessor.processSync(selector, { lossless: false }) + +const cssNormalizer = (css) => { + const processor = postcss([ + { + postcssPlugin: 'css-normalizer', + Rule(rule) { + rule.selectors = rule.selectors.map(selectorNormalizer) + }, + }, + ]) + + return processor.process(css, { from: undefined }) +} + +export const normalizeSelectorsInCss = (css) => cssNormalizer(css).css diff --git a/test/marpit.js b/test/marpit.js index 5c504910..65b34a29 100644 --- a/test/marpit.js +++ b/test/marpit.js @@ -3,6 +3,7 @@ import dedent from 'dedent' import MarkdownIt from 'markdown-it' import postcss from 'postcss' import { Marpit, ThemeSet } from '../src/index' +import { normalizeSelectorsInCss } from './_supports/selector_normalizer' describe('Marpit', () => { // Suppress PostCSS warning while running test @@ -45,6 +46,7 @@ describe('Marpit', () => { expect(instance.options.container.tag).toBe('div') expect(instance.options.container.class).toBe('marpit') expect(instance.options.cssContainerQuery).toBe(false) + expect(instance.options.cssNesting).toBe(true) expect(instance.options.lang).toBeUndefined() expect(instance.options.markdown).toBeUndefined() expect(instance.options.printable).toBe(true) @@ -501,6 +503,78 @@ describe('Marpit', () => { expect(css).not.toContain('container-name') }) }) + + context('with cssNesting option', () => { + const cssWithNesting = (root = 'section') => dedent` + ${root} { + color: red; + + h1 { + font-size: 2em; + + &:hover { + color: blue; + } + } + + > h2 { + color: green; + } + } + ` + + it('parses CSS nesting if cssNesting was true', () => { + // Inline style + const { css } = new Marpit({ cssNesting: true }).render( + ``, + ) + const normalizedCss = normalizeSelectorsInCss(css) + + expect(normalizedCss).toContain('div.marpit>section h1') + expect(normalizedCss).toContain('div.marpit>section h1:hover') + expect(normalizedCss).toContain('div.marpit>section>h2') + + // Custom theme + const marpit = new Marpit({ cssNesting: true }) + + marpit.themeSet.add(dedent` + /* @theme test */ + ${cssWithNesting(':root')} + `) + + const { css: customThemeCSS } = marpit.render('') + const normalizedCustomThemeCSS = normalizeSelectorsInCss(customThemeCSS) + + expect(normalizedCustomThemeCSS).toContain( + 'div.marpit>:where(section):not([\\20 root]) h1', + ) + expect(normalizedCustomThemeCSS).toContain( + 'div.marpit>:where(section):not([\\20 root]) h1:hover', + ) + expect(normalizedCustomThemeCSS).toContain( + 'div.marpit>:where(section):not([\\20 root])>h2', + ) + }) + + it('does not parse CSS nesting if cssNesting was false', () => { + // Inline style + const { css } = new Marpit({ cssNesting: false }).render( + ``, + ) + expect(css).not.toContain('h1:hover') // Test for parent selector `&` + + // Custom theme + const marpit = new Marpit({ cssNesting: false }) + + marpit.themeSet.add(dedent` + /* @theme test */ + ${cssWithNesting(':root')} + `) + + const { css: customThemeCSS } = marpit.render('') + expect(customThemeCSS).not.toContain('h1:hover') + }) + }) }) describe('#renderMarkdown', () => { diff --git a/test/postcss/root/replace.js b/test/postcss/root/replace.js index 78e666d4..b5385e32 100644 --- a/test/postcss/root/replace.js +++ b/test/postcss/root/replace.js @@ -25,6 +25,9 @@ describe('Marpit PostCSS root replace plugin', () => { expect(run(':root:not(:root.klass) { --bg: #fff; }').css).toBe( 'section:not(section.klass) { --bg: #fff; }', ) + expect(run(':is(:root) { --bg: #fff; }').css).toBe( + ':is(section) { --bg: #fff; }', + ) expect( run(dedent` @media screen {