diff --git a/CHANGELOG.md b/CHANGELOG.md index e2985956..2446a4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `size` global directive and `@size` theme metadata to get easier way for using 4:3 deck in built-in theme ([#91](https://github.com/marp-team/marp-core/issues/91), [#94](https://github.com/marp-team/marp-core/pull/94)) + ## v0.10.2 - 2019-06-21 ### Fixed diff --git a/README.md b/README.md index d1913d95..20af6263 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,27 @@ We provide bulit-in official themes for Marp. See more details in [themes]. [themes]: ./themes/ +### `size` global directive + +Do you want a traditional 4:3 slide size? We've added the support of `size` global directive only for Marp Core (And keeping [backward compatibility of syntax with the old Marp app](https://github.com/yhatt/marp/blob/master/example.md#size) too). + +Our extended theming system can use `960`x`720` slide in built-in themes easier: `size: 4:3`. + +```markdown +--- +theme: gaia +size: 4:3 +--- + +# A traditional 4:3 slide +``` + +If you want to use more size presets in your theme, you have to define `@size` metadata(s) in theme CSS. [Learn in the document of theme metadata for Marp Core][metadata]. + +Theme author does not have to worry an unintended design being used with unexpected slide size because user only can use pre-defined presets by author. + +[metadata]: ./themes#metadata-for-additional-features + ### Emoji support Emoji shortcode (like `:smile:`) and Unicode emoji 😄 will convert into the SVG vector image provided by [twemoji](https://github.com/twitter/twemoji) 😄. It could render emoji with high resolution. @@ -123,7 +144,7 @@ $$ ### Auto-scaling features -Auto-scaling is available only if enabled [Marpit's `inlineSVG` mode](https://github.com/marp-team/marpit#inline-svg-slide-experimental) and defined `@auto-scaling` meta data in an using theme CSS. In addition, you have to run [`Marp.ready()`](#marpready) on browser context. +Auto-scaling is available only if enabled [Marpit's `inlineSVG` mode](https://github.com/marp-team/marpit#inline-svg-slide-experimental) and defined [`@auto-scaling` metadata][metadata] in an using theme CSS. In addition, you have to run [`Marp.ready()`](#marpready) on browser context. ```css /* @@ -177,7 +198,9 @@ Several themes also can scale-down the viewing size of the code block to fit a s These features means that the contents on a slide are not cropped, and not shown unnecessary scrollbars in code. -> :information_source: `@auto-scaling code` is a keyword of the `@auto-scaling` meta to enable code block scaling. `uncover` theme has disabled code block scaling because we use elastic style that has not compatible with it. +> :information_source: `@auto-scaling code` is a keyword of the `@auto-scaling` meta to enable code block scaling. +> +> `uncover` theme has disabled code block scaling because we use elastic style that has not compatible with it. ## Constructor options diff --git a/jest.config.js b/jest.config.js index 08d86380..41273c28 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.{j,t}s'], coveragePathIgnorePatterns: ['/node_modules/', '.*\\.d\\.ts'], coverageThreshold: { global: { lines: 95 } }, + setupFiles: ['jest-plugin-context/setup'], testEnvironment: 'node', testRegex: '(/(test|__tests__)/(?!_).*|(\\.|/)(test|spec))\\.[jt]s$', transform: { diff --git a/package.json b/package.json index 85a3f144..e345b078 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "devDependencies": { "@types/cheerio": "^0.22.11", "@types/jest": "^24.0.15", + "@types/jest-plugin-context": "^2.9.2", "autoprefixer": "^9.6.0", "cheerio": "^1.0.0-rc.3", "codecov": "^3.5.0", @@ -63,6 +64,7 @@ "github-markdown-css": "^3.0.1", "jest": "^24.8.0", "jest-junit": "^6.4.0", + "jest-plugin-context": "^2.9.0", "markdown-it": "^8.4.2", "node-sass-package-importer": "^5.3.2", "npm-run-all": "^4.1.5", diff --git a/src/marp.ts b/src/marp.ts index 1836cc11..b11238d2 100644 --- a/src/marp.ts +++ b/src/marp.ts @@ -6,6 +6,7 @@ import * as emojiPlugin from './emoji/emoji' import * as fittingPlugin from './fitting/fitting' import * as htmlPlugin from './html/html' import * as mathPlugin from './math/math' +import * as sizePlugin from './size/size' import defaultTheme from '../themes/default.scss' import gaiaTheme from '../themes/gaia.scss' import uncoverTheme from '../themes/uncover.scss' @@ -59,10 +60,14 @@ export class Marp extends Marpit { }, }) - // Enable table this.markdown.enable(['table', 'linkify']) - // Add themes + // Theme support + this.themeSet.metaType = Object.freeze({ + 'auto-scaling': String, + size: Array, + }) + this.themeSet.default = this.themeSet.add(defaultTheme) this.themeSet.add(gaiaTheme) this.themeSet.add(uncoverTheme) @@ -75,6 +80,7 @@ export class Marp extends Marpit { .use(emojiPlugin.markdown) .use(mathPlugin.markdown, flag => (this.renderedMath = flag)) .use(fittingPlugin.markdown) + .use(sizePlugin.markdown) } highlighter(code: string, lang: string): string { diff --git a/src/size/size.ts b/src/size/size.ts new file mode 100644 index 00000000..4ab31413 --- /dev/null +++ b/src/size/size.ts @@ -0,0 +1,90 @@ +import marpitPlugin from '@marp-team/marpit/lib/markdown/marpit_plugin' +import { Theme } from '@marp-team/marpit' +import { Marp } from '../marp' + +interface DefinedSize { + width: string + height: string +} + +interface RestorableThemes { + default: Theme | undefined + themes: Set +} + +export const markdown = marpitPlugin(md => { + const marp: Marp = md.marpit + const { render } = marp + + const definedSizes = (theme: Theme): ReadonlyMap => { + const sizes = (marp.themeSet.getThemeMeta(theme, 'size') as string[]) || [] + const map = new Map() + + for (const value of sizes) { + const args = value.split(/\s+/) + + if (args.length === 3) { + map.set(args[0], { width: args[1], height: args[2] }) + } else if (args.length === 2 && args[1] === 'false') { + map.delete(args[0]) + } + } + + return map + } + + const forRestore: RestorableThemes = { + themes: new Set(), + default: undefined, + } + + // `size` global directive + marp.customDirectives.global.size = size => + typeof size === 'string' ? { size } : {} + + // Override render method to restore original theme set + marp.render = (...args) => { + try { + return render.apply(marp, args) + } finally { + forRestore.themes.forEach(theme => marp.themeSet.addTheme(theme)) + + if (forRestore.default) marp.themeSet.default = forRestore.default + } + } + + md.core.ruler.after('marpit_directives_global_parse', 'marp_size', state => { + if (state.inlineMode) return + + forRestore.themes.clear() + forRestore.default = undefined + + const { theme, size } = (marp as any).lastGlobalDirectives + if (!size) return + + const themeInstance = marp.themeSet.get(theme, true) as Theme + const customSize = definedSizes(themeInstance).get(size) + + if (customSize) { + const { width, height } = customSize + const css = `${themeInstance.css}\nsection{width:${width};height:${height};}` + + const overrideTheme = Object.assign(new (Theme as any)(), { + ...themeInstance, + ...customSize, + css, + }) + + forRestore.themes.add(themeInstance) + + if (themeInstance === marp.themeSet.default) { + forRestore.default = themeInstance + marp.themeSet.default = overrideTheme + } + + if (marp.themeSet.has(overrideTheme.name)) { + marp.themeSet.addTheme(overrideTheme) + } + } + }) +}) diff --git a/test/_helpers/context.ts b/test/_helpers/context.ts deleted file mode 100644 index 89eda7d4..00000000 --- a/test/_helpers/context.ts +++ /dev/null @@ -1 +0,0 @@ -export default describe diff --git a/test/browser.ts b/test/browser.ts index 684c7b38..7cc8ff50 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -1,6 +1,5 @@ /** @jest-environment jsdom */ import browser from '../src/browser' -import context from './_helpers/context' import fittingObserver from '../src/fitting/observer' const polyfill = jest.fn() diff --git a/test/fitting/observer.ts b/test/fitting/observer.ts index 102b027a..3f415f83 100644 --- a/test/fitting/observer.ts +++ b/test/fitting/observer.ts @@ -1,5 +1,4 @@ /** @jest-environment jsdom */ -import context from '../_helpers/context' import Marp from '../../src/marp' import fittingObserver from '../../src/fitting/observer' diff --git a/test/marp.ts b/test/marp.ts index 8e649d15..68dfec45 100644 --- a/test/marp.ts +++ b/test/marp.ts @@ -1,8 +1,6 @@ import { Marpit } from '@marp-team/marpit' import cheerio from 'cheerio' -import MarkdownIt from 'markdown-it' import postcss from 'postcss' -import context from './_helpers/context' import { EmojiOptions } from '../src/emoji/emoji' import browser from '../src/browser' import { Marp, MarpOptions } from '../src/marp' @@ -15,6 +13,12 @@ afterEach(() => jest.restoreAllMocks()) describe('Marp', () => { const marp = (opts?: MarpOptions): Marp => new Marp(opts) + const loadCheerio = (html: string) => + cheerio.load(html, { + lowerCaseAttributeNames: false, + lowerCaseTags: false, + }) + it('extends Marpit', () => expect(marp()).toBeInstanceOf(Marpit)) describe('markdown option', () => { @@ -313,10 +317,8 @@ describe('Marp', () => { }) context('when math typesetting syntax is not using', () => { - const ret = marp().render('plain text') - it('does not inject KaTeX css', () => - expect(ret.css).not.toContain('.katex')) + expect(marp().render('plain text').css).not.toContain('.katex')) }) context('with katexOption', () => { @@ -413,12 +415,6 @@ describe('Marp', () => { }) describe('Element fitting', () => { - const loadCheerio = (html: string) => - cheerio.load(html, { - lowerCaseAttributeNames: false, - lowerCaseTags: false, - }) - it('prepends CSS about fitting', () => { const { css } = marp().render('') @@ -569,6 +565,30 @@ describe('Marp', () => { }) }) + describe('size global directive', () => { + it('defines size custom global directive', () => + expect(marp().customDirectives.global.size).toBeTruthy()) + + context('with size directive as 4:3', () => { + const size = expect.objectContaining({ width: '960', height: '720' }) + + it('renders inline SVG with 960x720 size', () => { + const instance = marp() + const md = (t: string) => `\n` + + const { html } = instance.render('') + expect(loadCheerio(html)('foreignObject').attr()).toStrictEqual(size) + + for (const theme of instance.themeSet.themes()) { + const { html: themeHtml } = instance.render(md(theme.name)) + const $ = loadCheerio(themeHtml) + + expect($('foreignObject').attr()).toStrictEqual(size) + } + }) + }) + }) + describe('themeSet property', () => { const { themeSet } = new Marp() diff --git a/test/size/size.ts b/test/size/size.ts new file mode 100644 index 00000000..5ddb5e0a --- /dev/null +++ b/test/size/size.ts @@ -0,0 +1,135 @@ +import { Marpit, Theme } from '@marp-team/marpit' +import postcss from 'postcss' +import { markdown as sizePlugin } from '../../src/size/size' + +const metaType = { size: Array } + +describe('Size plugin', () => { + const marpit = (callback: (marpit: Marpit) => void = () => {}) => + new Marpit().use(sizePlugin).use(({ marpit }) => { + marpit.themeSet.metaType = metaType + callback(marpit) + }) + + const collectDecls = async ( + css: string, + selector = 'div.marpit > section' + ) => { + const collectedDecls: Record = {} + + await postcss([ + root => { + const collect = (rule: postcss.Rule | postcss.AtRule, to) => + rule.walkDecls(({ prop, value }) => { + to[prop] = value + }) + + root.walkRules(selector, rule => collect(rule, collectedDecls)) + root.walkAtRules(atRule => { + const name = `@${atRule.name}` + + collectedDecls[name] = collectedDecls[name] || {} + collect(atRule, collectedDecls[name]) + }) + }, + ]).process(css, { from: undefined }) + + return collectedDecls + } + + it('defines size custom global directive', () => + expect(marpit().customDirectives.global.size).toBeTruthy()) + + context('when specified theme has theme metadata', () => { + const instance = marpit(m => { + m.themeSet.add('/* @theme a *//* @size test 640px 480px */') + m.themeSet.add( + '/* @theme b *//* @size test2 800px 600px */\n@import "a";' + ) + m.themeSet.add('/* @theme c *//* @size test 6px 4px */\n@import "a";') + m.themeSet.add( + '/* @theme d *//* @size test false *//* @size test2 - invalid defintion */\n@import "b";' + ) + }) + + it('adds width and height style for section and @page rule', async () => { + const { css } = instance.render('\n') + expect(css).not.toBe(instance.render('').css) + + const decls = await collectDecls(css) + expect(decls.width).toBe('640px') + expect(decls.height).toBe('480px') + expect(decls['@page'].size).toBe('640px 480px') + }) + + it('reverts manipulated theme after rendering', () => { + const baseWidth = instance.themeSet.getThemeProp('', 'width') + const baseHeight = instance.themeSet.getThemeProp('', 'height') + + instance.render('\n') + + expect(instance.themeSet.getThemeProp('a', 'width')).toBe(baseWidth) + expect(instance.themeSet.getThemeProp('a', 'height')).toBe(baseHeight) + }) + + it('ignores undefined size name', () => { + const { css } = instance.render('\n') + expect(css).toBe(instance.render('').css) + }) + + it('ignores invalid size directive', () => { + const { css } = instance.render( + '\n' + ) + expect(css).toBe(instance.render('').css) + }) + + it('allows using defined size in imported theme', async () => { + const { css } = instance.render('\n') + const decls = await collectDecls(css) + + expect(decls.width).toBe('640px') + expect(decls.height).toBe('480px') + expect(decls['@page'].size).toBe('640px 480px') + }) + + it('can override defined size in inherited theme', async () => { + const { css } = instance.render('\n') + const decls = await collectDecls(css) + + expect(decls.width).toBe('6px') + expect(decls.height).toBe('4px') + expect(decls['@page'].size).toBe('6px 4px') + }) + + it('can disable defined size in inherited theme by `@size [name] false`', async () => { + const { css } = instance.render('\n') + expect(css).toBe(instance.render('').css) + }) + }) + + context('when default theme has size metadata', () => { + const defaultCSS = '/* @theme a *//* @size test 640px 480px */' + const defaultTheme = Theme.fromCSS(defaultCSS, { metaType }) + + const instance = marpit(m => { + m.themeSet.default = defaultTheme + }) + + it('adds width and height style for section', async () => { + const { css } = instance.render('') + const { width, height } = await collectDecls(css) + + expect(width).toBe('640px') + expect(height).toBe('480px') + }) + + it('reverts manipulated theme after rendering', () => { + instance.render('') + + expect(instance.themeSet.default!.css).toBe(defaultCSS) + expect(instance.themeSet.default!.width).toBeUndefined() + expect(instance.themeSet.default!.height).toBeUndefined() + }) + }) +}) diff --git a/themes/README.md b/themes/README.md index a1717309..2b74cf90 100644 --- a/themes/README.md +++ b/themes/README.md @@ -1,14 +1,26 @@ # Marp Core built-in themes -We provide some nice official themes in Marp Core. You can choose a favorite theme by using [Marpit `theme` directive](https://marpit.marp.app/directives?id=theme) in your Markdown. +We provide some nice built-in themes in Marp Core. You can choose a favorite theme by using [Marpit `theme` directive](https://marpit.marp.app/directives?id=theme) in your Markdown. -Screenshots were taken from the rendered result of [an example][example]. + [example]: example.md -### `invert` class +### Common feature -The all of built-in themes support `invert` class to use the inverted color scheme. +These can use in the all of built-in themes. + +#### 4:3 slide + +We have `4:3` slide size preset (`960x720`) for a traditional presentation. + +```markdown + +``` + +#### `invert` class + +By using `invert` class, you can change to use the inverted color scheme. ```markdown @@ -105,15 +117,15 @@ Uncover theme has three design concepts: simple, minimal, and modern. It's inspi [Auto-scaling for code block](https://github.com/marp-team/marp-core#auto-scaling-features) is disabled because uncover theme uses the elastic style that has not compatible with it. ---- +# Metadata for additional features -## Metadata for additional features +Marp Core's extended theming system will recognize the metadata to be able to enable extra features whose a side effect to the original DOM structure/the slide design through the manipulation. -Marp Core will recognize the metadata to be able to enable extra features whose side effect through manipulation to rendered DOM structure. +In other words, the enabled feature requires taking care of the manipulated DOM and the view when styling. -In other words, the enabled feature requires taking care of manipulated DOM when styling. +**_If you never want to think complex styling, it's better to define no extra metadata._** Your theme would work as same as a simple [Marpit theme CSS](https://marpit.marp.app/theme-css) if you do nothing. -### `@auto-scaling` +## `@auto-scaling [flag(s)]` Enable [auto-scaling features](https://github.com/marp-team/marp-core#auto-scaling-features). @@ -122,4 +134,56 @@ Enable [auto-scaling features](https://github.com/marp-team/marp-core#auto-scali - `math`: Enable scaling for math block. - `code`: Enable scaling for code block. -Through separating by comma, it can select multiple keywords for individual features. (e.g. `@auto-scaling fittingHeader,math`) +Through separating by comma, it can select multiple keywords for individual features. + +```css +/** + * @theme foobar + * @auto-scaling fittingHeader,math + */ +``` + +## `@size [name] [width] [height]` + +Define size preset(s) for usable in [`size` global directive](https://github.com/marp-team/marp-core#size-global-directive). + +```css +/** + * @theme foobar + * @size 4:3 960px 720px + * @size 16:9 1280px 720px + * @size 4K 3840px 2160px + */ + +section { + /* A way to define default size is as same as Marpit theme CSS. */ + width: 960px; + height: 720px; +} +``` + +User can choose a customized size of slide deck (`section`) from defined presets via `size` global directive. + +```markdown +--- +theme: foobar +size: 4K +--- + +# Slide deck for 4K screen (3840x2160) +``` + +When the imported theme through [`@import "foo";`](https://marpit.marp.app/theme-css?id=import-rule) or [`@import-theme "bar";`](https://marpit.marp.app/theme-css?id=import-theme-rule) has `@size` metadata(s), these presets still can use in an inherited theme. + +Or you can use `@size [name] false` in the inherited theme if you need to disable specific preset. + +```css +/** + * gaia-16-9 theme is based on Gaia theme, but 4:3 slide cannot use. + * + * @theme inherited-from-gaia + * @size 4:3 false + */ + +@import 'gaia'; +``` diff --git a/themes/default.scss b/themes/default.scss index 29232079..1dd69c3f 100644 --- a/themes/default.scss +++ b/themes/default.scss @@ -5,6 +5,7 @@ * @author Yuki Hattori * * @auto-scaling true + * @size 4:3 960px 720px */ @import '~github-markdown-css'; diff --git a/themes/gaia.scss b/themes/gaia.scss index a9b42f32..3b3d1b20 100644 --- a/themes/gaia.scss +++ b/themes/gaia.scss @@ -5,6 +5,7 @@ * @author Yuki Hattori * * @auto-scaling true + * @size 4:3 960px 720px */ $color-light: #fff8e1; diff --git a/themes/uncover.scss b/themes/uncover.scss index 1163b054..2431d7a5 100644 --- a/themes/uncover.scss +++ b/themes/uncover.scss @@ -5,6 +5,7 @@ * @author Yuki Hattori * * @auto-scaling fittingHeader,math + * @size 4:3 960px 720px */ @mixin color-scheme($bg: #fdfcff, $text: #202228, $highlight: #009dd5) { diff --git a/yarn.lock b/yarn.lock index b6c03e38..0fd4ce85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -418,7 +418,14 @@ resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== -"@types/jest@^24.0.15": +"@types/jest-plugin-context@^2.9.2": + version "2.9.2" + resolved "https://registry.yarnpkg.com/@types/jest-plugin-context/-/jest-plugin-context-2.9.2.tgz#f4ef041da7ba64d16b2564f5f2b36de245c71e65" + integrity sha512-r8bN9AFdaoaKl1x1IVGC2nsek8KJpQB4FUZjPBSfs6vyHhmdylgJ9qBCydCyRaqmWPB9/5tm4ypl8qvYdWnr1w== + dependencies: + "@types/jest" "*" + +"@types/jest@*", "@types/jest@^24.0.15": version "24.0.15" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.15.tgz#6c42d5af7fe3b44ffff7cc65de7bf741e8fa427f" integrity sha512-MU1HIvWUme74stAoc3mgAi+aMlgKOudgEvQDIm1v4RkrDudBh1T+NFp5sftpBAdXdx1J0PbdpJ+M2EsSOi1djA== @@ -3122,6 +3129,11 @@ jest-mock@^24.8.0: dependencies: "@jest/types" "^24.8.0" +jest-plugin-context@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/jest-plugin-context/-/jest-plugin-context-2.9.0.tgz#602aacbe61213a3bb229067db0eee9e003eb1931" + integrity sha1-YCqsvmEhOjuyKQZ9sO7p4APrGTE= + jest-pnp-resolver@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz#ecdae604c077a7fbc70defb6d517c3c1c898923a"