feat: add easy-slides CLI

This commit is contained in:
2026-08-29 18:59:33 +08:00
parent 2ca064d303
commit 33152a90dc
19 changed files with 1692 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import 'dotenv/config'
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { checkProject, printIssues } from './check.ts'
import type { EasySlidesConfig } from './lib/config.ts'
import type { DeckMetadata } from './lib/content.ts'
import { joinBase, normalizeSiteBase } from './lib/site-base.ts'
import { auditBuiltDecks } from './lib/overflow-audit.ts'
import { formatOverflowAuditResult } from './lib/overflow-report.ts'
import { runSlidev } from './lib/run-slidev.ts'
import { resolveTheme } from './lib/theme.ts'
const cwd = process.cwd()
const siteBase = normalizeSiteBase(process.env.SITE_BASE)
let config: EasySlidesConfig
let dist: string
async function main() {
const result = await checkProject(cwd)
config = result.config
dist = path.join(cwd, config.outDir)
const { decks, issues } = result
printIssues(issues, cwd)
if (issues.some(issue => issue.level === 'error'))
throw new Error('内容检查失败,已停止构建')
const published = decks.filter(deck => !deck.draft)
await rm(dist, { recursive: true, force: true })
await mkdir(dist, { recursive: true })
for (const deck of published)
await buildDeck(deck)
await reportSlideOverflow(published)
await writeFile(path.join(dist, 'index.html'), renderIndex(published), 'utf8')
await writeFile(path.join(dist, '_headers'), renderHeaders(), 'utf8')
await writeFile(path.join(dist, '_redirects'), renderRedirects(published), 'utf8')
console.log(`\n构建完成:${published.length} 套演示 → ${path.relative(cwd, dist)}`)
}
async function buildDeck(deck: DeckMetadata) {
const output = path.join(dist, deck.slug)
const base = joinBase(siteBase, deck.slug)
console.log(`\nBuilding ${deck.slug} (${base})`)
await runSlidev([
'build',
deck.entry,
'--theme',
resolveTheme(config.theme),
'--out',
output,
'--base',
base,
'--router-mode',
'hash',
])
const assets = path.join(deck.directory, 'assets')
await cp(assets, path.join(output, 'assets'), { recursive: true, force: true }).catch(() => undefined)
await copySpaEntry(output, 'presenter')
await copySpaEntry(output, 'overview')
await copySpaEntry(output, 'notes-edit')
}
async function copySpaEntry(output: string, route: string) {
const html = await readFile(path.join(output, 'index.html'), 'utf8')
const routeBootstrap = `<script>if(location.hash!==\"#/${route}\")location.hash=\"#/${route}\"</script>`
const routedHtml = html.replace('</head>', `${routeBootstrap}</head>`)
const directory = path.join(output, route)
await mkdir(directory, { recursive: true })
await writeFile(path.join(directory, 'index.html'), routedHtml, 'utf8')
}
async function reportSlideOverflow(decks: DeckMetadata[]) {
if (!decks.length)
return
console.log('\n检查编译后的幻灯片内容边界...')
const result = await auditBuiltDecks({ root: dist, decks, siteBase })
formatOverflowAuditResult(result, cwd).forEach(line => console.log(line))
}
function renderIndex(decks: DeckMetadata[]) {
const cards = decks.map((deck) => {
const href = joinBase(siteBase, deck.slug)
const cover = deck.cover
? `<img src="${escapeAttribute(`${href}${deck.cover.replace(/^\.\//, '')}`)}" alt="" loading="lazy">`
: `<div class="cover-fallback" aria-hidden="true"><span>${escapeHtml(deck.title.slice(0, 1))}</span></div>`
const tags = deck.tags.map(tag => `<li>${escapeHtml(tag)}</li>`).join('')
return `<article class="deck-card">
<a class="cover" href="${escapeAttribute(href)}">${cover}</a>
<div class="deck-copy">
<p class="meta">${escapeHtml(deck.date || 'Undated')}${deck.author ? ` · ${escapeHtml(deck.author)}` : ''}</p>
<h2><a href="${escapeAttribute(href)}">${escapeHtml(deck.title)}</a></h2>
${deck.description ? `<p class="description">${escapeHtml(deck.description)}</p>` : ''}
${tags ? `<ul class="tags">${tags}</ul>` : ''}
</div>
</article>`
}).join('\n')
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="${escapeAttribute(config.description)}">
<title>${escapeHtml(config.title)}</title>
<style>${landingStyles()}</style>
</head>
<body>
<main>
<header>
<p class="eyebrow">MARKDOWN PRESENTATIONS</p>
<h1>${escapeHtml(config.title)}</h1>
<p class="lede">${escapeHtml(config.description)}</p>
</header>
<section class="deck-list" aria-label="演示列表">
${cards || '<p>暂无公开演示。</p>'}
</section>
</main>
</body>
</html>`
}
function landingStyles() {
return `:root{font-family:Inter,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;color:#222;background:#f6f5f2}*{box-sizing:border-box}body{margin:0}main{width:min(1120px,calc(100% - 40px));margin:auto;padding:72px 0 96px}header{border-bottom:2px solid #1d4ed8;padding-bottom:28px;margin-bottom:36px}.eyebrow{font-size:12px;font-weight:700;letter-spacing:.18em;color:#1d4ed8}h1{font-size:clamp(48px,8vw,82px);line-height:1;margin:14px 0}.lede{font-size:clamp(19px,2.2vw,26px);color:#555;margin:0}.deck-list{display:grid;gap:28px}.deck-card{display:grid;grid-template-columns:minmax(220px,36%) 1fr;background:#fff;border:1px solid #d9d7d2;border-radius:10px;overflow:hidden;box-shadow:0 12px 36px rgba(30,64,175,.08)}.cover{display:block;min-height:230px;background:#eae7e2}.cover img{display:block;width:100%;height:100%;min-height:230px;object-fit:cover}.cover-fallback{height:100%;min-height:230px;display:grid;place-items:center;background:linear-gradient(135deg,#eff6ff,#dbeafe);color:#1d4ed8;font-size:92px;font-weight:700}.deck-copy{padding:32px}.meta{font-size:13px;color:#777;margin:0 0 10px}h2{font-size:clamp(26px,3vw,38px);line-height:1.15;margin:0}h2 a{color:inherit;text-decoration:none}h2 a:hover{color:#1d4ed8}.description{font-size:17px;line-height:1.7;color:#555}.tags{display:flex;gap:8px;flex-wrap:wrap;list-style:none;padding:0;margin:24px 0 0}.tags li{font-size:12px;background:#eff6ff;color:#1d4ed8;padding:5px 9px;border-radius:999px}@media(max-width:680px){main{padding-top:44px}.deck-card{grid-template-columns:1fr}.cover,.cover img,.cover-fallback{min-height:190px}.deck-copy{padding:24px}}`
}
function renderHeaders() {
return `/*.html\n Cache-Control: public, max-age=0, must-revalidate\n\n/*/assets/*\n Cache-Control: public, max-age=31536000, immutable\n`
}
function renderRedirects(decks: DeckMetadata[]) {
return decks.map(deck => `${joinBase(siteBase, deck.slug)}* ${joinBase(siteBase, deck.slug)}index.html 200`).join('\n')
}
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
}
function escapeAttribute(value: string) {
return escapeHtml(value)
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})