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 ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [2.0.0] — 2026
6
+
7
+ Version 2 is a ground-up modernisation. The rendered output is now a single, self-contained, responsive, accessible file, and the project ships as an ESM package usable as a CLI, a library, an Eleventy plugin, or a GitHub Action.
8
+
9
+ ### Added
10
+
11
+ - **Single-file output** — local images are base64-inlined into the page, so a built deck is one self-contained HTML file (`inlineAssets`, `basePath` render options).
12
+ - **Responsive 16:9 scaling** — each fixed-size slide is scaled to the window as one rigid unit via CSS `zoom` (a small inlined script sets the scale factor), so text, spacing, and layout shrink together and nothing reflows. Browser page zoom still enlarges the deck (the script compensates via `devicePixelRatio`), per WCAG 1.4.4.
13
+ - **Layout helpers** — slide-native `box`, `stack`, `cluster`, `columns`, `grid`, `center`, `frame`, `cover`, `sidebar`, and `overlay-centre` classes.
14
+ - **Smarter code-block focus** — code blocks are keyboard-scrollable by default (no-JS safe), and a tiny inlined script removes the tab stop from blocks that don't actually overflow, re-checking on resize.
15
+ - **`build-all <dir>`** CLI command — builds every deck in a folder and writes an accessible landing page linking them. A failing deck doesn't abort the site: the rest are built, the failure is reported, and the command exits non-zero with a summary.
16
+ - **GitHub Action** — a reusable composite `action.yml` plus an example Pages-deploy workflow, so decks can be published without a command line.
17
+ - **Theme template** — `themes/_template.css`, a documented, self-contained starter to copy and recolour.
18
+ - **`high-contrast` theme** — a second bundled theme (pure black/white, auto light/dark).
19
+ - **`runtimeScript` render option** — set `false` to omit the inlined code-block script for strict-CSP hosts.
20
+ - **Tests** (`node:test`) and **CI** covering accessibility invariants, image inlining, scaling, layout helpers, theme resolution, the CLI, and the Eleventy plugin — with 100% line, branch, and function coverage enforced in CI.
21
+
22
+ ### Changed
23
+
24
+ - Rewritten as **ESM** with package `exports` (`.` and `./eleventy`) and an `accessible-marp` bin.
25
+ - Transforms are **element-scoped** files — `_section.js`, `_img.js`, `_code.js`.
26
+ - The `basic` theme is re-authored in `em` units with automatic light/dark modes.
27
+ - Dependencies trimmed to the five that are actually used.
28
+ - js-beautify is loaded on demand — rendering with `prettify: false` never loads it.
29
+ - Node 24 (LTS) is the development, CI, and Action runtime, pinned in `.nvmrc`; the npm `engines` field is no longer set.
30
+
31
+ ### Fixed
32
+
33
+ - Code fences without a language (or with an unrecognised one) now escape their content instead of injecting it into the page as raw HTML.
34
+ - Slides with several headings no longer emit duplicate `slide-N` ids or concatenate every heading into the slide's `aria-label` — only the first heading is used. Heading-less slides are labelled `"Slide N"` with no dangling colon.
35
+ - Slide background images (`![bg](…)`) are now base64-inlined like every other image, so decks with backgrounds stay a single self-contained file. The consumed `data-background-image` and `data-marpit-pagination-total` attributes are stripped from the output.
36
+ - Applying a helper to a whole slide with `<!-- _class: … -->` now actually works: Marpit scopes bare class selectors to descendants of the slide, so the documented pattern silently did nothing. Themes now ship slide-level variants (`cover`, `center`, `stack`) written as `section.X`, and Marpit's bookkeeping `data-class` attribute is stripped.
37
+ - The `css` render option no longer requires a `/* @theme */` comment — one is prepended automatically, instead of Marpit rejecting the CSS.
38
+ - Image srcs are treated as URLs when inlining: percent-escapes are decoded (`my%20image.png` finds `my image.png` on disk) and query strings/fragments are ignored, instead of silently leaving a broken reference.
39
+ - The runtime script re-evaluates code-block focus after web fonts finish loading and observes the blocks directly — slides are fixed-size, so a block's overflow can change without the deck resizing.
40
+ - The CLI recognises backslash-separated (Windows) paths as paths rather than deck names, and CI now also runs the suite on Windows.
41
+ - Slides in decks without `paginate: true` are now numbered by position instead of being labelled "Slide undefined".
42
+
43
+ ### Security
44
+
45
+ - The GitHub Action passes its inputs to the build script via environment variables instead of interpolating `${{ }}` expressions into bash, closing a shell-injection surface.
46
+
47
+ ### Removed
48
+
49
+ - The `imageWidth` option (images are now sized responsively by CSS).
50
+ - The SASS build and the dead dependencies from 1.x (`node-sass`, `html-minifier`, and others).
51
+
52
+ [2.0.0]: https://github.com/abbott567/accessible-marp-decks/releases/tag/v2.0.0
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Craig Abbott
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,21 @@
1
+ Accessible Marp Decks
2
+ Copyright (c) 2026 Craig Abbott
3
+
4
+ This product is licensed under the ISC License (see LICENSE).
5
+
6
+ It builds on, and bundles or depends on, the following third-party work:
7
+
8
+ - Marp / Marpit (https://marp.app/) — MIT License.
9
+ Used to render Marp markdown into slide HTML and CSS.
10
+
11
+ - highlight.js (https://highlightjs.org/) — BSD 3-Clause License.
12
+ Used for syntax highlighting of fenced code blocks.
13
+
14
+ - The "a11y-light" and "a11y-dark" syntax-highlighting colour schemes by
15
+ Eric Bailey (https://github.com/ericwbailey/a11y-syntax-highlighting) —
16
+ MIT License. The bundled themes' code colours are derived from these.
17
+
18
+ The layout helpers in the bundled themes (box, stack, cluster, columns, grid,
19
+ center, frame, cover, sidebar, overlay-centre) are original CSS written for this
20
+ project using standard CSS grid and flexbox. They are conceptually inspired by
21
+ common layout patterns but do not copy any third-party implementation.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # Accessible Marp Decks
2
+
3
+ Render [Marp](https://marp.app/) markdown slide decks into a **single, self-contained, accessible HTML page** — every slide is a labelled landmark, headings have stable ids, code blocks that scroll are keyboard-focusable, pagination is announced to screen readers, images are inlined, and the whole thing scales to any window in 16:9 without reflowing.
4
+
5
+ Use it four ways:
6
+
7
+ - **CLI** — build a deck (or a whole folder) into shareable HTML.
8
+ - **Library** — `import { renderDeck }` and render Marp markdown anywhere.
9
+ - **Eleventy plugin** — drop deck files into an [Eleventy](https://www.11ty.dev/) site.
10
+ - **GitHub Action** — publish decks to GitHub Pages on push, no command line.
11
+
12
+ This project isn't a replacement for Marp or the VSCode preview extension — it's for **sharing** your slides in an accessible format.
13
+
14
+ ## Requirements
15
+
16
+ - Node.js 24 (LTS) — the version pinned in [`.nvmrc`](.nvmrc). Older versions may work but aren't tested.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install accessible-marp-decks
22
+ ```
23
+
24
+ ## Writing slides
25
+
26
+ Slides are [Marpit markdown](https://marpit.marp.app/markdown). A deck starts with front matter and separates slides with `---`:
27
+
28
+ ```markdown
29
+ ---
30
+ title: My Talk
31
+ description: A short description used for the page <title> and meta description.
32
+ paginate: true
33
+ marp: true
34
+ theme: basic
35
+ ---
36
+
37
+ # My Talk
38
+
39
+ Intro text.
40
+
41
+ ---
42
+
43
+ ## Second slide
44
+
45
+ More content.
46
+ ```
47
+
48
+ Compose richer slides with the [layout helpers](docs/layouts.md) (`columns`, `grid`, `cluster`, `frame`, …). To preview as you write, install the [Marp for VSCode extension](https://marketplace.visualstudio.com/items?itemName=marp-team.marp-vscode).
49
+
50
+ ## What you get out
51
+
52
+ A built deck is **one HTML file**. CSS is inlined, images are base64-encoded into the page, and the slides scale to fit the window. Nothing else needs to ship alongside it (except any linked `demos/` pages), so it's trivial to email, host, or attach.
53
+
54
+ ## CLI usage
55
+
56
+ The package installs an `accessible-marp` binary.
57
+
58
+ ```sh
59
+ # Build a named deck (looked up under examples/decks/<name>/slides.md by default)
60
+ accessible-marp build layouts --theme basic
61
+
62
+ # Build a specific markdown file to a chosen output directory
63
+ accessible-marp build ./slides.md --out ./public
64
+
65
+ # Build every deck in a folder into a browsable site with a landing page
66
+ accessible-marp build-all decks --out _site
67
+
68
+ # List the bundled themes
69
+ accessible-marp themes
70
+ ```
71
+
72
+ Options:
73
+
74
+ | Flag | Description |
75
+ | --- | --- |
76
+ | `--theme`, `-t` | Theme to use. Defaults to the deck's front-matter `theme`. |
77
+ | `--out`, `-o` | Output directory. Defaults to `dist/decks/<deck>` (or `dist/site` for `build-all`). |
78
+ | `--decks-dir` | Where named decks live. Defaults to `examples/decks`. |
79
+
80
+ `build` writes a single `slides.html` (plus any `demos/` folder). `build-all` writes each deck to `<out>/<name>/index.html` and an accessible landing page at `<out>/index.html`; if a deck fails, the rest are still built and the command exits non-zero with a summary. The legacy `npm run build deck=<name> theme=<name>` form still works.
81
+
82
+ ## Library usage
83
+
84
+ ```js
85
+ import { renderDeck, renderDeckFile, listThemes } from 'accessible-marp-decks'
86
+
87
+ const html = await renderDeck(markdownString, { theme: 'basic', basePath: './my-deck' })
88
+
89
+ // or straight from a file (basePath defaults to the file's directory)
90
+ const html2 = await renderDeckFile('./my-deck/slides.md', { theme: 'basic' })
91
+
92
+ await listThemes() // ['basic', 'high-contrast']
93
+ ```
94
+
95
+ ### `renderDeck(markdown, options)` → `Promise<string>`
96
+
97
+ | Option | Default | Description |
98
+ | --- | --- | --- |
99
+ | `theme` | front matter, else `basic` | A bundled theme name. |
100
+ | `css` | — | Raw theme CSS, used instead of a bundled theme. An `/* @theme */` comment is added automatically if missing. |
101
+ | `documentCss` | bundled `document.css` | Override the base accessible-layout CSS. |
102
+ | `basePath` | — | Directory used to resolve and inline relative image paths. |
103
+ | `inlineAssets` | `true` | Base64-inline local images for a single-file output. |
104
+ | `lang` | `'en'` | Document `lang` attribute. |
105
+ | `runtimeScript` | `true` | Inline the code-block scrolling enhancement script. Set `false` for strict-CSP hosts. |
106
+ | `prettify` | `true` | Pretty-print the HTML output. |
107
+
108
+ ## Eleventy plugin
109
+
110
+ Render Marp decks as accessible pages inside an [Eleventy](https://www.11ty.dev/) (3.0+) site.
111
+
112
+ ```js
113
+ // eleventy.config.js
114
+ import accessibleMarp from 'accessible-marp-decks/eleventy'
115
+
116
+ export default function (eleventyConfig) {
117
+ eleventyConfig.addPlugin(accessibleMarp, {
118
+ // theme: 'basic', // force one theme for all decks (optional)
119
+ })
120
+ }
121
+ ```
122
+
123
+ By default the plugin registers a **`.deck`** extension, so any `*.deck` file (containing Marp markdown) is built as an accessible deck page. Using a dedicated extension means your site's normal `.md` files are left untouched.
124
+
125
+ | Option | Default | Description |
126
+ | --- | --- | --- |
127
+ | `extension` | `'deck'` | File extension that marks a deck. Set to `'md'` to treat **every** markdown file as a deck (fully overrides Eleventy's markdown rendering). |
128
+ | `theme` | per-deck front matter | Force a theme for all decks. |
129
+ | `lang` | `'en'` | Document language. |
130
+
131
+ Images are inlined into each page automatically. Companion `demos/` folders (standalone HTML pages your slides link to) are **not** copied by the plugin — add a passthrough copy for them in your Eleventy config if you use them:
132
+
133
+ ```js
134
+ eleventyConfig.addPassthroughCopy('content/**/demos/**')
135
+ ```
136
+
137
+ A working demo lives in [`examples/eleventy-demo`](examples/eleventy-demo) — run it with `npm run demo`.
138
+
139
+ ## GitHub Action
140
+
141
+ Publish decks to GitHub Pages on every push — no command line. Add one workflow and enable Pages; full walkthrough in [docs/github-action.md](docs/github-action.md).
142
+
143
+ ```yaml
144
+ - name: Build decks
145
+ uses: abbott567/accessible-marp-decks@v2
146
+ with:
147
+ decks-dir: decks
148
+ out: _site
149
+ - uses: actions/upload-pages-artifact@v5
150
+ with:
151
+ path: _site
152
+ ```
153
+
154
+ ## Layouts and themes
155
+
156
+ - **Layout helpers** — `box`, `stack`, `cluster`, `columns`, `grid`, `center`, `frame`, `cover`, `sidebar`, `overlay-centre`. See [docs/layouts.md](docs/layouts.md).
157
+ - **Themes** live in [`themes/`](themes). Two are bundled: **`basic`** (the default) and **`high-contrast`** — both neutral designs with automatic light/dark modes via `prefers-color-scheme`, using the reader's `system-ui` font. To make your own, copy [`themes/_template.css`](themes/_template.css) and recolour it — see [docs/creating-themes.md](docs/creating-themes.md).
158
+
159
+ ## Accessibility
160
+
161
+ Every rendered deck is a labelled-landmark structure with screen-reader pagination, keyboard-scrollable code blocks, and reflow-free 16:9 scaling. The full list of guarantees is in [docs/accessibility.md](docs/accessibility.md).
162
+
163
+ ## Development
164
+
165
+ ```sh
166
+ npm install
167
+ npm test # node:test — accessibility, images, scaling, layouts, CLI, Eleventy
168
+ npm run lint # standard
169
+ npm run build:site # build the example decks into _site/
170
+ npm run demo # build the Eleventy demo site
171
+ ```
172
+
173
+ The publishable package is the engine (`src/`) and `themes/`. The `layouts` deck under `examples/decks` is a demo of the layouts the renderer supports and is excluded from the npm tarball.
174
+
175
+ ## Contributing
176
+
177
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). Security reports: [SECURITY.md](SECURITY.md).
178
+
179
+ ## License
180
+
181
+ [ISC](LICENSE). Third-party credits are in [NOTICE](NOTICE).
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "accessible-marp-decks",
3
+ "version": "2.0.0",
4
+ "description": "Render Marp markdown slide decks into a standalone, accessible HTML format. Usable as a CLI, an importable function, or an Eleventy plugin.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./eleventy": "./src/eleventy.js"
9
+ },
10
+ "bin": {
11
+ "accessible-marp": "src/cli.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "themes",
16
+ "README.md",
17
+ "LICENSE",
18
+ "NOTICE",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "node src/cli.js build",
23
+ "build:site": "node src/cli.js build-all examples/decks --out _site",
24
+ "test": "node --test",
25
+ "coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100",
26
+ "lint": "standard src test",
27
+ "demo": "cd examples/eleventy-demo && eleventy"
28
+ },
29
+ "keywords": [
30
+ "marp",
31
+ "marpit",
32
+ "accessibility",
33
+ "a11y",
34
+ "slides",
35
+ "presentation",
36
+ "eleventy",
37
+ "eleventy-plugin"
38
+ ],
39
+ "author": "Craig Abbott",
40
+ "license": "ISC",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/abbott567/accessible-marp-decks.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/abbott567/accessible-marp-decks/issues"
47
+ },
48
+ "homepage": "https://github.com/abbott567/accessible-marp-decks#readme",
49
+ "dependencies": {
50
+ "@marp-team/marpit": "^3.1.3",
51
+ "cheerio": "^1.0.0",
52
+ "gray-matter": "^4.0.3",
53
+ "highlight.js": "^11.10.0",
54
+ "js-beautify": "^1.15.1"
55
+ },
56
+ "peerDependencies": {
57
+ "@11ty/eleventy": ">=3.0.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@11ty/eleventy": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@11ty/eleventy": "^3.0.0",
66
+ "standard": "^17.1.2"
67
+ }
68
+ }
package/src/cli.js ADDED
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import { readFile, writeFile, mkdir, cp, stat, readdir } from 'node:fs/promises'
4
+ import { dirname, join, basename, resolve } from 'node:path'
5
+ import { renderDeck, readDeckInfo } from './core/render.js'
6
+ import { listThemes } from './core/themes.js'
7
+ import { escapeHtml } from './core/escape.js'
8
+
9
+ const USAGE = `accessible-marp — build accessible HTML decks from Marp markdown
10
+
11
+ Usage:
12
+ accessible-marp build <deck|path> [--theme <name>] [--out <dir>] [--decks-dir <dir>]
13
+ accessible-marp build-all <dir> [--theme <name>] [--out <dir>]
14
+ accessible-marp themes
15
+ accessible-marp --help
16
+
17
+ Arguments:
18
+ <deck|path> A deck name (resolved under --decks-dir) or a path to a .md file.
19
+ <dir> A directory of decks (each a folder containing slides.md).
20
+
21
+ Options:
22
+ --theme, -t <name> Theme to use. Defaults to the deck's front-matter theme.
23
+ --out, -o <dir> Output directory. Defaults to dist/decks/<deck> (build)
24
+ or dist/site (build-all).
25
+ --decks-dir <dir> Where named decks live. Defaults to examples/decks.
26
+ --help, -h Show this help.
27
+
28
+ Examples:
29
+ accessible-marp build layouts --theme basic
30
+ accessible-marp build ./slides.md --out ./public
31
+ accessible-marp build-all examples/decks --out _site
32
+ npm run build deck=layouts theme=basic`
33
+
34
+ async function exists (p) {
35
+ try {
36
+ await stat(p)
37
+ return true
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ /** Accept legacy `key=value` tokens (e.g. `deck=x theme=y`) alongside flags. */
44
+ function extractLegacyTokens (argv) {
45
+ const legacy = {}
46
+ const rest = []
47
+ for (const arg of argv) {
48
+ const m = /^(deck|theme|out)=(.*)$/.exec(arg)
49
+ if (m) legacy[m[1]] = m[2]
50
+ else rest.push(arg)
51
+ }
52
+ return { legacy, rest }
53
+ }
54
+
55
+ /**
56
+ * Render one deck's markdown to `<outDir>/<htmlName>` and copy any `demos/`.
57
+ * Returns metadata about the built deck.
58
+ */
59
+ async function buildOne ({ mdPath, sourceDir, deckName, outDir, theme, htmlName }) {
60
+ const markdown = await readFile(mdPath, 'utf8')
61
+ const html = await renderDeck(markdown, { theme, basePath: sourceDir })
62
+
63
+ await mkdir(outDir, { recursive: true })
64
+ await writeFile(join(outDir, htmlName), html)
65
+
66
+ // Images are base64-inlined into the page, so only companion `demos/`
67
+ // (standalone linked HTML pages) need copying next to the output.
68
+ const demosSrc = join(sourceDir, 'demos')
69
+ if (await exists(demosSrc)) {
70
+ await cp(demosSrc, join(outDir, 'demos'), { recursive: true })
71
+ }
72
+
73
+ const info = readDeckInfo(markdown)
74
+ return { deckName, title: info.title || deckName, description: info.description || '' }
75
+ }
76
+
77
+ async function buildCommand (deckArg, opts) {
78
+ const decksDir = opts['decks-dir'] || 'examples/decks'
79
+
80
+ // Resolve the markdown source: a path, or a named deck under decksDir.
81
+ // Anything containing a separator (either flavour, so Windows paths work)
82
+ // or ending in .md is a path; a bare word is a deck name.
83
+ let mdPath
84
+ let deckName
85
+ let sourceDir
86
+ if (deckArg.endsWith('.md') || /[\\/]/.test(deckArg)) {
87
+ mdPath = resolve(deckArg)
88
+ sourceDir = dirname(mdPath)
89
+ deckName = basename(mdPath, '.md')
90
+ // A conventional `<name>/slides.md` should be named after its folder.
91
+ if (deckName === 'slides') deckName = basename(sourceDir)
92
+ } else {
93
+ deckName = deckArg
94
+ sourceDir = resolve(decksDir, deckName)
95
+ mdPath = join(sourceDir, 'slides.md')
96
+ }
97
+
98
+ if (!(await exists(mdPath))) {
99
+ throw new Error(`Deck not found: ${mdPath}`)
100
+ }
101
+
102
+ const outDir = resolve(opts.out || join('dist', 'decks', deckName))
103
+ await buildOne({ mdPath, sourceDir, deckName, outDir, theme: opts.theme, htmlName: 'slides.html' })
104
+ console.log(`Built "${deckName}" → ${join(outDir, 'slides.html')}`)
105
+ }
106
+
107
+ async function buildAllCommand (dir, opts) {
108
+ const root = resolve(dir)
109
+ if (!(await exists(root))) throw new Error(`Directory not found: ${root}`)
110
+
111
+ const entries = await readdir(root, { withFileTypes: true })
112
+ const outRoot = resolve(opts.out || join('dist', 'site'))
113
+
114
+ const built = []
115
+ const failures = []
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue
118
+ const sourceDir = join(root, entry.name)
119
+ const mdPath = join(sourceDir, 'slides.md')
120
+ if (!(await exists(mdPath))) continue
121
+
122
+ // Each deck becomes /<name>/index.html for clean URLs on a static host.
123
+ // One broken deck must not abort the site — collect failures and keep going.
124
+ try {
125
+ const meta = await buildOne({
126
+ mdPath,
127
+ sourceDir,
128
+ deckName: entry.name,
129
+ outDir: join(outRoot, entry.name),
130
+ theme: opts.theme,
131
+ htmlName: 'index.html'
132
+ })
133
+ built.push(meta)
134
+ console.log(`Built "${entry.name}" → ${join(outRoot, entry.name, 'index.html')}`)
135
+ } catch (err) {
136
+ failures.push(entry.name)
137
+ console.error(`Failed "${entry.name}": ${err.message}`)
138
+ }
139
+ }
140
+
141
+ if (built.length === 0 && failures.length === 0) {
142
+ throw new Error(`No decks (folders containing slides.md) found in ${root}`)
143
+ }
144
+
145
+ if (built.length > 0) {
146
+ await writeFile(join(outRoot, 'index.html'), renderIndex(built))
147
+ console.log(`Wrote index → ${join(outRoot, 'index.html')} (${built.length} decks)`)
148
+ }
149
+
150
+ if (failures.length > 0) {
151
+ throw new Error(`${failures.length} of ${built.length + failures.length} decks failed to build`)
152
+ }
153
+ }
154
+
155
+ /** A minimal, accessible landing page linking to each built deck. */
156
+ function renderIndex (decks) {
157
+ const items = decks
158
+ .sort((a, b) => a.title.localeCompare(b.title))
159
+ .map(d => ` <li>
160
+ <a href="./${encodeURIComponent(d.deckName)}/">${escapeHtml(d.title)}</a>
161
+ ${d.description ? `<p>${escapeHtml(d.description)}</p>` : ''}
162
+ </li>`)
163
+ .join('\n')
164
+
165
+ return `<!DOCTYPE html>
166
+ <html lang="en">
167
+ <head>
168
+ <meta charset="UTF-8">
169
+ <title>Slide decks</title>
170
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
171
+ <style>
172
+ body { font-family: system-ui, sans-serif; max-width: 44rem; margin: 0 auto; padding: 2rem 1rem; line-height: 1.5; }
173
+ ul { list-style: none; padding: 0; }
174
+ li { margin: 0 0 1.5rem; }
175
+ a { font-size: 1.25rem; }
176
+ p { margin: .25rem 0 0; color: #555; }
177
+ @media (prefers-color-scheme: dark) {
178
+ body { background: #111; color: #eee; } p { color: #aaa; } a { color: #8ab4f8; }
179
+ }
180
+ </style>
181
+ </head>
182
+ <body>
183
+ <main>
184
+ <h1>Slide decks</h1>
185
+ <ul>
186
+ ${items}
187
+ </ul>
188
+ </main>
189
+ </body>
190
+ </html>
191
+ `
192
+ }
193
+
194
+ async function main () {
195
+ const { legacy, rest } = extractLegacyTokens(process.argv.slice(2))
196
+
197
+ const { values, positionals } = parseArgs({
198
+ args: rest,
199
+ allowPositionals: true,
200
+ options: {
201
+ theme: { type: 'string', short: 't' },
202
+ out: { type: 'string', short: 'o' },
203
+ 'decks-dir': { type: 'string' },
204
+ help: { type: 'boolean', short: 'h' }
205
+ }
206
+ })
207
+
208
+ if (values.help) {
209
+ console.log(USAGE)
210
+ return
211
+ }
212
+
213
+ const command = positionals[0]
214
+
215
+ if (command === 'themes') {
216
+ const themes = await listThemes()
217
+ console.log(themes.join('\n'))
218
+ return
219
+ }
220
+
221
+ const opts = {
222
+ theme: values.theme || legacy.theme,
223
+ out: values.out || legacy.out,
224
+ 'decks-dir': values['decks-dir']
225
+ }
226
+
227
+ if (command === 'build-all') {
228
+ const dir = positionals[1]
229
+ if (!dir) throw new Error('No directory specified. See --help.')
230
+ await buildAllCommand(dir, opts)
231
+ return
232
+ }
233
+
234
+ if (command === 'build' || legacy.deck) {
235
+ const deckArg = positionals[1] || legacy.deck
236
+ if (!deckArg) throw new Error('No deck specified. See --help.')
237
+ await buildCommand(deckArg, opts)
238
+ return
239
+ }
240
+
241
+ console.log(USAGE)
242
+ }
243
+
244
+ main().catch((err) => {
245
+ console.error(`Error: ${err.message}`)
246
+ process.exitCode = 1
247
+ })
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escape a value for safe insertion into HTML text or attribute content.
3
+ * Nullish values become the empty string. The one escape helper for the whole
4
+ * codebase — template shell, CLI index page, and highlight fallback all share
5
+ * the same rules.
6
+ *
7
+ * @param {unknown} value
8
+ * @returns {string}
9
+ */
10
+ export function escapeHtml (value) {
11
+ return String(value ?? '')
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ }
@@ -0,0 +1,29 @@
1
+ import hljs from 'highlight.js'
2
+ import { Marpit } from '@marp-team/marpit'
3
+ import { escapeHtml } from './escape.js'
4
+
5
+ /**
6
+ * Create a Marpit instance configured the way this project expects:
7
+ * HTML enabled, smart typography, and syntax highlighting via highlight.js.
8
+ *
9
+ * @returns {import('@marp-team/marpit').Marpit}
10
+ */
11
+ export function createMarpit () {
12
+ return new Marpit({
13
+ markdown: {
14
+ html: true,
15
+ linkify: true,
16
+ breaks: true,
17
+ typographer: true,
18
+ highlight (str, lang) {
19
+ // `ignoreIllegals` means highlight() won't throw for a language we've
20
+ // already confirmed is registered, so no defensive catch is needed.
21
+ if (lang && hljs.getLanguage(lang)) {
22
+ const highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
23
+ return `<pre class="hljs"><code>${highlighted}</code></pre>`
24
+ }
25
+ return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
26
+ }
27
+ }
28
+ })
29
+ }