Loading...
Loading...
Use this skill ONLY AFTER a teaching site is feature-complete and stable, when the user wants to produce a printable / archivable / shareable version — typically a PDF ebook with cover, page numbers, table of contents, or a DOCX for editorial review. Triggers on phrases like "做電子書", "產 PDF", "ebook", "PDF ebook", "印給學員", "課程講義 PDF", "course handbook", "DOCX deliverable", "book-style layout", "build:ebook". Do NOT invoke during early site development — this is a downstream consumer that breaks if the site changes underneath. The skill produces PDF via Playwright `page.pdf()` and DOCX via pandoc, both from a single composed markdown source.
npx skill4agent add kevintsai1202/teaching-site-skills course-ebook-publishingSchema authority: this skill reads the liveobject whose shape is defined inwindow.COURSE. Quiz / pre-test / post-test items are filtered OUT of the ebook (per §10 quiz item rules + ebook content policy)._shared/domain-primitives.mdFilename convention (English-first): outputs land in/dist/{name}.pdf. Source markdown is composed underdist/{name}.docx.dist/master.md
window.COURSEcourse-corporate-editionwindow.COURSEwindow.COURSE ─┐
├─→ compose-ebook.mjs ─→ master.md ─→ pandoc ─→ ebook.docx
asset folders ─┤ └─→ Playwright page.pdf() ─→ ebook.pdf
materials/ ─┘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.docxwindow.COURSEcourse-data.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;
}evalpage.pdf()pandoc → HTML → msedge --headless --print-to-pdffooterTemplateimport { 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();@page { margin: 20mm; }
@page :first { margin: 0; } /* full bleed cover */@page :first/* 章首跨頁分頁 */
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;
}const 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.docxscripts/gen-reference-docx.mjs<figure-grid>compose-ebook.mjsexport 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 */ }build-ebook.mjs\n\n---\n\n./assets/illustrations/foo.png--resource-pathfile://async 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
}// 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)build:ebook| Flag | Behavior |
|---|---|
| (no flag) | Build both PDF + DOCX |
| Stop after composing markdown (debug content) |
| Skip DOCX (faster iteration on PDF) |
| Skip PDF (faster iteration on DOCX) |
| Don't delete intermediate HTML (debug CSS) |
| Custom PDF output path |
course-content-authoringpickAsset()@page :first { margin: 0 }dist/dist/{course-name}.pdf.docx