diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e75dbe..153d5165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Make custom directives definable via `customDirectives` property ([#124](https://github.com/marp-team/marpit/issues/124), [#125](https://github.com/marp-team/marpit/pull/125)) + ## v0.6.1 - 2019-01-25 ### Fixed diff --git a/src/markdown/directives/apply.js b/src/markdown/directives/apply.js index 85c0bff2..524c335b 100644 --- a/src/markdown/directives/apply.js +++ b/src/markdown/directives/apply.js @@ -1,30 +1,30 @@ /** @module */ import kebabCase from 'lodash.kebabcase' -import { globals, locals } from './directives' +import builtInDirectives from './directives' import InlineStyle from '../../helpers/inline_style' -const publicDirectives = [...Object.keys(globals), ...Object.keys(locals)] - /** * Apply parsed Marpit directives to markdown-it tokens. * * @alias module:markdown/directives/apply * @param {MarkdownIt} md markdown-it instance. + * @param {Marpit} marpit Marpit instance. * @param {Object} [opts] * @param {boolean} [opts.dataset=true] Assigns directives as HTML data * attributes of each section tag. * @param {boolean} [opts.css=true] Assigns directives as CSS Custom Properties * of each section tag. - * @param {boolean} [opts.includeInternal=false] Whether include internal - * directives (Undefined in {@link module:markdown/directives/directives}.) - * In default, internal directives are not applied to HTML/CSS. */ -function apply(md, opts = {}) { +function apply(md, marpit, opts = {}) { const dataset = opts.dataset === undefined ? true : !!opts.dataset const css = opts.css === undefined ? true : !!opts.css - const filterFunc = key => - !!opts.includeInternal || publicDirectives.includes(key) + const { global, local } = marpit.customDirectives + const directives = [ + ...Object.keys(global), + ...Object.keys(local), + ...builtInDirectives, + ] md.core.ruler.after( 'marpit_directives_parse', @@ -39,7 +39,7 @@ function apply(md, opts = {}) { const style = new InlineStyle(token.attrGet('style')) for (const dir of Object.keys(marpitDirectives)) { - if (filterFunc(dir)) { + if (directives.includes(dir)) { const value = marpitDirectives[dir] if (value) { diff --git a/src/markdown/directives/directives.js b/src/markdown/directives/directives.js index c86e0760..93523e89 100644 --- a/src/markdown/directives/directives.js +++ b/src/markdown/directives/directives.js @@ -24,7 +24,7 @@ * @prop {Directive} theme Specify theme of the slide deck. */ export const globals = { - headingDivider(value) { + headingDivider: value => { const headings = [1, 2, 3, 4, 5, 6] const toInt = v => Array.isArray(v) || Number.isNaN(v) ? v : Number.parseInt(v, 10) @@ -42,13 +42,8 @@ export const globals = { return {} }, - style(value) { - return { style: value } - }, - theme(value, marpit) { - if (!marpit.themeSet.has(value)) return {} - return { theme: value } - }, + style: v => ({ style: v }), + theme: (v, marpit) => (marpit.themeSet.has(v) ? { theme: v } : {}), } /** @@ -77,38 +72,16 @@ export const globals = { * @prop {Directive} paginate Show page number on the slide if you set `true`. */ export const locals = { - backgroundColor(value) { - return { backgroundColor: value } - }, - backgroundImage(value) { - return { backgroundImage: value } - }, - backgroundPosition(value) { - return { backgroundPosition: value } - }, - backgroundRepeat(value) { - return { backgroundRepeat: value } - }, - backgroundSize(value) { - return { backgroundSize: value } - }, - class(value) { - return { class: Array.isArray(value) ? value.join(' ') : value } - }, - color(value) { - return { color: value } - }, - footer(value) { - return typeof value === 'string' ? { footer: value } : {} - }, - header(value) { - return typeof value === 'string' ? { header: value } : {} - }, - paginate(value) { - return { paginate: (value || '').toLowerCase() === 'true' } - }, + backgroundColor: v => ({ backgroundColor: v }), + backgroundImage: v => ({ backgroundImage: v }), + backgroundPosition: v => ({ backgroundPosition: v }), + backgroundRepeat: v => ({ backgroundRepeat: v }), + backgroundSize: v => ({ backgroundSize: v }), + class: v => ({ class: Array.isArray(v) ? v.join(' ') : v }), + color: v => ({ color: v }), + footer: v => (typeof v === 'string' ? { footer: v } : {}), + header: v => (typeof v === 'string' ? { header: v } : {}), + paginate: v => ({ paginate: (v || '').toLowerCase() === 'true' }), } -const directiveNames = [...Object.keys(globals), ...Object.keys(locals)] - -export default directiveNames +export default [...Object.keys(globals), ...Object.keys(locals)] diff --git a/src/markdown/directives/parse.js b/src/markdown/directives/parse.js index e7d6c97b..9eb34833 100644 --- a/src/markdown/directives/parse.js +++ b/src/markdown/directives/parse.js @@ -1,7 +1,7 @@ /** @module */ import MarkdownItFrontMatter from 'markdown-it-front-matter' import yaml from './yaml' -import { globals, locals } from './directives' +import * as directives from './directives' /** * Parse Marpit directives and store result to the slide token meta. @@ -44,6 +44,17 @@ function parse(md, marpit, opts = {}) { token.meta.marpitCommentParsed = 'directive' } + const filterBuiltinDirective = newProps => { + const ret = {} + + for (const prop of Object.keys(newProps).filter( + p => !directives.default.includes(p) + )) + ret[prop] = newProps[prop] + + return ret + } + // Parse global directives md.core.ruler.after('inline', 'marpit_directives_global_parse', state => { if (state.inlineMode) return @@ -55,11 +66,19 @@ function parse(md, marpit, opts = {}) { for (const key of Object.keys(obj)) { const globalKey = key.startsWith('$') ? key.slice(1) : key - if (globals[globalKey]) { + if (directives.globals[globalKey]) { + recognized = true + globalDirectives = { + ...globalDirectives, + ...directives.globals[globalKey](obj[key], marpit), + } + } else if (marpit.customDirectives.global[globalKey]) { recognized = true globalDirectives = { ...globalDirectives, - ...globals[globalKey](obj[key], marpit), + ...filterBuiltinDirective( + marpit.customDirectives.global[globalKey](obj[key], marpit) + ), } } } @@ -97,9 +116,20 @@ function parse(md, marpit, opts = {}) { let recognized = false for (const key of Object.keys(obj)) { - if (locals[key]) { + if (directives.locals[key]) { recognized = true - cursor.local = { ...cursor.local, ...locals[key](obj[key], marpit) } + cursor.local = { + ...cursor.local, + ...directives.locals[key](obj[key], marpit), + } + } else if (marpit.customDirectives.local[key]) { + recognized = true + cursor.local = { + ...cursor.local, + ...filterBuiltinDirective( + marpit.customDirectives.local[key](obj[key], marpit) + ), + } } // Spot directives @@ -107,11 +137,19 @@ function parse(md, marpit, opts = {}) { if (key.startsWith('_')) { const spotKey = key.slice(1) - if (locals[spotKey]) { + if (directives.locals[spotKey]) { + recognized = true + cursor.spot = { + ...cursor.spot, + ...directives.locals[spotKey](obj[key], marpit), + } + } else if (marpit.customDirectives.local[spotKey]) { recognized = true cursor.spot = { ...cursor.spot, - ...locals[spotKey](obj[key], marpit), + ...filterBuiltinDirective( + marpit.customDirectives.local[spotKey](obj[key], marpit) + ), } } } diff --git a/src/marpit.js b/src/marpit.js index 519ddbe8..78f868a5 100644 --- a/src/marpit.js +++ b/src/marpit.js @@ -76,7 +76,7 @@ class Marpit { * value of options after creating instance. * * @member {Object} options - * @memberOf Marpit# + * @memberOf Marpit * @readonly */ Object.defineProperty(this, 'options', { @@ -84,6 +84,23 @@ class Marpit { value: Object.freeze({ ...defaultOptions, ...opts }), }) + /** + * Definitions of the custom directive. + * + * It has the assignable `global` and `local` object. They have consisted of + * the directive name as a key, and parser function as a value. The parser + * should return the validated object for updating meta of markdown-it + * token. + * + * @member {Object} customDirectives + * @memberOf Marpit + * @readonly + */ + Object.defineProperty(this, 'customDirectives', { + value: Object.seal({ global: {}, local: {} }), + }) + + // Internal members Object.defineProperties(this, { containers: { value: [...wrapArray(this.options.container)] }, slideContainers: { value: [...wrapArray(this.options.slideContainer)] }, @@ -121,7 +138,7 @@ class Marpit { .use(marpitStyleParse, this) .use(marpitSlide) .use(marpitParseDirectives, this, { looseYAML }) - .use(marpitApplyDirectives) + .use(marpitApplyDirectives, this) .use(marpitHeaderAndFooter) .use(marpitHeadingDivider, this) .use(marpitSlideContainer, this.slideContainers) diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index e94a20ee..a4fea1cf 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -12,6 +12,7 @@ const splitBackgroundKeywords = ['left', 'right'] describe('Marpit background image plugin', () => { const marpitStub = svg => ({ + customDirectives: { global: {}, local: {} }, lastGlobalDirectives: {}, themeSet: { getThemeProp: () => 100 }, options: { inlineSVG: svg }, @@ -22,7 +23,7 @@ describe('Marpit background image plugin', () => { .use(comment) .use(slide) .use(parseDirectives, marpitStub(svg)) - .use(applyDirectives) + .use(applyDirectives, marpitStub(svg)) .use(inlineSVG, marpitStub(svg)) .use(parseImage, { filters }) .use(backgroundImage) diff --git a/test/markdown/collect.js b/test/markdown/collect.js index 027d4ad1..1ccffb41 100644 --- a/test/markdown/collect.js +++ b/test/markdown/collect.js @@ -13,6 +13,7 @@ describe('Marpit collect plugin', () => { const marpitStub = (svg = false) => ({ themeSet, + customDirectives: { global: {}, local: {} }, lastGlobalDirectives: {}, options: { inlineSVG: svg }, }) @@ -21,8 +22,11 @@ describe('Marpit collect plugin', () => { new MarkdownIt('commonmark') .use(comment) .use(slide) - .use(parseDirectives, { themeSet: marpitInstance.themeSet }) - .use(applyDirectives) + .use(parseDirectives, { + customDirectives: marpitInstance.customDirectives, + themeSet: marpitInstance.themeSet, + }) + .use(applyDirectives, marpitInstance) .use(collect, marpitInstance) .use(inlineSVG, marpitInstance) diff --git a/test/markdown/directives/apply.js b/test/markdown/directives/apply.js index 9a6c8f32..a206e196 100644 --- a/test/markdown/directives/apply.js +++ b/test/markdown/directives/apply.js @@ -10,12 +10,15 @@ describe('Marpit directives apply plugin', () => { const themeSetStub = new Map() themeSetStub.set('test_theme', true) - const md = (...args) => - new MarkdownIt('commonmark') + const md = (...args) => { + const customDirectives = { global: {}, local: {} } + + return new MarkdownIt('commonmark') .use(comment) .use(slide) - .use(parseDirectives, { themeSet: themeSetStub }) - .use(applyDirectives, ...args) + .use(parseDirectives, { customDirectives, themeSet: themeSetStub }) + .use(applyDirectives, { customDirectives }, ...args) + } const mdForTest = (...args) => md(...args).use(mdInstance => { @@ -86,19 +89,6 @@ describe('Marpit directives apply plugin', () => { }) }) - context('with includeInternal option as true', () => { - const opts = { includeInternal: true } - - it('applies together with unknown (internal) directive', () => { - const $ = cheerio.load(mdForTest(opts).render(basicDirs)) - const section = $('section').first() - const style = toObjStyle(section.attr('style')) - - expect(section.attr('data-unknown-dir')).toBe('directive') - expect(style['--unknown-dir']).toBe('directive') - }) - }) - describe('Local directives', () => { describe('Background image', () => { const bgDirs = dedent` diff --git a/test/markdown/directives/parse.js b/test/markdown/directives/parse.js index a011c04c..6a365f5c 100644 --- a/test/markdown/directives/parse.js +++ b/test/markdown/directives/parse.js @@ -6,7 +6,10 @@ import slide from '../../../src/markdown/slide' describe('Marpit directives parse plugin', () => { const themeSetStub = new Map() - const marpitStub = { themeSet: themeSetStub } + const marpitStub = { + customDirectives: { global: {}, local: {} }, + themeSet: themeSetStub, + } themeSetStub.set('test_theme', true) const md = (...args) => diff --git a/test/markdown/header_and_footer.js b/test/markdown/header_and_footer.js index 8ac595ac..c8aa0433 100644 --- a/test/markdown/header_and_footer.js +++ b/test/markdown/header_and_footer.js @@ -12,6 +12,7 @@ describe('Marpit header and footer plugin', () => { const marpitStub = (props = {}) => ({ themeSet, + customDirectives: { global: {}, local: {} }, lastGlobalDirectives: {}, ...props, }) @@ -20,8 +21,11 @@ describe('Marpit header and footer plugin', () => { new MarkdownIt('commonmark') .use(comment) .use(slide) - .use(parseDirectives, { themeSet: marpitInstance.themeSet }) - .use(applyDirectives) + .use(parseDirectives, { + customDirectives: marpitInstance.customDirectives, + themeSet: marpitInstance.themeSet, + }) + .use(applyDirectives, marpitInstance) .use(headerAndFooter) describe('Header local directive', () => { diff --git a/test/markdown/heading_divider.js b/test/markdown/heading_divider.js index 8d775c5c..14c84b59 100644 --- a/test/markdown/heading_divider.js +++ b/test/markdown/heading_divider.js @@ -7,6 +7,7 @@ import slide from '../../src/markdown/slide' describe('Marpit heading divider plugin', () => { const marpitStub = headingDividerOption => ({ + customDirectives: { global: {}, local: {} }, options: { headingDivider: headingDividerOption }, }) @@ -106,7 +107,12 @@ describe('Marpit heading divider plugin', () => { }) describe('Global directive', () => { - const md = (marpitInstance = { options: {} }) => + const md = ( + marpitInstance = { + customDirectives: { global: {}, local: {} }, + options: {}, + } + ) => new MarkdownIt('commonmark') .use(comment) .use(pluginMd => pluginMd.core.ruler.push('marpit_slide', () => {})) diff --git a/test/markdown/inline_svg.js b/test/markdown/inline_svg.js index 489af998..91924e65 100644 --- a/test/markdown/inline_svg.js +++ b/test/markdown/inline_svg.js @@ -8,6 +8,7 @@ import { Theme, ThemeSet } from '../../src/index' describe('Marpit inline SVG plugin', () => { const marpitStub = (props = {}) => ({ + customDirectives: { global: {}, local: {} }, themeSet: new ThemeSet(), lastGlobalDirectives: {}, options: { inlineSVG: true }, @@ -17,8 +18,11 @@ describe('Marpit inline SVG plugin', () => { const md = (marpitInstance = marpitStub()) => new MarkdownIt('commonmark') .use(slide) - .use(parseDirectives, { themeSet: marpitInstance.themeSet }) - .use(applyDirectives) + .use(parseDirectives, { + customDirectives: marpitInstance.customDirectives, + themeSet: marpitInstance.themeSet, + }) + .use(applyDirectives, marpitInstance) .use(inlineSVG, marpitInstance) const render = (markdownIt, text, inline = false) => { diff --git a/test/markdown/style/assign.js b/test/markdown/style/assign.js index 22229342..d1ab1cb3 100644 --- a/test/markdown/style/assign.js +++ b/test/markdown/style/assign.js @@ -10,6 +10,7 @@ import styleParse from '../../../src/markdown/style/parse' describe('Marpit style assign plugin', () => { const marpitStub = (...opts) => ({ + customDirectives: { global: {}, local: {} }, options: { inlineStyle: true }, themeSet: new Map(), ...opts, @@ -109,7 +110,7 @@ describe('Marpit style assign plugin', () => { .use(comment) .use(slide) .use(parseDirectives, marpit) - .use(applyDirectives) + .use(applyDirectives, marpit) .use(styleAssign, marpit) it('assigns parsed style global directive to Marpit lastStyles property', () => { @@ -131,7 +132,7 @@ describe('Marpit style assign plugin', () => { .use(styleParse, marpit) .use(slide) .use(parseDirectives, marpit) - .use(applyDirectives) + .use(applyDirectives, marpit) .use(styleAssign, marpit) it('assigns inline styles prior to directive style', () => { diff --git a/test/markdown/sweep.js b/test/markdown/sweep.js index 4ee462c2..baaaa07f 100644 --- a/test/markdown/sweep.js +++ b/test/markdown/sweep.js @@ -18,6 +18,7 @@ describe('Marpit sweep plugin', () => { it('sweeps blank paragraph made by background image plugin', () => { const marpitStub = { + customDirectives: { global: {}, local: {} }, themeSet: new Map(), options: { inlineSVG: false }, } @@ -25,7 +26,7 @@ describe('Marpit sweep plugin', () => { const markdown = md({ breaks: true }) .use(slide) .use(parseDirectives, marpitStub) - .use(applyDirectives) + .use(applyDirectives, marpitStub) .use(inlineSVG, marpitStub) .use(parseImage) .use(backgroundImage) diff --git a/test/marpit.js b/test/marpit.js index 9d8596ce..b5f1201a 100644 --- a/test/marpit.js +++ b/test/marpit.js @@ -11,24 +11,70 @@ describe('Marpit', () => { describe('#constructor', () => { const instance = new Marpit() - it('has default options', () => { - expect(instance.options.container.tag).toBe('div') - expect(instance.options.container.class).toBe('marpit') - expect(instance.options.backgroundSyntax).toBe(true) - expect(instance.options.markdown).toBe('commonmark') - expect(instance.options.printable).toBe(true) - expect(instance.options.slideContainer).toBe(false) - expect(instance.options.inlineSVG).toBe(false) + describe('options member', () => { + it('has default options', () => { + expect(instance.options.container.tag).toBe('div') + expect(instance.options.container.class).toBe('marpit') + expect(instance.options.backgroundSyntax).toBe(true) + expect(instance.options.markdown).toBe('commonmark') + expect(instance.options.printable).toBe(true) + expect(instance.options.slideContainer).toBe(false) + expect(instance.options.inlineSVG).toBe(false) + }) + + it('marks as immutable', () => { + expect(() => { + instance.options = { updated: true } + }).toThrow(TypeError) + + expect(() => { + instance.options.printable = false + }).toThrow(TypeError) + }) }) - it('marks options property as immutable', () => { - expect(() => { - instance.options = { updated: true } - }).toThrow(TypeError) + describe('customDirectives member', () => { + it('has sealed', () => { + expect(Object.isSealed(instance.customDirectives)).toBe(true) + + expect(() => { + delete instance.customDirectives.global + }).toThrow(TypeError) + + expect(() => { + instance.customDirectives.spot = {} + }).toThrow(TypeError) + }) + + it('is assignable parser function and apply to rendered token', () => { + const marpit = new Marpit({ container: undefined }) + + expect(() => { + marpit.customDirectives.global.marp = v => ({ marp: `test ${v}` }) + }).not.toThrowError() - expect(() => { - instance.options.printable = false - }).toThrow(TypeError) + const [token] = marpit.markdown.parse('') + expect(token.meta.marpitDirectives).toStrictEqual({ marp: 'test ok' }) + }) + + it('does not overload built-in directive parser', () => { + const marpit = new Marpit({ container: undefined }) + marpit.customDirectives.local.class = () => ({ class: '!' }) + + const [token] = marpit.markdown.parse('') + expect(token.meta.marpitDirectives).toStrictEqual({ class: 'ok' }) + }) + + it('cannot assign built-in directive as meta', () => { + const marpit = new Marpit({ container: undefined }) + marpit.customDirectives.local.test = v => ({ test: v, class: v }) + + const [first, , , second] = marpit.markdown.parse( + '\n***\n' + ) + expect(first.meta.marpitDirectives).toStrictEqual({ test: 'local' }) + expect(second.meta.marpitDirectives).toStrictEqual({ test: 'spot' }) + }) }) it('has themeSet property', () => {