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 {