theme.js

import postcss from 'postcss'
import postcssImportParse from './postcss/import/parse'
import postcssMeta from './postcss/meta'
import { pseudoClass } from './postcss/root/increasing_specificity'
import postcssRootReplace from './postcss/root/replace'
import postcssSectionSize from './postcss/section_size'
import skipThemeValidationSymbol from './theme/symbol'

const absoluteUnits = {
  cm: (v) => (v * 960) / 25.4,
  in: (v) => v * 96,
  mm: (v) => (v * 96) / 25.4,
  pc: (v) => v * 16,
  pt: (v) => (v * 4) / 3,
  px: (v) => v,
}

const convertToPixel = (value) => {
  if (typeof value !== 'string') return undefined

  const matched = value.match(/^(-?[.0-9]+)([a-z]+)$/i)
  if (!matched) return undefined

  const [, num, unit] = matched
  const parsed = Number.parseFloat(num)
  if (Number.isNaN(parsed)) return undefined

  const conv = absoluteUnits[unit]
  return conv ? conv(parsed) : undefined
}

const memoizeProp = (name) => `${name}Memoized`
const reservedMetaType = { theme: String }

/**
 * Marpit theme class.
 */
class Theme {
  /**
   * Create a Theme instance.
   *
   * You should use {@link Theme.fromCSS} unless there is some particular
   * reason.
   *
   * @param {string} name The name of theme.
   * @param {string} css The content of CSS.
   * @hideconstructor
   */
  constructor(name, css) {
    /**
     * The name of theme.
     * @type {string}
     */
    this.name = name

    /**
     * The content of theme CSS.
     * @type {string}
     */
    this.css = css

    /**
     * Parsed metadata from CSS comments.
     * @type {Object}
     */
    this.meta = Object.freeze({})

    /**
     * Parsed `@import` rules.
     * @type {module:postcss/import/parse~ImportMeta[]}
     */
    this.importRules = []

    /**
     * Slide width. It requires the absolute unit supported in CSS.
     * @type {string}
     */
    this.width = undefined

    /**
     * Slide height. It requires the absolute unit supported in CSS.
     * @type {string}
     */
    this.height = undefined

    this.memoizeInit('width')
    this.memoizeInit('height')
  }

  /**
   * Create a Theme instance from Marpit theme CSS.
   *
   * @alias Theme.fromCSS
   * @param {string} cssString The string of Marpit theme CSS. It requires
   *     `@theme` meta comment.
   * @param {Object} [opts]
   * @param {Object} [opts.metaType] An object for defined types for metadata.
   */
  static fromCSS(cssString, opts = {}) {
    const metaType = { ...(opts.metaType || {}), ...reservedMetaType }

    const { css, result } = postcss([
      postcssMeta({ metaType }),
      postcssRootReplace({ pseudoClass }),
      postcssSectionSize({ preferedPseudoClass: pseudoClass }),
      postcssImportParse,
    ]).process(cssString)

    if (!opts[skipThemeValidationSymbol] && !result.marpitMeta.theme)
      throw new Error('Marpit theme CSS requires @theme meta.')

    const theme = new Theme(result.marpitMeta.theme, css)

    theme.importRules = [...result.marpitImport]
    theme.meta = Object.freeze({ ...result.marpitMeta })

    Object.assign(theme, { ...result.marpitSectionSize })

    return Object.freeze(theme)
  }

  /**
   * The converted width into pixel.
   *
   * @alias Theme#widthPixel
   * @type {number}
   * @readonly
   */
  get widthPixel() {
    return this.memoize('width', convertToPixel)
  }

  /**
   * The converted height into pixel.
   *
   * @alias Theme#heightPixel
   * @type {number}
   * @readonly
   */
  get heightPixel() {
    return this.memoize('height', convertToPixel)
  }

  /** @private */
  memoize(prop, func) {
    if (this[memoizeProp(prop)].has(this[prop]))
      return this[memoizeProp(prop)].get(this[prop])

    const converted = func(this[prop])
    this[memoizeProp(prop)].set(this[prop], converted)
    return converted
  }

  /** @private */
  memoizeInit(prop) {
    if (!this[memoizeProp(prop)])
      Object.defineProperty(this, memoizeProp(prop), { value: new Map() })
  }
}

export default Theme