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"