accessible-marp-decks 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +15 -0
- package/NOTICE +21 -0
- package/README.md +181 -0
- package/package.json +68 -0
- package/src/cli.js +247 -0
- package/src/core/escape.js +16 -0
- package/src/core/marpit.js +29 -0
- package/src/core/render.js +87 -0
- package/src/core/runtime-script.js +99 -0
- package/src/core/template.js +36 -0
- package/src/core/themes.js +56 -0
- package/src/core/transforms/_code.js +15 -0
- package/src/core/transforms/_img.js +98 -0
- package/src/core/transforms/_section.js +59 -0
- package/src/core/transforms/index.js +23 -0
- package/src/eleventy.js +53 -0
- package/src/index.js +19 -0
- package/themes/_template.css +249 -0
- package/themes/basic.css +316 -0
- package/themes/document.css +68 -0
- package/themes/high-contrast.css +237 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import matter from 'gray-matter'
|
|
2
|
+
import { createMarpit } from './marpit.js'
|
|
3
|
+
import { resolveThemeCSS, readDocumentCSS } from './themes.js'
|
|
4
|
+
import { buildDocument } from './template.js'
|
|
5
|
+
import { applyTransforms } from './transforms/index.js'
|
|
6
|
+
|
|
7
|
+
// js-beautify is heavyweight and only needed when prettifying, so it is
|
|
8
|
+
// imported on demand (and cached) rather than at module load.
|
|
9
|
+
let beautifyHTML = null
|
|
10
|
+
|
|
11
|
+
async function prettifyHTML (html) {
|
|
12
|
+
if (!beautifyHTML) {
|
|
13
|
+
const { default: jsBeautify } = await import('js-beautify')
|
|
14
|
+
beautifyHTML = jsBeautify.html
|
|
15
|
+
}
|
|
16
|
+
return beautifyHTML(html, {
|
|
17
|
+
indent_size: 2,
|
|
18
|
+
wrap_line_length: 0,
|
|
19
|
+
preserve_newlines: false,
|
|
20
|
+
unformatted: ['code', 'pre']
|
|
21
|
+
}).trim() + '\n'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} RenderOptions
|
|
26
|
+
* @property {string} [theme] - Bundled theme name (default `"basic"`).
|
|
27
|
+
* @property {string} [css] - Raw theme CSS, used instead of a bundled theme.
|
|
28
|
+
* @property {string} [documentCss] - Override the base accessible-layout CSS.
|
|
29
|
+
* @property {string} [basePath] - Directory to resolve relative image srcs against.
|
|
30
|
+
* Required for `inlineAssets`; when omitted, images are left as references.
|
|
31
|
+
* @property {boolean} [inlineAssets=true] - Base64-inline local images so the
|
|
32
|
+
* output is a single self-contained file.
|
|
33
|
+
* @property {string} [lang='en'] - Document language attribute.
|
|
34
|
+
* @property {boolean} [runtimeScript=true] - Inline the runtime script that
|
|
35
|
+
* scales each slide to the window and refines code-block focus. Set `false`
|
|
36
|
+
* for strict-CSP hosts: slides then render at full 1280px size (the page
|
|
37
|
+
* scrolls) and every code block stays focusable.
|
|
38
|
+
* @property {boolean} [prettify=true] - Pretty-print the HTML output.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render Marp markdown into a standalone, accessible HTML document.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} markdown - Marp markdown, including front matter.
|
|
45
|
+
* @param {RenderOptions} [options]
|
|
46
|
+
* @returns {Promise<string>}
|
|
47
|
+
*/
|
|
48
|
+
export async function renderDeck (markdown, options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
theme,
|
|
51
|
+
css,
|
|
52
|
+
documentCss,
|
|
53
|
+
basePath,
|
|
54
|
+
inlineAssets = true,
|
|
55
|
+
lang = 'en',
|
|
56
|
+
runtimeScript = true,
|
|
57
|
+
prettify = true
|
|
58
|
+
} = options
|
|
59
|
+
|
|
60
|
+
const { data: deckInfo } = matter(markdown)
|
|
61
|
+
|
|
62
|
+
const marpit = createMarpit()
|
|
63
|
+
const themeCSS = await resolveThemeCSS(theme ?? deckInfo.theme, css)
|
|
64
|
+
marpit.themeSet.default = marpit.themeSet.add(themeCSS)
|
|
65
|
+
|
|
66
|
+
const { html, css: marpitCSS } = marpit.render(markdown)
|
|
67
|
+
|
|
68
|
+
const baseCSS = documentCss ?? await readDocumentCSS()
|
|
69
|
+
const combinedCSS = `${marpitCSS}${baseCSS}`
|
|
70
|
+
|
|
71
|
+
const documentHTML = buildDocument({ html, css: combinedCSS, deckInfo, lang, runtimeScript })
|
|
72
|
+
const modifiedHTML = await applyTransforms(documentHTML, { basePath, inlineAssets })
|
|
73
|
+
|
|
74
|
+
if (!prettify) return modifiedHTML
|
|
75
|
+
return prettifyHTML(modifiedHTML)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the deck front matter (title, description, theme, …) without
|
|
80
|
+
* rendering. Useful for tooling around a deck.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} markdown
|
|
83
|
+
* @returns {Record<string, unknown>}
|
|
84
|
+
*/
|
|
85
|
+
export function readDeckInfo (markdown) {
|
|
86
|
+
return matter(markdown).data
|
|
87
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single progressive-enhancement script inlined into every rendered deck
|
|
3
|
+
* (unless `runtimeScript` is disabled). It does two things:
|
|
4
|
+
*
|
|
5
|
+
* 1. Uniform scaling — sets `--slide-scale` on each `div.marpit` to
|
|
6
|
+
* (container width / 1280) so the CSS `zoom` magnifies each fixed-size slide
|
|
7
|
+
* as one rigid unit. Re-run on resize. Without it, slides render at their
|
|
8
|
+
* full 1280px design size (the no-JS fallback).
|
|
9
|
+
*
|
|
10
|
+
* 2. Code-block focus — code blocks ship with `tabindex="0"` (+ role/label) so
|
|
11
|
+
* they are keyboard-scrollable even without JS. This removes those from any
|
|
12
|
+
* block that doesn't actually overflow, so only scrollable code stays in the
|
|
13
|
+
* tab order. Adapted from the author's blog-v2 (`c-code-block-scrolling`).
|
|
14
|
+
*/
|
|
15
|
+
export const pageScript = `<script>
|
|
16
|
+
(() => {
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const DESIGN_WIDTH = 1280;
|
|
20
|
+
const OVERFLOW_TOLERANCE = 1;
|
|
21
|
+
|
|
22
|
+
// --- 1. Uniform whole-slide scaling ---------------------------------
|
|
23
|
+
// Fit-to-width alone would cancel browser zoom (Cmd/Ctrl and +): zooming
|
|
24
|
+
// shrinks the viewport in CSS pixels, so a purely width-derived scale
|
|
25
|
+
// re-fits and the text never gets bigger. Desktop page zoom shows up as a
|
|
26
|
+
// devicePixelRatio change, so we multiply the fit scale by the zoom factor
|
|
27
|
+
// since load — zooming then magnifies the deck (with scrollbars), as
|
|
28
|
+
// WCAG 1.4.4 expects. Known trade-off: dragging the window to a display
|
|
29
|
+
// with a different pixel density reads as a zoom until the next reload.
|
|
30
|
+
const baseDPR = window.devicePixelRatio || 1;
|
|
31
|
+
const decks = document.querySelectorAll("div.marpit");
|
|
32
|
+
const scale = (deck) => {
|
|
33
|
+
const zoomFactor = (window.devicePixelRatio || 1) / baseDPR;
|
|
34
|
+
const fit = deck.clientWidth / DESIGN_WIDTH;
|
|
35
|
+
deck.style.setProperty("--slide-scale", fit * zoomFactor);
|
|
36
|
+
};
|
|
37
|
+
const scaleAll = () => decks.forEach(scale);
|
|
38
|
+
|
|
39
|
+
// --- 2. Focus only genuinely scrollable code blocks -----------------
|
|
40
|
+
const isOverflowing = (el) => el.scrollWidth - el.clientWidth > OVERFLOW_TOLERANCE;
|
|
41
|
+
const updateCode = (code) => {
|
|
42
|
+
if (isOverflowing(code)) {
|
|
43
|
+
if (!code.hasAttribute("tabindex")) {
|
|
44
|
+
code.setAttribute("tabindex", "0");
|
|
45
|
+
code.setAttribute("role", "region");
|
|
46
|
+
code.setAttribute("aria-label", "Code block, scrollable");
|
|
47
|
+
}
|
|
48
|
+
} else if (code.getAttribute("tabindex") === "0") {
|
|
49
|
+
code.removeAttribute("tabindex");
|
|
50
|
+
code.removeAttribute("role");
|
|
51
|
+
code.removeAttribute("aria-label");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const blocks = document.querySelectorAll("pre > code");
|
|
55
|
+
const updateAllCode = () => blocks.forEach(updateCode);
|
|
56
|
+
|
|
57
|
+
const refresh = () => {
|
|
58
|
+
scaleAll();
|
|
59
|
+
updateAllCode();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const init = () => {
|
|
63
|
+
refresh();
|
|
64
|
+
|
|
65
|
+
// Fonts can finish loading after first paint and change what overflows.
|
|
66
|
+
if (document.fonts && document.fonts.ready) {
|
|
67
|
+
document.fonts.ready.then(refresh);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// React to size changes (dynamic content, the slide zoom). Code blocks
|
|
71
|
+
// are observed directly too: slides are fixed-size, so a block's
|
|
72
|
+
// overflow can change without the deck's own box ever resizing.
|
|
73
|
+
if ("ResizeObserver" in window) {
|
|
74
|
+
const observer = new window.ResizeObserver(() => refresh());
|
|
75
|
+
decks.forEach((deck) => observer.observe(deck));
|
|
76
|
+
blocks.forEach((code) => observer.observe(code));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Re-evaluate on window resize, debounced to one call per frame.
|
|
80
|
+
let frame = null;
|
|
81
|
+
window.addEventListener("resize", () => {
|
|
82
|
+
if (frame !== null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
frame = window.requestAnimationFrame(() => {
|
|
87
|
+
frame = null;
|
|
88
|
+
refresh();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (document.readyState === "loading") {
|
|
94
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
95
|
+
} else {
|
|
96
|
+
init();
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
</script>`
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { escapeHtml } from './escape.js'
|
|
2
|
+
import { pageScript } from './runtime-script.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build the outer HTML document shell that wraps the rendered slides.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} params
|
|
8
|
+
* @param {string} params.html - Marpit-rendered slide markup.
|
|
9
|
+
* @param {string} params.css - Combined CSS to inline in the document head.
|
|
10
|
+
* @param {object} [params.deckInfo] - Front-matter info (`title`, `description`).
|
|
11
|
+
* @param {string} [params.lang] - Document language. Defaults to `"en"`.
|
|
12
|
+
* @param {boolean} [params.runtimeScript] - Inline the code-block scrolling
|
|
13
|
+
* enhancement script. Defaults to `true`; set `false` for strict-CSP hosts.
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function buildDocument ({ html, css, deckInfo = {}, lang = 'en', runtimeScript = true }) {
|
|
17
|
+
const title = escapeHtml(deckInfo.title)
|
|
18
|
+
const description = escapeHtml(deckInfo.description)
|
|
19
|
+
|
|
20
|
+
return `<!DOCTYPE html>
|
|
21
|
+
<html lang="${escapeHtml(lang)}">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8">
|
|
24
|
+
<title>${title}</title>
|
|
25
|
+
<meta name="description" content="${description}">
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
27
|
+
<style>${css}</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<main>
|
|
31
|
+
${html}
|
|
32
|
+
</main>
|
|
33
|
+
${runtimeScript ? pageScript : ''}
|
|
34
|
+
</body>
|
|
35
|
+
</html>`
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
/** Absolute path to the bundled `themes/` directory. */
|
|
8
|
+
export const themesDir = resolve(here, '..', '..', 'themes')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* List the names of the bundled themes (without the `.css` extension).
|
|
12
|
+
* `document.css` (the injected base) and underscore-prefixed partials such as
|
|
13
|
+
* `_template.css` are not selectable themes and are excluded.
|
|
14
|
+
*
|
|
15
|
+
* @returns {Promise<string[]>}
|
|
16
|
+
*/
|
|
17
|
+
export async function listThemes () {
|
|
18
|
+
const entries = await readdir(themesDir)
|
|
19
|
+
return entries
|
|
20
|
+
.filter(name => name.endsWith('.css') && name !== 'document.css' && !name.startsWith('_'))
|
|
21
|
+
.map(name => name.replace(/\.css$/, ''))
|
|
22
|
+
.sort()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a theme to its CSS string. Accepts either the name of a bundled
|
|
27
|
+
* theme (e.g. `"basic"`) or, if `css` is provided, uses that instead. Marpit
|
|
28
|
+
* refuses theme CSS without an `@theme` meta comment, so one is prepended
|
|
29
|
+
* automatically when the caller's CSS doesn't declare it.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} [name] - Bundled theme name.
|
|
32
|
+
* @param {string} [css] - Raw CSS to use instead of a bundled theme.
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveThemeCSS (name, css) {
|
|
36
|
+
if (typeof css === 'string') {
|
|
37
|
+
return /@theme\s+\S/.test(css) ? css : `/* @theme custom */\n${css}`
|
|
38
|
+
}
|
|
39
|
+
const themeName = name || 'basic'
|
|
40
|
+
const available = await listThemes()
|
|
41
|
+
if (!available.includes(themeName)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Theme "${themeName}" does not exist. Available themes: ${available.join(', ')}`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return readFile(join(themesDir, `${themeName}.css`), 'utf8')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the base accessible-layout stylesheet that is always injected.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<string>}
|
|
53
|
+
*/
|
|
54
|
+
export async function readDocumentCSS () {
|
|
55
|
+
return readFile(join(themesDir, 'document.css'), 'utf8')
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make code blocks keyboard-scrollable as a safe, no-JS default: mark each as a
|
|
3
|
+
* labelled region and put it in the tab order. The inlined runtime script (see
|
|
4
|
+
* `src/core/runtime-script.js`) then *removes* these from any block that does
|
|
5
|
+
* not actually overflow, so only genuinely scrollable code stays focusable.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
8
|
+
*/
|
|
9
|
+
export function modifyCodeBlocks ($) {
|
|
10
|
+
$('pre code').each(function () {
|
|
11
|
+
$(this).attr('role', 'region')
|
|
12
|
+
$(this).attr('aria-label', 'Code block, scrollable')
|
|
13
|
+
$(this).attr('tabindex', '0')
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MIME_BY_EXT = {
|
|
5
|
+
'.png': 'image/png',
|
|
6
|
+
'.jpg': 'image/jpeg',
|
|
7
|
+
'.jpeg': 'image/jpeg',
|
|
8
|
+
'.gif': 'image/gif',
|
|
9
|
+
'.svg': 'image/svg+xml',
|
|
10
|
+
'.webp': 'image/webp',
|
|
11
|
+
'.avif': 'image/avif',
|
|
12
|
+
'.bmp': 'image/bmp',
|
|
13
|
+
'.ico': 'image/x-icon'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Matches the first url(...) token in a CSS value, quoted or bare. */
|
|
17
|
+
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/
|
|
18
|
+
|
|
19
|
+
/** A src that already points somewhere self-contained or remote — leave it. */
|
|
20
|
+
function isExternal (src) {
|
|
21
|
+
return /^(https?:)?\/\//.test(src) || src.startsWith('data:')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a local asset and return it as a `data:` URI, or `null` when it can't
|
|
26
|
+
* be inlined (unknown type, missing or unreadable file).
|
|
27
|
+
*
|
|
28
|
+
* Srcs are written as URLs, so before touching the filesystem the query string
|
|
29
|
+
* and fragment are dropped and percent-escapes decoded — `my%20image.png?v=2`
|
|
30
|
+
* resolves to `my image.png` on disk.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} src - Relative source path from the markup.
|
|
33
|
+
* @param {string} basePath - Directory to resolve `src` against.
|
|
34
|
+
* @returns {Promise<string | null>}
|
|
35
|
+
*/
|
|
36
|
+
async function toDataURI (src, basePath) {
|
|
37
|
+
const cleaned = src.split(/[?#]/)[0]
|
|
38
|
+
let decoded
|
|
39
|
+
try {
|
|
40
|
+
decoded = decodeURIComponent(cleaned)
|
|
41
|
+
} catch {
|
|
42
|
+
// Malformed percent-escapes — fall back to the raw path.
|
|
43
|
+
decoded = cleaned
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mime = MIME_BY_EXT[extname(decoded).toLowerCase()]
|
|
47
|
+
if (!mime) return null
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const filePath = join(basePath, decoded.replace(/^\.?\//, ''))
|
|
51
|
+
const data = await readFile(filePath)
|
|
52
|
+
return `data:${mime};base64,${data.toString('base64')}`
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing/unreadable asset — caller leaves the original reference intact.
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Portability pass over the deck's images: base64-inlines local assets into
|
|
61
|
+
* `data:` URIs (when `basePath` is known and `inlineAssets` is on) so the
|
|
62
|
+
* rendered deck is a single, self-contained file. Covers both `<img>` elements
|
|
63
|
+
* and slide background images (Marp's `` becomes a `background-image`
|
|
64
|
+
* style on the section). Remote and already-inlined sources are left untouched.
|
|
65
|
+
* Image sizing is left to the theme CSS so images scale with the slide.
|
|
66
|
+
*
|
|
67
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {string} [options.basePath] - Directory to resolve relative srcs against.
|
|
70
|
+
* @param {boolean} [options.inlineAssets=true] - Base64-inline local images.
|
|
71
|
+
*/
|
|
72
|
+
export async function modifyImg ($, { basePath, inlineAssets = true } = {}) {
|
|
73
|
+
if (!inlineAssets || !basePath) return
|
|
74
|
+
|
|
75
|
+
for (const el of $('img').toArray()) {
|
|
76
|
+
const $img = $(el)
|
|
77
|
+
const src = $img.attr('src')
|
|
78
|
+
if (!src || isExternal(src)) continue
|
|
79
|
+
|
|
80
|
+
const dataURI = await toDataURI(src, basePath)
|
|
81
|
+
if (dataURI) $img.attr('src', dataURI)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Slide backgrounds:  ends up as url(...) inside the section's
|
|
85
|
+
// style attribute, which <img>-only inlining would miss.
|
|
86
|
+
for (const el of $('section[style]').toArray()) {
|
|
87
|
+
const $section = $(el)
|
|
88
|
+
const style = $section.attr('style')
|
|
89
|
+
const match = CSS_URL_RE.exec(style)
|
|
90
|
+
if (!match) continue
|
|
91
|
+
|
|
92
|
+
const src = match[2]
|
|
93
|
+
if (isExternal(src)) continue
|
|
94
|
+
|
|
95
|
+
const dataURI = await toDataURI(src, basePath)
|
|
96
|
+
if (dataURI) $section.attr('style', style.replace(match[0], `url("${dataURI}")`))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn each Marpit `<section>` (a slide) into an accessible landmark:
|
|
3
|
+
* strips presentational Marpit attributes, adds an `aria-label`, gives the
|
|
4
|
+
* slide heading a stable id + `aria-describedby`, and appends a footer with
|
|
5
|
+
* screen-reader pagination. The responsive 16:9 scaling is applied by
|
|
6
|
+
* `themes/document.css` directly to `div.marpit > section` (so Marpit's
|
|
7
|
+
* theme scoping is preserved — the section must stay a child of `.marpit`).
|
|
8
|
+
*
|
|
9
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
10
|
+
*/
|
|
11
|
+
export function modifySection ($) {
|
|
12
|
+
$('section').each(function (index) {
|
|
13
|
+
const $section = $(this)
|
|
14
|
+
$section.removeAttr('data-theme')
|
|
15
|
+
$section.removeAttr('data-footer')
|
|
16
|
+
$section.removeAttr('data-paginate')
|
|
17
|
+
$section.removeAttr('data-marpit-pagination-total')
|
|
18
|
+
$section.removeAttr('data-class')
|
|
19
|
+
$section.removeAttr('style')
|
|
20
|
+
|
|
21
|
+
const backgroundImage = $section.attr('data-background-image')
|
|
22
|
+
if (backgroundImage !== undefined) {
|
|
23
|
+
$section.css('background-image', backgroundImage)
|
|
24
|
+
$section.removeAttr('data-background-image')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasFooter = $section.find('footer').length > 0
|
|
28
|
+
if (!hasFooter) $section.append('<footer></footer>')
|
|
29
|
+
const $footer = $section.find('footer')
|
|
30
|
+
|
|
31
|
+
// Marpit only sets data-marpit-pagination when `paginate: true`; fall back
|
|
32
|
+
// to the slide's position so labels never read "Slide undefined".
|
|
33
|
+
const pageNo = $section.attr('data-marpit-pagination') ?? String(index + 1)
|
|
34
|
+
$section.removeAttr('data-marpit-pagination')
|
|
35
|
+
|
|
36
|
+
// Only the slide's FIRST heading gets the stable id and describedby —
|
|
37
|
+
// applying them to every heading would emit duplicate ids and concatenate
|
|
38
|
+
// all heading text into the label.
|
|
39
|
+
const $heading = $section.find(':header').first()
|
|
40
|
+
if ($heading.length > 0) {
|
|
41
|
+
$heading.attr('aria-describedby', `page-number-${pageNo}`)
|
|
42
|
+
$heading.attr('id', `slide-${pageNo}`)
|
|
43
|
+
$section.attr('aria-label', `Slide ${pageNo}: ${$heading.text()}`)
|
|
44
|
+
} else {
|
|
45
|
+
// No heading to point at: label the slide plainly, with no dangling colon.
|
|
46
|
+
$section.attr('aria-label', `Slide ${pageNo}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$footer.append(`
|
|
50
|
+
<div class="pagination">
|
|
51
|
+
<p class="visually-hidden">End of slide ${pageNo}</p>
|
|
52
|
+
<p id="page-number-${pageNo}" aria-hidden="true">
|
|
53
|
+
<span class="visually-hidden">slide </span>
|
|
54
|
+
<span>${pageNo}</span>
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
`)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio'
|
|
2
|
+
import { modifySection } from './_section.js'
|
|
3
|
+
import { modifyImg } from './_img.js'
|
|
4
|
+
import { modifyCodeBlocks } from './_code.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply all accessibility transforms to a full HTML document string.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} documentHTML
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {string} [options.basePath] - Directory for resolving local image srcs.
|
|
12
|
+
* @param {boolean} [options.inlineAssets] - Base64-inline local images.
|
|
13
|
+
* @returns {Promise<string>}
|
|
14
|
+
*/
|
|
15
|
+
export async function applyTransforms (documentHTML, options = {}) {
|
|
16
|
+
const $ = cheerio.load(documentHTML)
|
|
17
|
+
modifySection($)
|
|
18
|
+
await modifyImg($, options)
|
|
19
|
+
modifyCodeBlocks($)
|
|
20
|
+
return $.html()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { modifySection, modifyImg, modifyCodeBlocks }
|
package/src/eleventy.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { renderDeck, readDeckInfo } from './core/render.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} PluginOptions
|
|
7
|
+
* @property {string} [extension='deck'] - File extension that marks a deck.
|
|
8
|
+
* Deck files contain Marp markdown. Defaults to `deck` (e.g. `talk.deck`) so
|
|
9
|
+
* it never clobbers your site's normal `.md` files. Set to `md` to treat
|
|
10
|
+
* *every* markdown file as a deck (this fully overrides Eleventy's built-in
|
|
11
|
+
* markdown rendering).
|
|
12
|
+
* @property {string} [theme] - Force a theme for all decks. When omitted, each
|
|
13
|
+
* deck's own front-matter `theme` (falling back to the default) is used.
|
|
14
|
+
* @property {string} [lang] - Document language attribute.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Eleventy plugin: render Marp decks as accessible HTML pages.
|
|
19
|
+
*
|
|
20
|
+
* Thin wrapper around {@link renderDeck} — all rendering logic lives in the
|
|
21
|
+
* core engine; this only wires it into Eleventy's template pipeline.
|
|
22
|
+
*
|
|
23
|
+
* @param {import('@11ty/eleventy').UserConfig} eleventyConfig
|
|
24
|
+
* @param {PluginOptions} [options]
|
|
25
|
+
*/
|
|
26
|
+
export default function accessibleMarpPlugin (eleventyConfig, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
extension = 'deck',
|
|
29
|
+
theme,
|
|
30
|
+
lang
|
|
31
|
+
} = options
|
|
32
|
+
|
|
33
|
+
eleventyConfig.addTemplateFormats(extension)
|
|
34
|
+
|
|
35
|
+
eleventyConfig.addExtension(extension, {
|
|
36
|
+
// We read the raw file ourselves so Marp's front-matter directives survive.
|
|
37
|
+
read: false,
|
|
38
|
+
getData: async (inputPath) => {
|
|
39
|
+
const markdown = await readFile(inputPath, 'utf8')
|
|
40
|
+
return readDeckInfo(markdown)
|
|
41
|
+
},
|
|
42
|
+
compile: (_inputContent, inputPath) => async (data) => {
|
|
43
|
+
const markdown = await readFile(inputPath, 'utf8')
|
|
44
|
+
return renderDeck(markdown, {
|
|
45
|
+
theme: theme ?? data?.theme,
|
|
46
|
+
basePath: dirname(inputPath),
|
|
47
|
+
lang: lang ?? data?.lang
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { renderDeck, readDeckInfo }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { renderDeck, readDeckInfo } from './core/render.js'
|
|
4
|
+
import { listThemes, themesDir } from './core/themes.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Render a Marp markdown file into an accessible HTML document. Relative image
|
|
8
|
+
* srcs are resolved (and inlined) against the file's own directory by default.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} path - Path to a `.md` deck file.
|
|
11
|
+
* @param {import('./core/render.js').RenderOptions} [options]
|
|
12
|
+
* @returns {Promise<string>}
|
|
13
|
+
*/
|
|
14
|
+
export async function renderDeckFile (path, options = {}) {
|
|
15
|
+
const markdown = await readFile(path, 'utf8')
|
|
16
|
+
return renderDeck(markdown, { basePath: dirname(path), ...options })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { renderDeck, readDeckInfo, listThemes, themesDir }
|