Audience: an AI coding agent that must understand, integrate, extend, or
debug this framework in any repo. This document is the reference. Prefer it
over the human-oriented README.md when deciding behavior or diagnosing a
failure.
Document contract: every claim here either (a) describes the shipped behavior as observable in the files named, or (b) is explicitly marked as a convention / non-goal. If source and FSD disagree, source wins and the FSD is the bug — update it in the same PR.
tutorial-forge is a build-time pipeline that converts one markdown deck file into one self-contained static HTML package suitable for:
- Serving from any static host (GitHub Pages, Netlify, nginx, static CDN).
- Embedding in a Go binary via
//go:embed. - Attaching to a release artifact.
The pipeline applies five non-trivial transforms during the build:
| # | Transform | Why |
|---|---|---|
| 1 | ```mermaid → SVG (headless Puppeteer + mermaid.min.js) |
Ship no runtime; every diagram gets the interactive viewer uniformly. |
| 2 | First-N acronym expansion → auto-link to glossary | First N occurrences become "Expansion (ACRONYM)"; every subsequent occurrence becomes <a class="acronym-link" href="#glossary-acr">ACRONYM</a>. The glossary section is auto-generated at the end of the deck with one <dt id="glossary-acr"> per acronym. Idempotent across builds, including the "ACRONYM: Expansion" title-form. A floating "← Return to Previous" button appears when the current hash points at a glossary entry; clicking fires history.back(). |
| 3 | Heading-ID resolution (explicit pins first, then auto-slug with dedup) | Stable TOC anchors even when heading text changes. |
| 4 | Speaker-note extraction (note: … → collapsible <details>) |
Separates presenter notes from reader content in-source. |
| 5 | Persistent TOC + Graphs sidebar (REQUIRED) | Fixed-position left-column navigation. Section links auto-collected from headings; Graphs sub-section auto-collected from .svg-figure wrappers with nearest-heading labels. Scroll-sync highlights the current section. |
Everything else is passthrough: GFM markdown via marked, zenburn
highlighting for fenced code, standard markdown tables/lists/blockquotes.
- Not a slide deck engine. Output is one scrollable long-form HTML page,
not reveal.js. The deck's
---separators become visual rules, not slide boundaries. (This was a deliberate pivot — see commit0f12fbfain the mdemg repo and the FSD's §12 Rollback for context.) - Not a static-site generator. Produces exactly one page per invocation. Multi-page / multi-deck is out of scope.
- Not a Mermaid dialect extender. Uses stock mermaid.min.js 11.x. If a diagram doesn't render in the official playground, it won't render here.
- Not a runtime. The emitted bundle has only one tiny client-side JS
file (
svg-viewer.js, ~9 KB, zero deps). No build step at serve time. - Not a PR / commit author. The framework writes files and exits; CI / Makefile / human / another agent owns the git flow.
tutorial.config.json
│
▼
┌──────────────────── cli.mjs ────────────────────┐
│ (parses args, loads config) │
└──────────────────┬────────────┬─────────────────┘
│ build │ expand-acronyms
▼ ▼
build.mjs transforms/acronyms.mjs
│
┌─────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
transforms/ renderers/ marked.parse
acronyms.mjs mermaid.mjs (upstream npm pkg)
(JS, pure) (Puppeteer + mermaid)
│ │ │
└─────────────┼───────────────────────┘
▼
<config.output>/
index.html
favicon.ico
SOURCE_SHA256.txt
assets/
css/
js/
svg/
- CLI loads JSON config, resolves paths relative to the config file's dir.
- Deck markdown is read + preprocessed:
- Optional YAML frontmatter stripped.
./assets/→assets/path rewrites.- Acronym expansion (idempotent, see §4.2).
- Deck is split on
\n---\ninto chunks (one per logical section). - Per chunk:
- Extract explicit
<!-- .slide: id="X" -->directive → record pin. - Extract trailing
note: ...block → wrap in<details>. - Replace
```mermaidfences with@@MERMAID_PLACEHOLDER_N@@tokens (sources collected into array; order preserved).
- Extract explicit
- Chunks rejoined with
<hr class="slide-break" />. marked.parse→ HTML.- Mermaid pass: Puppeteer renders each source to SVG, writes
<output>/assets/svg/mermaid-NN.svg, swaps each placeholder for a<figure class="svg-figure" tabindex="0"><img …/></figure>. - Heading-ID pass A: attach explicit pin IDs to matching headings in source order (first unlocked heading whose plain-text matches wins).
- Heading-ID pass B: auto-slug every remaining heading. Deck-wide
uniqueness via
-2,-3, … suffixes. - HTML shell assembled around body.
- Aux assets copied (
assets/css/{slides-overrides,zenburn.min}.css,assets/js/svg-viewer.js, hand-authored assets fromconfig.assetsDir, favicon). SOURCE_SHA256.txtwritten with the SHA of deck, CSS, SVG viewer.
marked@13.0.3— markdown → HTML.mermaid@12.3.0— diagram runtime, read at build time by Puppeteer.puppeteer@25.16.0— headless Chromium for Mermaid render. Downloads bundled Chromium (~150 MB) on firstnpm install.package.jsonoverridesbasic-ftpto its patched line because puppeteer's proxy chain still pins a vulnerable one.highlight.js@11.11.1— shipszenburn.min.cssonly (code renders at serve time via plain<pre><code class="language-…">which browsers highlight if a code engine is loaded; the framework's bundle does not include highlight.js runtime — the ULTRA-light choice).- No reveal-md: its only use was the bundled
favicon.ico, which is vendored attemplates/favicon.icoso the express server it drags in is not a dependency of a static build.
Shape: #!/usr/bin/env node ESM module.
Argv parsing: positional subcommand (build default when first arg
starts with -), then --config|-c <path> (default ./tutorial.config.json),
--write (expand-acronyms only), --help|-h.
Subcommands:
| Subcommand | Behavior | Exit code |
|---|---|---|
build |
Calls build.mjs::build(config, configDir). Overwrites output dir. |
0 success, 1 fatal |
expand-acronyms |
Dry-run or (with --write) persists the acronym transform to the deck source file. |
0 |
help / --help / -h |
Prints help; no side effects. | 0 |
Exit codes beyond 0:
1— fatal error (ENOENT, Mermaid parse failure, etc.).2— argv / config load error.
Error stream: all errors land on stderr prefixed [tutorial-forge] FATAL:.
Stack trace included.
Public API:
loadAcronyms(configAcronyms) → { ACRONYM: expansion, … }
expandAcronyms(deckText, acronyms, expandCount = 2) → { text, applied }
expandAcronymsInFile(deckPath, acronyms, expandCount) → { changed, text, applied, path }Idempotency contract (load-bearing): before expanding anything the
transform pre-scans the deck for existing "${expansion} (${ACRONYM})"
substrings and counts them toward the per-acronym budget. Running the
transform repeatedly against an already-expanded deck is a no-op. The
applied return value reports only new expansions, not pre-existing.
Masked regions (acronyms inside these are never matched):
- Fenced code blocks (
```→```, tracked by line toggle). - Lines that are pure HTML comments (
<!-- … -->). - Inline code spans (
`…`). - HTML tags (
<…>). - Markdown link URL parts (
](…)).
Match constraint: (?<!\w)(ACRONYM)(?!\w) with longest-first alternation
so UVTS doesn't lose to UV. Match is skipped if preceded by ( (that's
inside an already-expanded form's (ACRONYM) suffix).
Config merge:
merged = { ...defaults_acronyms_json, ...config.acronyms.extra }
for key in config.acronyms.disabled: delete merged[key]
Public API:
renderMermaidSources(sources: string[], opts?: { theme?: 'default'|'dark' })
→ Promise<string[]> // SVG strings in input orderInternals:
- Launches one Puppeteer browser per invocation (
--no-sandbox,--disable-setuid-sandbox). - Sets page content ONCE with the bundled mermaid.min.js inlined as a
<script>tag (no file:// URL required; works offline). mermaid.initialize({ htmlLabels: true, flowchart.curve: 'basis', … }). Theme passed through viaopts.theme(default:'default'= light).- For each source: pre-normalize (
<br/>→\n; auto-quote[labels]containing( ) / & \n), thenpage.evaluate(mermaid.render(id, src)). Fails hard on parse error with the full source dumped to stderr.
Pre-normalization rules (load-bearing): Mermaid's strict parser rejects
unquoted ( inside rectangle [label] syntax because it reads as a stadium
shape marker. Framework auto-wraps any [content] containing special chars
in "..." quotes. Author-side quoting is still recommended for robustness.
Error signature on parse failure:
Error: mermaid render failed for block N: Parse error on line X:
...<cursor-anchor>...
Expecting '…', got '…'
--- source ---
<the full source>
--- end ---
Public API:
build(config: TutorialConfig, configDir: string) → Promise<{
files: number,
bytes: number,
mermaidCount: number,
acronymsApplied: Record<string, number>,
deckSha: string,
cssSha: string,
viewerSha: string
}>Required config fields: deck, output.
Failure modes & exit (via fail() → process.exit(1)):
- Missing deck source →
deck source not found: <abs path>. - Missing CSS override →
CSS not found: <abs path>. - Missing SVG viewer →
SVG viewer not found: <abs path>. - Missing framework-shipped highlight style or mermaid.min.js → corrupt
install; run
npm installinscripts/tutorial-forge/.
Side effects:
rmSync(OUTPUT_DIR, { recursive: true, force: true })— destructive to output dir. Agents MUST ensureconfig.outputis not pointed at something a user would hate to lose. The directory is wiped on every build.- Writes files under
OUTPUT_DIRonly. Never writes to deck source or any other input (unlessexpand-acronyms --writewas invoked).
Mermaid rendering is skipped when either (a) no ```mermaid blocks
present, OR (b) config.mermaid.skipRender === true. In case (b),
placeholders remain in the HTML as @@MERMAID_PLACEHOLDER_N@@ text — the
build does NOT fail, so this flag is useful for dry runs that skip the
Puppeteer startup cost but WILL produce a visually broken HTML. Do not
use for production builds.
| File | Role | Can be overridden by config |
|---|---|---|
templates/default.css |
Dark-theme CSS, 1400px max-width centered, .svg-figure styling, speaker-notes, slide-break rules |
Yes: config.cssPath |
templates/svg-viewer.js |
Interactive viewer: toolbar [+][−][⟳][⤢], wheel-zoom at cursor, drag-pan, touch pinch, double-click reset, fullscreen; MutationObserver-safe idempotent attach |
Yes: config.svgViewerPath |
defaults/acronyms.json |
~55 industry-standard terms (AI, API, CLI, CSS, HTML, HITL, LLM, …) | Via config.acronyms.extra (adds) and config.acronyms.disabled (removes) |
Framework defaults for highlight.js/styles/base16/zenburn.min.css,
mermaid.min.js, and favicon.ico are read from node_modules/. These are
NOT user-configurable except by config.iconPath for the favicon.
Full type definition (TypeScript shape; file itself is plain JSON):
interface TutorialConfig {
// REQUIRED
deck: string; // path to markdown source, relative to config file
output: string; // path to output directory, relative to config file
// Shell / branding
title?: string; // default: 'Tutorial' — <title> element text
iconPath?: string; // default: templates/favicon.ico
// Overrides (fall back to framework defaults when absent)
cssPath?: string; // default: templates/default.css
svgViewerPath?: string; // default: templates/svg-viewer.js
// Hand-authored assets
assetsDir?: string; // optional; contents merged into <output>/assets
// Acronym transform
acronyms?: {
expandCount?: number; // default: 1 — first N occurrences get "Expansion (ACRONYM)"
extra?: Record<string, string>; // merged over defaults/acronyms.json
disabled?: string[]; // acronyms dropped from merged dict
glossary?: {
enabled?: boolean; // default: true — auto-emit glossary section at end of deck
label?: string; // default: 'Glossary' — section heading
};
};
// Mermaid
mermaid?: {
theme?: 'default' | 'dark'; // default: 'default' (light); .svg-figure has white BG
skipRender?: boolean; // default: false; see §4.4 warning
};
// Table of Contents sidebar (REQUIRED feature; see §15)
toc?: {
enabled?: boolean; // default: true — DISABLING IT VIOLATES THE FSD CONTRACT
minLevel?: number; // default: 1 (H1); range 1..6
maxLevel?: number; // default: 2 (H2); range 1..6
label?: string; // default: 'Contents' — sidebar heading
graphs?: {
enabled?: boolean; // default: true — list every .svg-figure
label?: string; // default: 'Graphs' — sub-section heading
};
};
// Markdown preprocessing
stripFrontmatter?: boolean; // default: true
}Fields marked as "default: X" mean the framework substitutes X when the field is absent OR null OR falsy. Pass an explicit value to override.
Path resolution: all relative paths resolve against the directory containing the config file (NOT process.cwd). Absolute paths work too.
Prescriptive checklist for an AI agent dropping tutorial-forge into a repo that doesn't have it:
cp -r <source-repo>/scripts/tutorial-forge <target-repo>/scripts/tutorial-forgeAlternative: git subtree add --prefix=scripts/tutorial-forge <source-url> main.
cd <target-repo>/scripts/tutorial-forge
npm installNote: this downloads Chromium (~150 MB). Agents working in sandboxed
environments MUST confirm the install succeeds BEFORE Step 4. On failure:
check ~/.cache/puppeteer/ for a chrome directory; if missing, re-run
npx puppeteer browsers install chrome from the forge directory.
Append to repo .gitignore:
scripts/tutorial-forge/node_modules/
Minimum viable config:
{
"deck": "docs/tutorial.md",
"output": "build/tutorial-static",
"title": "My Tutorial"
}Create at the repo root (same dir as .gitignore, not inside the forge).
Create docs/tutorial.md with standard markdown. Framework conventions:
- Fenced code blocks with language tag get code-theme highlighting.
```mermaidfences get pre-rendered to SVG + interactive viewer.<!-- .slide: id="pinned-anchor" -->on its own line BEFORE a heading pins the heading's HTML id (useful for TOC links).note:on its own line after main content starts a speaker-note block which becomes a collapsible<details>at the end of that section.---on its own line separates sections visually.
See §7 for exact author-side rules.
node scripts/tutorial-forge/cli.mjs build --config tutorial.config.jsonExpected success output: lines prefixed [tutorial-forge], final line
DONE: N files, X.XX MB total.
Serve + browser-check:
cd build/tutorial-static && python3 -m http.server 8000
# open http://localhost:8000/.PHONY: sync-tutorial-embed verify-tutorial-embed
sync-tutorial-embed:
@test -d scripts/tutorial-forge/node_modules \
|| (echo "run: cd scripts/tutorial-forge && npm ci" && exit 1)
@node scripts/tutorial-forge/cli.mjs build --config tutorial.config.json
verify-tutorial-embed:
@python3 scripts/verify_tutorial_embed.pyCopy scripts/verify_tutorial_embed.py from the mdemg repo as a reference
drift-gate implementation. Adjust constants if your layout differs; the
script reads tutorial.config.json so it auto-adapts to CSS/viewer
override paths.
In the repo's CI workflow, add a step that runs make verify-tutorial-embed.
This fails fast if someone edits the deck without re-running the sync.
Any #-###### heading gets an id attribute. Resolution precedence:
- Explicit pin: a
<!-- .slide: id="X" -->directive IMMEDIATELY preceding (within the same section) will attachid="X"to the heading whose plain text matches, in source order. - Auto-slug: lowercased, stripped of non-word chars, whitespace →
dashes, deduplicated with
-2,-3, … suffixes.
Agent must know: TOC fragment links like #my-section work ONLY if the
heading they target has that exact id. Changing a heading's wording
without a pin breaks any TOC entry pointing at the auto-slug.
A line consisting of exactly note: (optional trailing whitespace, must be
at start of line) after section content starts a note block. Everything
after it (up to the next \n---\n boundary) becomes <details class="speaker-notes"><summary>Speaker notes</summary>….
Standard fenced blocks only; mermaid is the required language tag.
Framework pre-renders ALL diagrams at build time with these effective
settings (not configurable at the deck level — change in renderers/ mermaid.mjs):
theme: light (default), overridable viaconfig.mermaid.theme.flowchart.htmlLabels: true— labels become foreignObject HTML. This is permissive on special chars but means the rendered SVG embeds HTML.flowchart.curve: 'basis'— smooth curved edges.useMaxWidth: false— SVG keeps its natural computed width; the.svg-figurewrapper +object-fit: containscales it to the viewer frame.
Author rules that improve rendered quality:
| Pattern | Behavior | Fix if awkward |
|---|---|---|
flowchart LR with many nodes |
Wide-aspect SVG (ratio > 3×), text crushes | Switch to TD |
[Label with (parens)] |
Framework auto-quotes to ["..."]; harmless |
Quote explicitly: ["Label with (parens)"] |
[Line one<br/>Line two] |
<br/> auto-converted to \n |
OK, no change needed |
Nodes using & as "and" |
Auto-quoted; safe | OK |
| Ultra-long labels | SVG natural width inflates; text may crush in viewer frame | Shorten labels or split node |
Standard markdown image syntax works. Images are NOT auto-wrapped in the
viewer; only ```mermaid blocks and files emitted into assets/svg/
get the .svg-figure treatment. For a hand-authored diagram that should
get the viewer treatment, write it as raw HTML in the deck:
<figure class="svg-figure" tabindex="0" aria-label="My diagram">
<img src="assets/svg/my-diagram.svg" alt="My diagram" />
</figure>Place my-diagram.svg under config.assetsDir/svg/ so it lands in the
output.
A line consisting of exactly --- splits logical sections. Rendered as
<hr class="slide-break" /> — a subtle dashed horizontal rule.
Author normal prose. The framework finds and expands acronyms matching the
merged dictionary (default + config.acronyms.extra, minus
config.acronyms.disabled). Pre-existing "Expansion (ACRONYM)" forms
authored by hand count toward the per-acronym budget — the transform
leaves them alone.
To prevent expansion of a specific project-internal name (e.g., MDEMG,
JIMINY, RSIC), add it to config.acronyms.disabled. It will never be
expanded regardless of the default dictionary.
An agent that successfully built a tutorial via tutorial-forge can verify the result shape with these invariants:
For a deck with M ```mermaid blocks and no hand-authored SVGs:
index.htmlexists.SOURCE_SHA256.txtexists with keysdeck_sha256,css_sha256,svg_viewer_sha256, each 64-hex-char.assets/css/slides-overrides.css,assets/css/zenburn.min.cssexist.assets/js/svg-viewer.jsexists.assets/svg/mermaid-01.svg…assets/svg/mermaid-${M.pad(2)}.svgexist.
const h = readIndexHtml();
assert h.includes('<main class="deck">');
assert !h.includes('mermaid.min.js'); // runtime NOT shipped
assert !h.includes('<div class="reveal">'); // reveal.js NOT present
const svgFigs = h.match(/class="svg-figure"/g).length;
const mermaidRefs = h.match(/mermaid-\d+\.svg/g).length;
assert svgFigs === mermaidRefs + hand_authored_count;
assert headingIds >= 1; // every heading gets an idRunning build twice in a row produces byte-identical index.html:
make sync-tutorial-embed && sha256sum output/index.html
make sync-tutorial-embed && sha256sum output/index.html
# the two sums MUST matchMermaid SVGs may differ by a few bytes between runs due to coordinate precision noise in Chromium's layout engine; this is tolerated and NOT a regression.
For every acronym in the merged dict, the string "${expansion} (${ACRONYM})"
appears ≤ expandCount times in the output HTML. If an acronym does not
appear at all in the deck, it appears 0 times.
| Signature | Root cause | Fix |
|---|---|---|
mermaid render failed for block N: Parse error on line X: ... got 'PS' |
Unquoted ( inside [label]; parser reads as stadium shape |
Framework should auto-quote; if error persists, quote the label manually: A["label with (parens)"] |
mermaid render failed for block N: Expecting 'SEMI', got 'STR' |
Label contains unescaped " |
Escape with " or use single quotes outside: [Label with \"quotes\"] → ['Label with "quotes"'] |
FATAL: deck source not found: <path> |
config.deck path wrong or deck missing |
Check tutorial.config.json; path is relative to config file dir |
FATAL: SVG viewer not found |
config.svgViewerPath points nowhere; framework default also missing |
Either fix the config path or re-install forge deps |
tutorial-forge deps not installed (from Makefile) |
scripts/tutorial-forge/node_modules/ missing |
cd scripts/tutorial-forge && npm ci |
| Build hangs / Chromium never launches | Puppeteer cache missing; ~/.cache/puppeteer/chrome* absent |
npx puppeteer browsers install chrome from forge dir |
make verify-tutorial-embed reports DRIFT after a deck edit |
Normal — the drift gate is doing its job | Run make sync-tutorial-embed + stage the regenerated staged/ dir + commit |
CI 404 on /ui/tutorial/assets/js/svg-viewer.js after deploy |
Build never ran OR go:embed didn't pick up the staged dir |
Verify internal/cli/tutorial_templates/staged/ contains the files locally; rebuild binary |
| Mermaid diagram renders but text is tiny | Wide-aspect SVG (viewBox width ≫ height) scaled into .svg-figure frame |
Convert flowchart LR → TD in deck source |
| Mermaid diagram text wrong color (white on white, black on black) | Mismatched config.mermaid.theme vs .svg-figure background |
Default theme is light (black text on white); if you set theme: 'dark', override CSS so .svg-figure has a dark background |
Acronym expanded wrong (e.g., CMS means "Cognitive Memory Substrate" not "Content Management System") |
Framework default collides with project-internal meaning | Add to config.acronyms.extra to override, OR add to config.acronyms.disabled and let surrounding prose define it |
| Deck pattern | Framework behavior |
|---|---|
# Heading (no pin above) |
Auto-slug id = heading; dedup suffix if collides |
<!-- .slide: id="foo" -->\n# Heading |
id = foo (explicit pin) |
```mermaid\nfoo\n``` |
Rendered to assets/svg/mermaid-01.svg; replaced with .svg-figure wrapper |
`inline code` |
Passthrough; acronym transform skips contents |
[link](https://…/UVTS) |
UVTS in URL is skipped (link-URL mask); link text IS scanned |
LLM first time (new deck) |
Expanded to Large Language Model (LLM) |
LLM third time |
Left as LLM (budget exhausted) |
Author-written Large Language Model (LLM) |
Counts toward LLM's budget of 2 (idempotency) |
```python\nLLM = ... \n``` |
LLM not expanded (fenced-code mask) |
<!-- a comment --> on its own line |
Line passthrough; acronym transform skips contents |
note: block |
Wrapped in <details class="speaker-notes"> |
--- on its own line |
Rendered as <hr class="slide-break" /> |
| YAML frontmatter at top | Stripped by default (config.stripFrontmatter: true) |
Purpose: fail CI if someone edits the deck without re-running the sync.
Reference impl: scripts/verify_tutorial_embed.py in the mdemg repo. It
reads tutorial.config.json, computes current SHAs of
deck/CSS/viewer, compares to SOURCE_SHA256.txt in the output dir.
Exit codes:
0— no drift.1— drift (or breadcrumb missing).
Wire example (GitHub Actions):
- name: Install tutorial-forge
run: cd scripts/tutorial-forge && npm ci
- name: Build tutorial
run: make sync-tutorial-embed
- name: Verify tutorial embed
run: make verify-tutorial-embedThe breadcrumb pattern makes the gate trivially diffable. Add a git diff --exit-code SOURCE_SHA256.txt step to catch drift that was committed
to the output dir with stale SHAs:
- name: Confirm staged embed is current
run: |
make sync-tutorial-embed
git diff --exit-code internal/cli/tutorial_templates/staged/| Scope | Procedure |
|---|---|
| One bad build | rm -rf <config.output> && make sync-tutorial-embed |
| One bad commit | git revert <sha> + make sync-tutorial-embed + commit |
| Framework upgrade broke a build | cd scripts/tutorial-forge && git checkout <known-good-sha> -- . + npm ci + rebuild |
| Deck doesn't need the framework anymore | Delete scripts/tutorial-forge/, tutorial.config.json, output dir, Makefile targets, drift verifier. Deck source + any hand-authored assets stay fine standalone. |
Mdemg (https://github.com/reh3376/mdemg) is the primary consumer. Study it
for the full integration pattern:
- Config:
tutorial.config.jsonat repo root. - Deck:
docs/onboarding/mdemg-onboarding-deck.md. - CSS override:
docs/onboarding/slides-overrides.css. - Viewer override:
docs/onboarding/svg-viewer.js. - Hand-authored assets:
docs/onboarding/assets/svg/u4-how-jiminy-thinks.svg. - Output:
internal/cli/tutorial_templates/staged/. //go:embeddirective:internal/cli/tutorial_templates/embed.go.- HTTP handler:
internal/api/handlers_tutorial.go(serves at/ui/tutorial/). - Embed invariant tests:
internal/cli/tutorial_templates/embed_test.go. - Handler tests:
internal/api/handlers_tutorial_test.go. - Drift verifier:
scripts/verify_tutorial_embed.py. - Makefile targets:
sync-tutorial-embed,verify-tutorial-embed.
Mdemg's acronym config disables expansion for 5 project-internal names
(MDEMG, JIMINY, RSIC, J17, MLX) and adds 7 project-specific
expansions (CMS, UATS, UVTS, UBENCH, ULTS, UAITS, UXTS). This
is the canonical pattern for a repo with its own naming conventions.
The framework ships at scripts/tutorial-forge/ within a consumer repo —
no npm registry publish yet. Agents upgrading should:
- Pull the latest
scripts/tutorial-forge/from the source repo. - Diff
package.jsonfor dep version changes. cd scripts/tutorial-forge && npm installto update node_modules.- Run
make sync-tutorial-embedto regenerate the output. - Run
make verify-tutorial-embedto confirm. - Commit the regenerated output.
Breaking changes are indicated by MINOR bumps in package.json's version
field (currently 0.1.0). The framework is pre-1.0; any change may break.
The TOC sidebar is a load-bearing framework contract. Every tutorial
rendered by tutorial-forge ships with it unless config.toc.enabled is
explicitly set to false — which agents should treat as a deliberate
operator opt-out, not a default. New repos adopting the framework inherit
the TOC with zero configuration.
- Fixed-position left column on viewports ≥ 980px wide.
- Width:
--toc-width(default 260px). position: sticky; top: 0; height: 100vh; overflow-y: auto— the TOC stays in place as the main content scrolls, with internal scrolling if the TOC's own list exceeds viewport height.bodybecomes a CSS grid with 2 columns (<toc-width>and1fr); the deck occupies column 2.- Below 980px the TOC is hidden via media query. (Collapsible mobile variant is a reasonable future enhancement; not shipped v0.1.)
The body gets class="has-toc" whenever a TOC is emitted; CSS keys all
layout rules off that class, so a theme override can turn off the grid
by targeting body:not(.has-toc).
- Collected from every
<hN id="…">wheretocMinLevel ≤ N ≤ tocMaxLevel. - Rendered as a flat
<ul class="deck-toc-sections">of<li class="deck-toc-l{N}">wrappers; each item is one<a href="#id">. - Author-authored
<!-- .slide: id="X" -->pins propagate correctly because the TOC reads the final resolved id from the body HTML.
- Collected from every
<figure class="svg-figure">element in document order. .svg-figureelements without anidattribute getid="figure-NN"injected at build time. Pre-rendered Mermaid diagrams keep theirid="diagram-NN"(two naming conventions:diagram-*for mermaid,figure-*for hand-authored HTML).- Each entry is labeled with the plain text of its nearest preceding heading (any level — more context beats consistency with the Sections list), prefixed by a 2-digit position number.
- Example rendered item:
19. How Jiminy Thinks → #figure-01. - The sub-section is skipped when
config.toc.graphs.enabled === falseOR when the document contains no.svg-figureelements.
- An inline IIFE at end-of-body uses
IntersectionObserverto track which heading is currently near the top of the viewport. - The matching TOC entry gets
class="deck-toc-active"applied; previous active entry has it removed. - Observer config:
rootMargin: '0px 0px -70% 0px', threshold: 0. Meaning: a heading becomes "active" when it enters the top 30% of the viewport. - Active-link
scrollIntoView({block:'nearest', inline:'nearest'})keeps the current entry visible in a long sidebar list. - Zero external deps; renders no-op in browsers without IntersectionObserver.
The framework-shipped CSS (templates/default.css) ships these tokens
inside :root. A consumer-provided cssPath override MUST retain them
or override every TOC rule explicitly:
--toc-width— default 260px, drives both the sidebar width and the deck grid column.--bg-elevated— TOC background.--rule,--rule-soft— border separators.--accent— active entry accent color.--fg,--fg-muted,--fg-subtle— text hierarchy.--radius-small— subtle rounded corners on item hover.
An agent that successfully built a tutorial with the TOC enabled can verify shape with these invariants (in addition to §8):
const h = readIndexHtml();
assert h.includes('<body class="has-toc">');
assert h.match(/<nav class="deck-toc"/);
assert h.match(/<div class="deck-toc-head">\w+<\/div>/);
assert h.includes('IntersectionObserver'); // scroll-sync IIFE present
assert h.match(/class="deck-toc-l\d"/); // at least one Section entry
// If the deck has mermaid or hand-authored .svg-figure:
assert h.match(/class="deck-toc-graph"/); // at least one Graphs entry
assert svgFigureCount >= graphsEntryCount; // every graph entry maps to a figurePreserving the "quick-navigate without excessive scrolling" invariant is the point of a tutorial package. A reader (human or AI agent) landing on a 230-slide deck without a sidebar nav has no mental model of the document shape and must scroll to discover structure. The sidebar makes section hierarchy explicit, exposes every diagram as a jump target, and keeps the current location visible. These properties are so central to the UX contract that tutorial-forge is opinionated here: shipping a tutorial package WITHOUT the TOC is a UX regression.
Agents extending the framework (new TOC source types, nested-group rendering, collapsible mobile variant) should preserve the invariants in §15.1–§15.4.
Target state: a consumer repo publishes its rendered tutorial to a
public URL like https://<owner>.github.io/<repo>/ where anyone can view
it without installing anything. Reference deployment:
https://reh3376.github.io/mdemg/.
The pattern below is what the mdemg reference implementation uses. Every tutorial-forge consumer repo (including the standalone forge repo itself, once it ships an example tutorial) should wire it in the same shape.
- The repo is public (private repos need a GH Enterprise plan for Pages).
- Pages is enabled with GitHub Actions as the source (one-time repo-admin action; see §16.2).
- A
tutorial.config.jsonat the repo root resolves to a buildable deck.
Via the GitHub REST API (works with gh auth login holding repo +
workflow scope):
gh api -X POST repos/<owner>/<repo>/pages -f build_type=workflowSuccessful response includes html_url (the public URL), build_type: workflow, and https_enforced: true.
Equivalent UI path (if the API call fails or isn't desired): Repo → Settings → Pages → Source: "GitHub Actions". No branch or folder is selected — the workflow produces the artifact.
Verbatim shape used in mdemg; copy as-is into any consumer repo, adjust
only cache-dependency-path + working-directory if the forge lives
somewhere other than scripts/tutorial-forge/:
name: Deploy Tutorial
on:
push:
branches: [main]
paths:
- 'docs/onboarding/**'
- 'scripts/tutorial-forge/**'
- 'tutorial.config.json'
- '.github/workflows/deploy-tutorial.yml'
workflow_dispatch:
concurrency:
group: deploy-tutorial-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'scripts/tutorial-forge/package-lock.json'
- name: Install tutorial-forge deps
working-directory: scripts/tutorial-forge
run: npm ci
- name: Build tutorial
run: node scripts/tutorial-forge/cli.mjs build --config tutorial.config.json
- uses: actions/upload-pages-artifact@v3
with:
path: internal/cli/tutorial_templates/staged # ← point at config.output
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4Path adjustments when adopting into a different repo:
cache-dependency-path+working-directory: match where you dropped the forge (scripts/tutorial-forge/,tooling/tutorial-forge/, or just the repo root in a standalone forge repo).upload-pages-artifact.path: must equalconfig.outputfromtutorial.config.json. The example demo writes tobuild/tutorial-static; mdemg writes tointernal/cli/tutorial_templates/staged.
For a standalone forge repo publishing its own example as the demo
site: the forge lives at the repo root (no nesting), so change the two
scripts/tutorial-forge/ prefixes to ./ (or omit entirely in
working-directory). The deck source and config path still need
adjusting to point at the example (example/deck.md,
example/tutorial.config.json, output example/dist).
A new fork or first-time-contributor repo may queue the workflow at
action_required status; the operator approves once via the Actions
tab's "Approve and run" button. Subsequent pushes run without prompt.
After successful deploy:
https://<owner>.github.io/<repo>/serves the renderedindex.html.last-modifiedHTTP header on the response matches the deploy time.- Auto-republishes on every main-branch push that touches a path in the
workflow trigger's
paths:filter.
After the first successful deploy, these invariants hold:
assert HTTP(https://<owner>.github.io/<repo>/) === 200;
const html = fetch(above);
assert html.includes('<main class="deck">'); // Section 1 shape
assert html.includes('class="deck-toc"'); // Section 15 TOC
assert !html.includes('mermaid.min.js'); // Section 1 pre-renderIf (3) fails or the URL 404s, check the Actions tab for a failed deploy
run — typical causes are a Puppeteer Chromium download timeout (retry)
or an incorrect path: in upload-pages-artifact (output dir mismatch
with config.output).
Re-deploy a prior known-good commit via workflow_dispatch with the
desired ref. Pages serves whatever the latest deploy published — there
is no "versioned Pages release" concept. Keep the deck source + config
versioned in git; redeploying from an older commit is one git revert
- push away.
tutorial-forge is responsible for building the static artifact and
documenting the deploy pattern. It does NOT ship the GH Pages workflow
file itself — each consumer repo authors its own
.github/workflows/deploy-tutorial.yml using the template above,
because:
- The workflow's trigger
paths:filter needs to name the consumer's own deck/config locations. - The
upload-pages-artifact.pathdepends on each consumer'sconfig.output. - Different consumer repos may want different triggers (schedule, release, manual-only).
An agent asked to "set up Pages" for a tutorial-forge consumer should
copy §16.3 into the consumer's .github/workflows/deploy-tutorial.yml,
adjust paths, enable Pages via §16.2's API call, and verify §16.5's
invariants after the first push.
- Not a tutorial on how to USE the shipped MDEMG tutorial. For that, run
mdemg and visit
/ui/tutorial/. - Not a design critique or rationale document. For the "why we pivoted from
reveal.js" story, see commit message
0f12fbfain the mdemg repo. - Not a changelog. See git log.
- Not a replacement for
README.md. The README is human-oriented; this FSD is agent-oriented. They cover the same system from different angles — neither one is the subset of the other.
End of FSD. Agents working on tutorial-forge should keep this document synchronized with code changes. If a code change contradicts a §4 "load-bearing" contract, update both in the same commit.