import postcss from 'postcss'
import postcssPlugin from './helpers/postcss_plugin'
import postcssAdvancedBackground from './postcss/advanced_background'
import postcssContainerQuery, {
postprocess as postcssContainerQueryPostProcess,
} from './postcss/container_query'
import postcssImportHoisting from './postcss/import/hoisting'
import postcssImportReplace from './postcss/import/replace'
import postcssImportSuppress from './postcss/import/suppress'
import postcssPagination from './postcss/pagination'
import postcssPrintable, {
postprocess as postcssPrintablePostProcess,
} from './postcss/printable'
import postcssPseudoPrepend from './postcss/pseudo_selector/prepend'
import postcssPseudoReplace from './postcss/pseudo_selector/replace'
import postcssRootFontSize from './postcss/root/font_size'
import postcssRootIncreasingSpecificity, {
pseudoClass,
} from './postcss/root/increasing_specificity'
import postcssRem from './postcss/root/rem'
import postcssRootReplace from './postcss/root/replace'
import postcssSVGBackdrop from './postcss/svg_backdrop'
import Theme from './theme'
import scaffold from './theme/scaffold'
/**
* Marpit theme set class.
*/
class ThemeSet {
/**
* Create a ThemeSet instance.
*/
constructor() {
/**
* An instance of default theme.
*
* While running {@link ThemeSet#pack}, ThemeSet will use this theme when
* the definition of theme directive or the theme with specified name is not
* found.
*
* By default, Marpit does not provide default theme (`undefined`).
*
* @type {Theme|undefined}
*/
this.default = undefined
/**
* The default type settings for theme metadata added by
* {@link ThemeSet#add}.
*
* A key of object is the name of metadata and a value is the type which of
* `String` and `Array`. You have to set `Array` if the theme allows
* multi-time definitions in same meta key.
*
* ```css
* /**
* * @theme example
* * @foo Single value
* * @foo allows only one string
* * @bar Multiple value 1
* * @bar Multiple value 2
* * @bar Multiple value 3
* * ...
* ```
*
* ```js
* const themeSet = new ThemeSet()
*
* themeSet.metaType = {
* foo: String,
* bar: Array,
* }
*
* themeSet.add(css)
*
* console.log(themeSet.getThemeMeta('example', 'foo'))
* // => 'allows only one string'
*
* console.log(themeSet.getThemeMeta('example', 'bar'))
* // => ['Multiple value 1', 'Multiple value 2', 'Multiple value 3']
* ```
*
* @type {Object}
*/
this.metaType = {}
Object.defineProperty(this, 'themeMap', { value: new Map() })
}
/**
* Return the number of themes.
*
* @type {number}
* @readonly
*/
get size() {
return this.themeMap.size
}
/**
* Add theme CSS from string.
*
* @param {string} css The theme CSS string.
* @returns {Theme} A created {@link Theme} instance.
* @throws Will throw an error if the theme name is not specified by `@theme`
* metadata.
*/
add(css) {
const theme = Theme.fromCSS(css, { metaType: this.metaType })
this.addTheme(theme)
return theme
}
/**
* Add theme instance.
*
* @param {Theme} theme The theme instance.
* @throws Will throw an error if the theme name is not specified.
*/
addTheme(theme) {
if (!(theme instanceof Theme))
throw new Error('ThemeSet can add only an instance of Theme.')
if (typeof theme.name !== 'string')
throw new Error('An instance of Theme requires name.')
this.themeMap.set(theme.name, theme)
}
/**
* Removes all themes from a {@link themeSet} object.
*/
clear() {
return this.themeMap.clear()
}
/**
* Remove a specific named theme from a {@link themeSet} object.
*
* @param {string} name The theme name to delete.
* @returns {boolean} Returns `true` if a theme in current {@link ThemeSet}
* existed and has been removed, or `false` if the theme does not exist.
*/
delete(name) {
return this.themeMap.delete(name)
}
/**
* Returns a specific named theme.
*
* @param {string} name The theme name to get.
* @param {boolean} [fallback=false] If true, return instance's default theme
* or scaffold theme when specified theme cannot find.
* @returns {Theme|undefined} Returns specified or fallbacked theme, or
* `undefined` if `fallback` is false and the specified theme has not
* existed.
*/
get(name, fallback = false) {
const theme = this.themeMap.get(name)
return fallback ? theme || this.default || scaffold : theme
}
/**
* Returns value(s) of specified metadata from a theme. It considers `@import`
* and `@import-theme` rules in getting meta value. On the other hand, the
* default theme specified by the instance is not considered.
*
* To support metadata with array type, it will merge into a flatten array
* when the all of got valid values that includes imported themes are array.
*
* @param {string|Theme} theme The theme name or instance.
* @param {string} meta The meta name to get.
* @returns {string|string[]|undefined}
*/
getThemeMeta(theme, meta) {
const themeInstance = theme instanceof Theme ? theme : this.get(theme)
const metas = themeInstance
? this.resolveImport(themeInstance)
.map((t) => t.meta[meta])
.filter((m) => m)
: []
// Flatten in order of definitions when the all of valid values are array
if (metas.length > 0 && metas.every((m) => Array.isArray(m))) {
const mergedArray = []
for (const m of metas) mergedArray.unshift(...m)
return mergedArray
}
return metas[0]
}
/**
* Returns the value of specified property name from a theme. It considers
* `@import` and `@import-theme` rules in getting value.
*
* It will fallback the reference object into the instance's default theme or
* scaffold theme when the specified theme is `undefined`.
*
* @param {string|Theme} theme The theme name or instance.
* @param {string} prop The property name to get.
* @returns {*}
*/
getThemeProp(theme, prop) {
const themeInstance = theme instanceof Theme ? theme : this.get(theme)
const props = themeInstance
? this.resolveImport(themeInstance).map((t) => t[prop])
: []
return [...props, this.default && this.default[prop], scaffold[prop]].find(
(t) => t,
)
}
/**
* Returns a boolean indicating whether a specific named theme exists or not.
*
* @param {string} name The theme name.
* @returns {boolean} Returns `true` if a specific named theme exists,
* otherwise `false`.
*/
has(name) {
return this.themeMap.has(name)
}
/**
* Convert registered theme CSS into usable in the rendered markdown by
* {@link Marpit#render}.
*
* **This method is designed for internal use by {@link Marpit} class.** Use
* {@link Marpit#render} instead unless there is some particular reason.
*
* @param {string} name The theme name. It will use the instance's default
* theme or scaffold theme when a specific named theme does not exist.
* @param {Object} [opts] The option object passed by {@link Marpit#render}.
* @param {string} [opts.after] A CSS string to append into after theme.
* @param {string} [opts.before] A CSS string to prepend into before theme.
* @param {Element[]} [opts.containers] Container elements wrapping whole
* slide deck.
* @param {boolean|string|string[]} [opts.containerQuery] Enable CSS container
* query by setting `true`. You can also specify the name of container for
* CSS container query used by the `@container` at-rule in child elements.
* @param {boolean} [opts.printable] Make style printable to PDF.
* @param {Marpit~InlineSVGOptions} [opts.inlineSVG] Apply a hierarchy of
* inline SVG to CSS selector by setting `true`. _(Experimental)_
* @return {string} The converted CSS string.
*/
pack(name, opts = {}) {
const slideElements = [{ tag: 'section' }]
const theme = this.get(name, true)
const inlineSVGOpts = opts.inlineSVG || {}
if (inlineSVGOpts.enabled) {
slideElements.unshift({ tag: 'svg' }, { tag: 'foreignObject' })
}
const additionalCSS = (css) => {
if (!css) return undefined
try {
return postcss([postcssImportSuppress(this)]).process(css).css
} catch (e) {
return undefined
}
}
const after = additionalCSS(opts.after)
const before = additionalCSS(opts.before)
const containerName =
typeof opts.containerQuery === 'string' ||
Array.isArray(opts.containerQuery)
? 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
}
/**
* Returns a `Iterator` object that contains registered themes to current
* instance.
*
* @returns {Iterator.<Theme>}
*/
themes() {
return this.themeMap.values()
}
/**
* Resolves `@import` and `@import-theme` and returns an array of using theme
* instances.
*
* @private
* @param {Theme} theme Theme instance
* @returns {Theme[]}
*/
resolveImport(theme, importedThemes = []) {
const { name } = theme
if (importedThemes.includes(name))
throw new Error(`Circular "${name}" theme import is detected.`)
const resolvedThemes = [theme]
theme.importRules.forEach((m) => {
const importTheme = this.get(m.value)
if (importTheme)
resolvedThemes.push(
...this.resolveImport(
importTheme,
[...importedThemes, name].filter((n) => n),
),
)
})
return resolvedThemes.filter((v) => v)
}
}
export default ThemeSet