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.
@@ -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 `![bg](…)` 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: ![bg](…) 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 }
@@ -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 }