Course Ebook Publishing
Schema authority: this skill reads the live
window.COURSEobject whose shape is defined in_shared/domain-primitives.md. Quiz / pre-test / post-test items are filtered OUT of the ebook (per §10 quiz item rules + ebook content policy).Filename convention (English-first): outputs land in
dist/{name}.pdf/dist/{name}.docx. Source markdown is composed underdist/master.md.
This skill turns a finished teaching website into a book — PDF (primary) and optionally DOCX (for editorial review or further authoring). It is a post-site step: it consumes window.COURSE from the live site, never re-authoring content.
When to Invoke
- Site is feature-complete (Stages 1–5 done) and content is stable.
- Stakeholders ask for a printable hand-out, an archivable record, or a deliverable for non-web channels.
- Sometimes invoked after
course-corporate-editionto produce a client deliverable bundle.
Do NOT invoke when site is mid-development. The ebook pipeline reads window.COURSE; if the site changes daily, the ebook drifts daily.
Architecture: Single Source → Two Outputs
window.COURSE ─┐ ├─→ compose-ebook.mjs ─→ master.md ─→ pandoc ─→ ebook.docx asset folders ─┤ └─→ Playwright page.pdf() ─→ ebook.pdf materials/ ─┘
Why a single composed markdown intermediate: keeps PDF and DOCX content identical. Pandoc handles markdown → DOCX cleanly; Playwright renders the same markdown via an HTML wrapper → PDF. Two outputs, one source of truth.
File Layout (Standard)
scripts/ ├── build-ebook.mjs ← CLI entry point (--md-only, --no-docx, --no-pdf, --output, --keep-html) ├── render-pdf.mjs ← markdown → HTML → PDF via Playwright └── lib/ ├── compose-ebook.mjs ← loadSources + composeXxx functions (cover, TOC, chapters, appendix) └── reference.docx ← pandoc style template (generated by gen-reference-docx.mjs) style-ebook.css ← @page rules, cover, page-number footer, print-only styles dist/ ← output: ebook.md, ebook.pdf, ebook.docx
Loading Source Data: window.COURSE from index.html
When the site uses external course-data.js, just import it. When it uses inlined COURSE (corporate edition), extract via regex + vm sandbox:
jsimport vm from 'node:vm'; async function loadCourseFromIndexHtml(htmlPath) { const html = await fs.readFile(htmlPath, 'utf8'); const match = html.match(/<script>\s*window\.COURSE\s*=\s*\{[\s\S]*?\};\s*<\/script>/); if (!match) throw new Error('window.COURSE 區塊找不到'); const code = match[0].replace(/<\/?script>/g, ''); const sandbox = { window: {}, console }; vm.createContext(sandbox); vm.runInContext(code, sandbox); return sandbox.window.COURSE; }
Why vm sandbox not eval: vm isolates the script's globals from the build process; an accidental side-effect can't leak.
PDF Rendering: Playwright page.pdf() (not Chrome CLI)
Original implementation: pandoc → HTML → msedge --headless --print-to-pdf. Switch to Playwright — Chrome CLI doesn't support footerTemplate (no page numbers).
jsimport { chromium } from 'playwright'; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(`file://${path.resolve(htmlPath)}`, { waitUntil: 'networkidle' }); await page.pdf({ path: outputPath, format: 'A4', printBackground: true, displayHeaderFooter: true, headerTemplate: '<div></div>', footerTemplate: ` <div style="font-size:9pt; width:100%; text-align:center; color:#666;"> <span class="pageNumber"></span> / <span class="totalPages"></span> </div>`, margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' }, }); await browser.close();
Cover page without page number
The cover should be number-less. CSS:
css@page { margin: 20mm; } @page :first { margin: 0; } /* full bleed cover */
Playwright respects @page :first — the footer doesn't print on it.
Book-Style Layout (style-ebook.css)
The example workshop's "book-grade typography" commit was driven by aesthetics, not just formality. Key rules worth porting:
css/* 章首跨頁分頁 */ h1.chapter { page-break-before: always; } /* 圖文並排(Grid 排版) */ .figure-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12mm; } .figure-grid .three-up { grid-template-columns: repeat(3, 1fr); } /* 提示詞區塊:橘色 PROMPT 徽章 + 暗底 code */ .prompt-block { background: #1a1a1a; color: #e0e0e0; border-left: 4px solid #ff8c00; padding: 12pt; margin: 8pt 0; } .prompt-block::before { content: 'PROMPT'; display: inline-block; background: #ff8c00; color: white; padding: 2pt 8pt; border-radius: 3pt; font-size: 8pt; margin-bottom: 6pt; } /* 任務勾選方框(純印刷) */ .task::before { content: '☐'; margin-right: 8pt; font-size: 14pt; color: #888; } /* 學習目標綠色徽章 */ .goal::before { content: '✓'; margin-right: 6pt; color: #2e7d32; font-weight: bold; }
DOCX via pandoc + reference template
jsconst args = [ mdPath, '-f', 'gfm+attributes+raw_html', '-t', 'docx', '--standalone', '--toc', '--toc-depth=2', '--resource-path', path.dirname(mdPath), '--reference-doc', 'scripts/lib/reference.docx', '-o', outPath, ]; spawn('pandoc', args);
reference.docx is a one-time-generated template with your chosen fonts (e.g. Microsoft JhengHei UI for Chinese), heading colors, paragraph spacing. Generate via scripts/gen-reference-docx.mjs — pandoc copies the styles.
Caveat: raw HTML support in DOCX is partial. <figure-grid> blocks won't render as grids in Word — they degrade to vertical stack. Use markdown  syntax for images so they survive both formats.
Compose Module Anatomy
compose-ebook.mjs exports one function per chapter type:
jsexport async function loadSources() { /* read COURSE + materials + assets */ } export async function composeCover(meta) { /* big title, day list, meta card */ } export async function composeOverview(meta) { /* TOC + course overview */ } export async function composeChapter(day, assetIndex) { /* hero image + units */ } export async function composeAppendixMaterials(materials) { /* full-text material appendix */ } export async function composeAppendixSkills(skills) { /* QR-coded skill links */ }
Each returns a markdown string. build-ebook.mjs concatenates them with \n\n---\n\n.
Asset Path Strategy
Inside the composed markdown, images use paths relative to the markdown file (./assets/illustrations/foo.png). Pandoc's --resource-path resolves them; Playwright's file:// HTML loader does too. No absolute paths.
For corporate editions with asset fallback, resolve the actual file location at compose time and embed the resolved path:
jsasync function pickAsset(name, roots) { for (const root of roots) { const full = path.join(root, name); try { await fs.access(full); return path.relative(mdDir, full); } catch { continue; } } return null; // caller decides whether to drop the image or fall back to placeholder }
What to Filter Out
Some content lives on the website but should not be in the ebook:
- Quiz items: leaking exam questions defeats the assessment. The example workshop filters out the entire quiz chapter.
- Pre-test / post-test sections: same reason.
- Interactive-only features: "點擊複製" buttons make no sense in print. Render the raw prompt text, not the button.
- Dynamic illustrations: anything generated client-side via canvas/JS won't render.
Add filter conditions to the compose functions; don't try to render-then-strip.
Verification
After generating, verify:
js// scripts/verify-ebook.mjs // - PDF file exists and is > 100KB // - First page (cover) has no page number // - TOC pages have entries // - Every image reference resolved (no broken-image placeholders)
For DOCX, open in Word once and visually check: TOC linked, fonts applied, no missing-image squares.
CLI Conventions
The example workshop's build:ebook script supports:
| Flag | Behavior |
|---|---|
| (no flag) | Build both PDF + DOCX |
--md-only | Stop after composing markdown (debug content) |
--no-docx | Skip DOCX (faster iteration on PDF) |
--no-pdf | Skip PDF (faster iteration on DOCX) |
--keep-html | Don't delete intermediate HTML (debug CSS) |
--output PATH | Custom PDF output path |
Provide these from day one — you'll iterate the layout many times.
Anti-Patterns
- Re-authoring content in the ebook builder — the ebook is a derivative. If you find yourself adding new prose in the compose functions, that prose belongs in
course-content-authoringinstead. - Using Chrome CLI for PDF — switch to Playwright early; you'll want footers eventually.
- Hardcoding image paths — use
pickAsset()with a list of roots, especially when supporting corporate edition fallbacks. - Forgetting
@page :first { margin: 0 }— cover ends up with a stray page number in the footer. - Re-running the ebook build every time the site changes — only build at stable checkpoints; the intermediate
dist/folder is large.
Hand-off
When this skill finishes:
dist/{course-name}.pdfand optionally.docxare produced.- Verify scripts pass.
- If for a client, the file goes into the deliverable bundle.
Tell the user: "ebook ready. The site and ebook are now in sync; from now on, treat the site as canonical and rebuild the ebook only at release milestones — don't edit the ebook directly, it'll get overwritten."


