diff --git a/scripts/build.ts b/scripts/build.ts
new file mode 100644
index 0000000..188367d
--- /dev/null
+++ b/scripts/build.ts
@@ -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 = ``
+ const routedHtml = html.replace('', `${routeBootstrap}`)
+ 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
+ ? `
`
+ : `
${escapeHtml(deck.title.slice(0, 1))}
`
+ const tags = deck.tags.map(tag => `${escapeHtml(tag)}`).join('')
+ return `
+ ${cover}
+
+
${escapeHtml(deck.date || 'Undated')}${deck.author ? ` · ${escapeHtml(deck.author)}` : ''}
+
+ ${deck.description ? `
${escapeHtml(deck.description)}
` : ''}
+ ${tags ? `
` : ''}
+
+ `
+ }).join('\n')
+
+ return `
+
+
+
+
+
+ ${escapeHtml(config.title)}
+
+
+
+
+
+
+ ${cards || '暂无公开演示。
'}
+
+
+
+`
+}
+
+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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
+}
+
+function escapeAttribute(value: string) {
+ return escapeHtml(value)
+}
+
+await main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error)
+ process.exitCode = 1
+})
diff --git a/scripts/check.ts b/scripts/check.ts
new file mode 100644
index 0000000..d6e0a4c
--- /dev/null
+++ b/scripts/check.ts
@@ -0,0 +1,58 @@
+import 'dotenv/config'
+import path from 'node:path'
+import { discoverDecks, validateDeck, type ValidationIssue } from './lib/content.ts'
+import { loadProjectConfig, type EasySlidesConfig } from './lib/config.ts'
+import { formatStaticOverflowResult } from './lib/overflow-report.ts'
+import { auditStaticDecks } from './lib/static-overflow.ts'
+import { hasPresenterToken } from './lib/token.ts'
+
+export async function checkProject(cwd = process.cwd(), providedConfig?: EasySlidesConfig) {
+ const config = providedConfig ?? await loadProjectConfig(cwd)
+ const decks = await discoverDecks(cwd, config.slidesDir)
+ const issues: ValidationIssue[] = []
+
+ if (decks.length === 0)
+ issues.push({ level: 'error', file: path.join(cwd, config.slidesDir), message: `没有发现 ${config.slidesDir}//slides.md` })
+
+ for (const deck of decks)
+ issues.push(...await validateDeck(deck))
+
+ const staticOverflow = await auditStaticDecks(decks)
+
+ if (!hasPresenterToken()) {
+ issues.push({
+ level: 'warning',
+ file: path.join(cwd, '.env'),
+ message: '未设置 PRESENTER_TOKEN;生产构建将隐藏演示者入口',
+ })
+ }
+
+ return { config, decks, issues, staticOverflow }
+}
+
+export function printIssues(issues: ValidationIssue[], cwd = process.cwd()) {
+ for (const issue of issues) {
+ const marker = issue.level === 'error' ? 'ERROR' : 'WARN '
+ console.log(`${marker} ${path.relative(cwd, issue.file)}: ${issue.message}`)
+ }
+}
+
+export async function runCheck() {
+ const result = await checkProject()
+ printIssues(result.issues)
+ formatStaticOverflowResult(result.staticOverflow).forEach(line => console.log(line))
+ const errors = result.issues.filter(issue => issue.level === 'error')
+ if (errors.length > 0) {
+ console.error(`\n检查失败:${errors.length} 个错误`)
+ process.exitCode = 1
+ return
+ }
+
+ const warnings = result.issues.filter(issue => issue.level === 'warning').length
+ + result.staticOverflow.issues.length
+ + result.staticOverflow.errors.length
+ console.log(`\n检查通过:${result.decks.length} 套演示,${warnings} 个警告`)
+}
+
+if (import.meta.url === new URL(process.argv[1], 'file:').href)
+ await runCheck()
diff --git a/scripts/cli.ts b/scripts/cli.ts
new file mode 100644
index 0000000..ec26233
--- /dev/null
+++ b/scripts/cli.ts
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+import path from 'node:path'
+
+const rawArgs = process.argv.slice(2)
+const command = rawArgs.shift()
+const rootIndex = rawArgs.indexOf('--root')
+if (rootIndex >= 0) {
+ const root = rawArgs[rootIndex + 1]
+ if (!root)
+ throw new Error('--root 需要一个目录')
+ rawArgs.splice(rootIndex, 2)
+ process.chdir(path.resolve(root))
+}
+process.argv = [process.argv[0], process.argv[1], ...rawArgs]
+
+switch (command) {
+ case 'dev':
+ await import('./dev.ts')
+ break
+ case 'present':
+ await import('./present.ts')
+ break
+ case 'build':
+ await import('./build.ts')
+ break
+ case 'check':
+ await (await import('./check.ts')).runCheck()
+ break
+ case 'export':
+ await import('./export.ts')
+ break
+ case 'init':
+ await import('./init.ts')
+ break
+ case undefined:
+ case 'help':
+ case '--help':
+ case '-h':
+ printHelp()
+ break
+ default:
+ console.error(`未知命令:${command}`)
+ printHelp()
+ process.exitCode = 1
+}
+
+function printHelp() {
+ console.log(`easy-slides
+
+用法:
+ easy-slides init [directory] [--engine ]
+ easy-slides dev [slug|slides.md]
+ easy-slides present
+ easy-slides build
+ easy-slides check
+ easy-slides export --format pdf|png|pptx
+
+全局选项:
+ --root 在指定内容项目中运行`)
+}
diff --git a/scripts/copy-theme.mjs b/scripts/copy-theme.mjs
new file mode 100644
index 0000000..bc5b0cf
--- /dev/null
+++ b/scripts/copy-theme.mjs
@@ -0,0 +1,13 @@
+import { cp, rm } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const source = path.join(root, 'packages', 'slidev-theme-easy-jyy')
+const destination = path.join(root, 'dist-cli', 'theme')
+
+await rm(destination, { recursive: true, force: true })
+await cp(source, destination, {
+ recursive: true,
+ filter: filename => !filename.split(path.sep).includes('node_modules') && path.basename(filename) !== '.DS_Store',
+})
diff --git a/scripts/dev.ts b/scripts/dev.ts
new file mode 100644
index 0000000..282ea34
--- /dev/null
+++ b/scripts/dev.ts
@@ -0,0 +1,30 @@
+import 'dotenv/config'
+import { loadProjectConfig } from './lib/config.ts'
+import { resolveDeck } from './lib/content.ts'
+import { startDevOverflowAudit } from './lib/dev-overflow-audit.ts'
+import { startSlidev, waitForSlidev } from './lib/run-slidev.ts'
+import { resolveTheme } from './lib/theme.ts'
+
+const input = process.argv.slice(2).find(argument => !argument.startsWith('-'))
+const config = await loadProjectConfig()
+const deck = await resolveDeck(input, process.cwd(), config.slidesDir)
+const port = process.env.PORT || '3030'
+const slidev = startSlidev([
+ deck.entry,
+ '--theme',
+ resolveTheme(config.theme),
+ '--open',
+ '--port',
+ port,
+])
+const overflowAudit = startDevOverflowAudit({
+ deck,
+})
+slidev.once('exit', () => void overflowAudit.close())
+
+try {
+ await waitForSlidev(slidev)
+}
+finally {
+ await overflowAudit.close()
+}
diff --git a/scripts/export.ts b/scripts/export.ts
new file mode 100644
index 0000000..1f8e8d4
--- /dev/null
+++ b/scripts/export.ts
@@ -0,0 +1,43 @@
+import 'dotenv/config'
+import { mkdir } from 'node:fs/promises'
+import path from 'node:path'
+import { loadProjectConfig } from './lib/config.ts'
+import { resolveDeck } from './lib/content.ts'
+import { runSlidev } from './lib/run-slidev.ts'
+import { resolveTheme } from './lib/theme.ts'
+
+const args = process.argv.slice(2)
+const slug = args.find(argument => !argument.startsWith('-'))
+const formatIndex = args.indexOf('--format')
+const format = formatIndex >= 0 ? args[formatIndex + 1] : 'pdf'
+const allowed = new Set(['pdf', 'png', 'pptx', 'md'])
+
+if (!allowed.has(format))
+ throw new Error(`不支持导出格式:${format}`)
+
+const config = await loadProjectConfig()
+const deck = await resolveDeck(slug, process.cwd(), config.slidesDir)
+const exportDir = path.join(process.cwd(), config.exportDir)
+await mkdir(exportDir, { recursive: true })
+const output = path.join(exportDir, `${deck.slug}.${format}`)
+
+const slidevArgs = [
+ 'export',
+ deck.entry,
+ '--theme',
+ resolveTheme(config.theme),
+ '--format',
+ format,
+ '--output',
+ output,
+]
+
+// Slidev's one-piece exporter may capture off-screen lazy images before they
+// are painted. Per-slide export renders each canvas as the active slide and is
+// reliable for Markdown images across PNG, PDF, and screenshot-based PPTX.
+if (format !== 'md')
+ slidevArgs.push('--per-slide')
+
+await runSlidev(slidevArgs)
+
+console.log(`导出完成:${path.relative(process.cwd(), output)}`)
diff --git a/scripts/index.ts b/scripts/index.ts
new file mode 100644
index 0000000..f427700
--- /dev/null
+++ b/scripts/index.ts
@@ -0,0 +1,2 @@
+export { loadProjectConfig, normalizeConfig } from './lib/config.ts'
+export type { EasySlidesConfig } from './lib/config.ts'
diff --git a/scripts/init.ts b/scripts/init.ts
new file mode 100644
index 0000000..708f68d
--- /dev/null
+++ b/scripts/init.ts
@@ -0,0 +1,134 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+const require = createRequire(import.meta.url)
+const engineVersion = String(require('../package.json').version)
+const args = process.argv.slice(2)
+const engineIndex = args.indexOf('--engine')
+const engineSpec = engineIndex >= 0 ? args[engineIndex + 1] : `^${engineVersion}`
+if (!engineSpec)
+ throw new Error('--engine 需要 npm 版本、Git URL 或本地路径')
+const targetInput = args.find((argument, index) => !argument.startsWith('-') && index !== engineIndex + 1) ?? '.'
+const target = path.resolve(targetInput)
+
+await mkdir(path.join(target, 'slides', 'welcome', 'assets'), { recursive: true })
+await mkdir(path.join(target, '.github', 'workflows'), { recursive: true })
+
+await writeNew('package.json', `${JSON.stringify({
+ name: path.basename(target),
+ private: true,
+ scripts: {
+ dev: 'easy-slides dev',
+ present: 'easy-slides present',
+ build: 'easy-slides build',
+ check: 'easy-slides check',
+ export: 'easy-slides export',
+ },
+ devDependencies: {
+ '@easy-slides/cli': engineSpec,
+ },
+}, null, 2)}\n`)
+
+await writeNew('easy-slides.config.mjs', `export default {
+ title: '${escapeJavaScript(path.basename(target))}',
+ description: 'Markdown presentations',
+ slidesDir: 'slides',
+ outDir: 'dist',
+}
+`)
+
+await writeNew(path.join('slides', 'welcome', 'slides.md'), `---
+title: Welcome
+description: Your first easy-slides deck
+date: ${new Date().toISOString().slice(0, 10)}
+tags: [Markdown]
+---
+
+# Welcome
+
+> Edit slides/welcome/slides.md and push to deploy.
+
+
+
+---
+
+## Content lives here
+
+- The engine is an npm dependency
+- This repository owns slides and assets
+- The static build can deploy anywhere
+`)
+
+await writeNew('.gitignore', `node_modules/
+dist/
+exports/
+.env
+.DS_Store
+`)
+
+await writeNew('pnpm-workspace.yaml', `allowBuilds:
+ playwright-chromium: false
+`)
+
+await writeNew(path.join('.github', 'workflows', 'deploy-pages.yml'), `name: Deploy slides
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: \${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.19.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm check
+ env:
+ PRESENTER_TOKEN: \${{ secrets.PRESENTER_TOKEN }}
+ - run: pnpm build
+ env:
+ SITE_BASE: /\${{ github.event.repository.name }}/
+ PRESENTER_TOKEN: \${{ secrets.PRESENTER_TOKEN }}
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v4
+ with:
+ path: dist
+ - id: deployment
+ uses: actions/deploy-pages@v4
+`)
+
+console.log(`内容项目已创建:${target}`)
+console.log('下一步:pnpm install && pnpm dev -- welcome')
+
+async function writeNew(relative: string, content: string) {
+ const filename = path.join(target, relative)
+ await mkdir(path.dirname(filename), { recursive: true })
+ await writeFile(filename, content, { encoding: 'utf8', flag: 'wx' }).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === 'EEXIST')
+ throw new Error(`拒绝覆盖已有文件:${filename}`)
+ throw error
+ })
+}
+
+function escapeJavaScript(value: string) {
+ return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")
+}
diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts
new file mode 100644
index 0000000..3f13897
--- /dev/null
+++ b/scripts/lib/config.ts
@@ -0,0 +1,70 @@
+import { access, readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+export interface EasySlidesConfig {
+ title: string
+ description: string
+ slidesDir: string
+ outDir: string
+ exportDir: string
+ theme?: string
+}
+
+const defaults: EasySlidesConfig = {
+ title: 'easy-slides',
+ description: 'Markdown presentations powered by easy-slides',
+ slidesDir: 'slides',
+ outDir: 'dist',
+ exportDir: 'exports',
+}
+
+export async function loadProjectConfig(cwd = process.cwd()): Promise {
+ const candidates = [
+ 'easy-slides.config.mjs',
+ 'easy-slides.config.js',
+ 'easy-slides.config.json',
+ ]
+
+ for (const candidate of candidates) {
+ const filename = path.join(cwd, candidate)
+ if (!(await exists(filename)))
+ continue
+
+ const loaded = candidate.endsWith('.json')
+ ? JSON.parse(await readFile(filename, 'utf8'))
+ : (await import(`${pathToFileURL(filename).href}?t=${Date.now()}`)).default
+
+ return normalizeConfig(loaded)
+ }
+
+ return { ...defaults }
+}
+
+export function normalizeConfig(value: Partial | undefined): EasySlidesConfig {
+ return {
+ ...defaults,
+ ...value,
+ slidesDir: normalizeRelativePath(value?.slidesDir ?? defaults.slidesDir, 'slidesDir'),
+ outDir: normalizeRelativePath(value?.outDir ?? defaults.outDir, 'outDir'),
+ exportDir: normalizeRelativePath(value?.exportDir ?? defaults.exportDir, 'exportDir'),
+ }
+}
+
+function normalizeRelativePath(value: string, field: string) {
+ const normalized = value.trim().replace(/[\\/]+$/, '')
+ const portable = normalized.replaceAll('\\', '/')
+ if (!normalized || path.isAbsolute(normalized) || portable === '..' || portable.startsWith('../'))
+ throw new Error(`${field} 必须是项目内的相对路径`)
+ return normalized
+}
+
+async function exists(filename: string) {
+ try {
+ await access(filename)
+ return true
+ }
+ catch {
+ return false
+ }
+}
diff --git a/scripts/lib/content.ts b/scripts/lib/content.ts
new file mode 100644
index 0000000..5de7e05
--- /dev/null
+++ b/scripts/lib/content.ts
@@ -0,0 +1,163 @@
+import { access, readFile } from 'node:fs/promises'
+import path from 'node:path'
+import fg from 'fast-glob'
+import matter from 'gray-matter'
+
+export interface DeckMetadata {
+ slug: string
+ entry: string
+ directory: string
+ title: string
+ description: string
+ author: string
+ date: string
+ tags: string[]
+ cover?: string
+ draft: boolean
+}
+
+export interface ValidationIssue {
+ level: 'error' | 'warning'
+ file: string
+ message: string
+}
+
+const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+const allowedFits = new Set(['contain', 'cover', 'fill', 'scale-down', 'none'])
+
+export async function discoverDecks(cwd = process.cwd(), slidesDir = 'slides'): Promise {
+ const entries = await fg(`${slidesDir.replaceAll('\\', '/')}/*/slides.md`, { cwd, absolute: true, onlyFiles: true })
+ const decks = await Promise.all(entries.map(entry => readDeck(entry)))
+ return decks.sort((a, b) => compareDecks(a, b))
+}
+
+export async function readDeck(entry: string): Promise {
+ const source = await readFile(entry, 'utf8')
+ const parsed = matter(source)
+ const directory = path.dirname(entry)
+ const slug = path.basename(directory)
+ const tags = Array.isArray(parsed.data.tags)
+ ? parsed.data.tags.map((tag: unknown) => String(tag).trim()).filter(Boolean)
+ : []
+
+ return {
+ slug,
+ entry,
+ directory,
+ title: String(parsed.data.title ?? '').trim(),
+ description: String(parsed.data.description ?? '').trim(),
+ author: String(parsed.data.author ?? '').trim(),
+ date: normalizeDate(parsed.data.date),
+ tags,
+ cover: parsed.data.cover ? String(parsed.data.cover).trim() : undefined,
+ draft: parsed.data.draft === true,
+ }
+}
+
+export async function resolveDeck(input: string | undefined, cwd = process.cwd(), slidesDir = 'slides') {
+ const decks = await discoverDecks(cwd, slidesDir)
+ if (!input)
+ return decks.find(deck => !deck.draft) ?? decks[0]
+
+ const direct = path.resolve(cwd, input)
+ const bySlug = decks.find(deck => deck.slug === input)
+ const byPath = decks.find(deck => path.resolve(deck.entry) === direct)
+ const deck = bySlug ?? byPath
+ if (!deck)
+ throw new Error(`找不到演示“${input}”。可用 slug:${decks.map(item => item.slug).join(', ') || '无'}`)
+ return deck
+}
+
+export async function validateDeck(deck: DeckMetadata): Promise {
+ const issues: ValidationIssue[] = []
+ const source = await readFile(deck.entry, 'utf8')
+ const parsed = matter(source)
+
+ if (!slugPattern.test(deck.slug))
+ issues.push(error(deck.entry, '目录名必须是小写 kebab-case slug'))
+ if (!deck.title)
+ issues.push(error(deck.entry, 'frontmatter 缺少必填 title'))
+ if (deck.date && Number.isNaN(Date.parse(deck.date)))
+ issues.push(error(deck.entry, `date 不是有效日期:${deck.date}`))
+ if (parsed.data.tags !== undefined && !Array.isArray(parsed.data.tags))
+ issues.push(error(deck.entry, 'tags 必须是数组'))
+ if (parsed.data.theme && parsed.data.theme !== 'easy-jyy')
+ issues.push(warning(deck.entry, `当前主题为 ${parsed.data.theme},easy-jyy 主题能力可能不会生效`))
+
+ if (deck.cover && !isRemote(deck.cover)) {
+ const coverPath = path.resolve(deck.directory, deck.cover)
+ if (!(await exists(coverPath)))
+ issues.push(error(deck.entry, `封面不存在:${deck.cover}`))
+ }
+
+ for (const image of extractMarkdownImages(parsed.content)) {
+ if (!isRemote(image.source) && !image.source.startsWith('/') && !(await exists(path.resolve(deck.directory, image.source))))
+ issues.push(error(deck.entry, `图片不存在:${image.source}`))
+
+ if (image.attributes.fit && !allowedFits.has(image.attributes.fit))
+ issues.push(error(deck.entry, `图片 fit 不受支持:${image.attributes.fit}`))
+ if (image.attributes['max-height'] && !isSafeCssSize(image.attributes['max-height']))
+ issues.push(error(deck.entry, `图片 max-height 不合法:${image.attributes['max-height']}`))
+ }
+
+ return issues
+}
+
+export function extractMarkdownImages(markdown: string) {
+ const images: Array<{ source: string, attributes: Record }> = []
+ const pattern = /!\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)\s*(?:\{([^}]*)\})?/g
+ for (const match of markdown.matchAll(pattern)) {
+ images.push({
+ source: match[1],
+ attributes: parseAttributes(match[2] ?? ''),
+ })
+ }
+ return images
+}
+
+export function parseAttributes(input: string) {
+ const attributes: Record = {}
+ const pattern = /([\w-]+)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g
+ for (const match of input.matchAll(pattern))
+ attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? ''
+ return attributes
+}
+
+function normalizeDate(value: unknown) {
+ if (!value)
+ return ''
+ if (value instanceof Date)
+ return value.toISOString().slice(0, 10)
+ return String(value).trim()
+}
+
+function compareDecks(a: DeckMetadata, b: DeckMetadata) {
+ const byDate = (Date.parse(b.date || '1970-01-01') || 0) - (Date.parse(a.date || '1970-01-01') || 0)
+ return byDate || a.title.localeCompare(b.title, 'zh-CN')
+}
+
+function isRemote(value: string) {
+ return /^(?:https?:)?\/\//.test(value) || value.startsWith('data:')
+}
+
+function isSafeCssSize(value: string) {
+ return /^(?:\d+(?:\.\d+)?)(?:px|rem|em|vh|vw|%|vmin|vmax)$/.test(value)
+}
+
+async function exists(file: string) {
+ try {
+ await access(file)
+ return true
+ }
+ catch {
+ return false
+ }
+}
+
+function error(file: string, message: string): ValidationIssue {
+ return { level: 'error', file, message }
+}
+
+function warning(file: string, message: string): ValidationIssue {
+ return { level: 'warning', file, message }
+}
diff --git a/scripts/lib/dev-overflow-audit.ts b/scripts/lib/dev-overflow-audit.ts
new file mode 100644
index 0000000..c5c6798
--- /dev/null
+++ b/scripts/lib/dev-overflow-audit.ts
@@ -0,0 +1,109 @@
+import { watch, type FSWatcher } from 'node:fs'
+import path from 'node:path'
+import type { DeckMetadata } from './content.ts'
+import { formatStaticOverflowResult, staticOverflowFingerprint } from './overflow-report.ts'
+import { StaticOverflowAuditor } from './static-overflow.ts'
+
+interface DevOverflowAuditOptions {
+ cwd?: string
+ debounceMs?: number
+ deck: DeckMetadata
+ output?: (line: string) => void
+}
+
+export interface DevOverflowAuditController {
+ close: () => Promise
+ ready: Promise
+}
+
+export function startDevOverflowAudit(options: DevOverflowAuditOptions): DevOverflowAuditController {
+ const auditor = new StaticOverflowAuditor()
+ const cwd = options.cwd ?? process.cwd()
+ const debounceMs = options.debounceMs ?? 250
+ const output = options.output ?? console.log
+ let activeAudit: Promise | undefined
+ let closed = false
+ let fingerprint = ''
+ let queued = false
+ let timer: ReturnType | undefined
+ let watcher: FSWatcher | undefined
+
+ function scheduleAudit(delay = debounceMs) {
+ if (closed)
+ return
+ if (timer)
+ clearTimeout(timer)
+ timer = setTimeout(() => {
+ timer = undefined
+ void runAudit()
+ }, delay)
+ }
+
+ async function runAudit() {
+ if (closed)
+ return
+ if (activeAudit) {
+ queued = true
+ return activeAudit
+ }
+
+ activeAudit = auditor.audit([options.deck])
+ .then((result) => {
+ if (closed)
+ return
+ const nextFingerprint = staticOverflowFingerprint(result)
+ if (nextFingerprint !== fingerprint) {
+ fingerprint = nextFingerprint
+ formatStaticOverflowResult(result, cwd).forEach(line => output(line))
+ }
+ })
+ .catch((error) => {
+ if (closed)
+ return
+ const message = error instanceof Error ? error.message : String(error)
+ const nextFingerprint = `error:${message}`
+ if (nextFingerprint !== fingerprint) {
+ fingerprint = nextFingerprint
+ output(`WARN ${path.relative(cwd, options.deck.entry)}: 无法完成静态内容检查:${message}`)
+ }
+ })
+ .finally(() => {
+ activeAudit = undefined
+ if (queued) {
+ queued = false
+ scheduleAudit(0)
+ }
+ })
+ return activeAudit
+ }
+
+ function startWatcher() {
+ const handleChange = () => scheduleAudit()
+ try {
+ watcher = watch(options.deck.directory, { recursive: true }, handleChange)
+ }
+ catch {
+ watcher = watch(options.deck.entry, handleChange)
+ }
+ }
+
+ const ready = (async () => {
+ output('\n检查开发内容中的静态溢出风险...')
+ startWatcher()
+ await runAudit()
+ })()
+
+ return {
+ ready,
+ async close() {
+ if (closed)
+ return
+ closed = true
+ watcher?.close()
+ if (timer)
+ clearTimeout(timer)
+ await ready.catch(() => undefined)
+ await activeAudit?.catch(() => undefined)
+ },
+ }
+}
diff --git a/scripts/lib/overflow-audit.ts b/scripts/lib/overflow-audit.ts
new file mode 100644
index 0000000..6e93947
--- /dev/null
+++ b/scripts/lib/overflow-audit.ts
@@ -0,0 +1,276 @@
+import { createReadStream } from 'node:fs'
+import { stat } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import { chromium, type Browser, type Page } from 'playwright-chromium'
+
+export interface OverflowAuditDeck {
+ entry: string
+ slug: string
+}
+
+export interface SlideOverflowIssue {
+ deck: OverflowAuditDeck
+ heading: string
+ horizontal: number
+ slide: number
+ vertical: number
+}
+
+export interface OverflowAuditResult {
+ errors: Array<{ deck: OverflowAuditDeck, message: string }>
+ issues: SlideOverflowIssue[]
+}
+
+interface OverflowAuditOptions {
+ decks: OverflowAuditDeck[]
+ root: string
+ siteBase: string
+}
+
+export interface OverflowAuditTarget {
+ deck: OverflowAuditDeck
+ routerMode?: 'hash' | 'history'
+ url: string
+}
+
+const overflowTolerance = 2
+
+export class OverflowAuditor {
+ private browser?: Browser
+ private page?: Page
+
+ async audit(targets: OverflowAuditTarget[]): Promise {
+ const page = await this.ensurePage()
+ const result: OverflowAuditResult = { errors: [], issues: [] }
+ for (const target of targets) {
+ try {
+ result.issues.push(...await auditDeck(page, target))
+ }
+ catch (error) {
+ result.errors.push({
+ deck: target.deck,
+ message: error instanceof Error ? error.message : String(error),
+ })
+ }
+ }
+ return result
+ }
+
+ async close() {
+ await this.browser?.close()
+ this.browser = undefined
+ this.page = undefined
+ }
+
+ private async ensurePage() {
+ if (this.page && !this.page.isClosed())
+ return this.page
+ await this.browser?.close().catch(() => undefined)
+ this.browser = await chromium.launch({ headless: true })
+ const context = await this.browser.newContext({ viewport: { height: 900, width: 1200 } })
+ await context.addInitScript(() => {
+ Object.defineProperty(navigator, 'wakeLock', {
+ configurable: true,
+ value: {
+ async request(type: string) {
+ const sentinel = new EventTarget() as EventTarget & {
+ release: () => Promise
+ released: boolean
+ type: string
+ }
+ sentinel.released = false
+ sentinel.type = type
+ sentinel.release = async () => {
+ if (sentinel.released)
+ return
+ sentinel.released = true
+ sentinel.dispatchEvent(new Event('release'))
+ }
+ return sentinel
+ },
+ },
+ })
+ })
+ this.page = await context.newPage()
+ return this.page
+ }
+}
+
+export async function auditBuiltDecks(options: OverflowAuditOptions): Promise {
+ const server = await startStaticServer(options.root, options.siteBase)
+ const address = server.address() as AddressInfo
+ const origin = `http://127.0.0.1:${address.port}`
+ const auditor = new OverflowAuditor()
+ try {
+ return await auditor.audit(options.decks.map(deck => ({
+ deck,
+ url: new URL(`${options.siteBase}${deck.slug}/`.replace(/\/{2,}/g, '/'), origin).href,
+ })))
+ }
+ finally {
+ await auditor.close()
+ await new Promise(resolve => server.close(() => resolve()))
+ }
+}
+
+async function auditDeck(page: Page, target: OverflowAuditTarget) {
+ const issues: SlideOverflowIssue[] = []
+ const overviewUrl = auditRouteUrl(target, 'overview')
+ await page.goto(overviewUrl.href, { timeout: 30_000, waitUntil: 'networkidle' })
+ await page.locator('.slidev-layout').first().waitFor({ state: 'attached', timeout: 15_000 })
+ await waitForPageAssets(page)
+ const slideNumbers = await page.locator('[class*="slidev-page-"]').evaluateAll((pages) => {
+ const numbers = pages.flatMap((page) => {
+ const pageClass = Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className))
+ const number = Number.parseInt(pageClass?.replace('slidev-page-', '') ?? '', 10)
+ return Number.isFinite(number) ? [number] : []
+ })
+ return Array.from(new Set(numbers)).sort((a, b) => a - b)
+ })
+ if (slideNumbers.length && target.routerMode !== 'history') {
+ await page.evaluate((number) => {
+ window.location.hash = `#/${number}`
+ }, slideNumbers[0])
+ await page.reload({ timeout: 30_000, waitUntil: 'networkidle' })
+ await page.evaluate(async () => {
+ await document.fonts?.ready
+ })
+ }
+ for (const slide of slideNumbers) {
+ if (target.routerMode === 'history')
+ await page.goto(auditRouteUrl(target, String(slide)).href, { timeout: 30_000, waitUntil: 'networkidle' })
+ else
+ await page.evaluate((number) => {
+ window.location.hash = `#/${number}`
+ }, slide)
+ await page.waitForFunction((number) => {
+ const layouts = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`))
+ return layouts.some(layout => layout.getBoundingClientRect().width > 0)
+ }, slide, { timeout: 10_000 })
+ await waitForSlideAssets(page, slide)
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
+ const measurement = await page.evaluate((number) => {
+ const layouts = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`))
+ const layout = layouts.find(item => item.getBoundingClientRect().width > 0) ?? layouts[0]
+ return {
+ heading: layout?.querySelector('h1, h2, h3')?.textContent?.trim() ?? '',
+ horizontal: layout ? Math.max(0, Math.ceil(layout.scrollWidth - layout.clientWidth)) : 0,
+ slide: number,
+ vertical: layout ? Math.max(0, Math.ceil(layout.scrollHeight - layout.clientHeight)) : 0,
+ }
+ }, slide)
+ if (measurement.vertical <= overflowTolerance && measurement.horizontal <= overflowTolerance)
+ continue
+ issues.push({ deck: target.deck, ...measurement })
+ }
+ return issues
+}
+
+function auditRouteUrl(target: OverflowAuditTarget, route: string) {
+ const url = target.routerMode === 'history'
+ ? new URL(`${route.replace(/^\/+/, '')}${route === 'overview' ? '/' : ''}`, ensureTrailingSlash(target.url))
+ : new URL(target.url)
+ url.searchParams.set('easy-slides-audit', '1')
+ if (target.routerMode !== 'history')
+ url.hash = `#/${route}`
+ return url
+}
+
+function ensureTrailingSlash(value: string) {
+ return value.endsWith('/') ? value : `${value}/`
+}
+
+async function waitForPageAssets(page: Page) {
+ await page.evaluate(async () => {
+ await document.fonts?.ready
+ const images = Array.from(document.images)
+ await Promise.race([
+ Promise.all(images.map(image => image.complete
+ ? Promise.resolve()
+ : new Promise((resolve) => {
+ image.addEventListener('load', () => resolve(), { once: true })
+ image.addEventListener('error', () => resolve(), { once: true })
+ }))),
+ new Promise(resolve => setTimeout(resolve, 5_000)),
+ ])
+ })
+}
+
+async function waitForSlideAssets(page: Page, slide: number) {
+ await page.evaluate(async (number) => {
+ const layout = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`))
+ .find(item => item.getBoundingClientRect().width > 0)
+ const images = Array.from(layout?.querySelectorAll('img') ?? [])
+ await Promise.race([
+ Promise.all(images.map(image => image.complete
+ ? Promise.resolve()
+ : new Promise((resolve) => {
+ image.addEventListener('load', () => resolve(), { once: true })
+ image.addEventListener('error', () => resolve(), { once: true })
+ }))),
+ new Promise(resolve => setTimeout(resolve, 5_000)),
+ ])
+ }, slide)
+}
+
+async function startStaticServer(root: string, siteBase: string) {
+ const server = createServer(async (request, response) => {
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
+ response.writeHead(405).end()
+ return
+ }
+ try {
+ const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1')
+ const file = await resolveStaticFile(root, requestUrl.pathname, siteBase)
+ response.statusCode = 200
+ response.setHeader('Content-Type', contentType(file))
+ if (request.method === 'HEAD') {
+ response.end()
+ return
+ }
+ createReadStream(file).pipe(response)
+ }
+ catch {
+ response.writeHead(404).end('Not found')
+ }
+ })
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => resolve())
+ })
+ return server
+}
+
+async function resolveStaticFile(root: string, requestPath: string, siteBase: string) {
+ let pathname = decodeURIComponent(requestPath)
+ const basePrefix = siteBase === '/' ? '' : siteBase.replace(/\/$/, '')
+ if (basePrefix && pathname.startsWith(`${basePrefix}/`))
+ pathname = pathname.slice(basePrefix.length)
+ const candidate = path.resolve(root, pathname.replace(/^\/+/, ''))
+ const relative = path.relative(root, candidate)
+ if (relative.startsWith('..') || path.isAbsolute(relative))
+ throw new Error('Path outside static root')
+ const details = await stat(candidate)
+ return details.isDirectory() ? path.join(candidate, 'index.html') : candidate
+}
+
+function contentType(file: string) {
+ const extension = path.extname(file).toLowerCase()
+ return ({
+ '.css': 'text/css; charset=utf-8',
+ '.gif': 'image/gif',
+ '.html': 'text/html; charset=utf-8',
+ '.jpeg': 'image/jpeg',
+ '.jpg': 'image/jpeg',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.ttf': 'font/ttf',
+ '.webp': 'image/webp',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ } as Record)[extension] ?? 'application/octet-stream'
+}
diff --git a/scripts/lib/overflow-report.ts b/scripts/lib/overflow-report.ts
new file mode 100644
index 0000000..e0e5da7
--- /dev/null
+++ b/scripts/lib/overflow-report.ts
@@ -0,0 +1,64 @@
+import path from 'node:path'
+import type { OverflowAuditResult } from './overflow-audit.ts'
+import type { StaticOverflowResult } from './static-overflow.ts'
+
+const overflowTolerance = 2
+
+export function formatOverflowAuditResult(result: OverflowAuditResult, cwd = process.cwd()) {
+ const lines: string[] = []
+ for (const issue of result.issues) {
+ const dimensions = [
+ issue.vertical > overflowTolerance ? `纵向 ${issue.vertical}px` : '',
+ issue.horizontal > overflowTolerance ? `横向 ${issue.horizontal}px` : '',
+ ].filter(Boolean).join('、')
+ const heading = issue.heading ? `(${issue.heading})` : ''
+ lines.push(`WARN ${path.relative(cwd, issue.deck.entry)}: 第 ${issue.slide} 页${heading}内容溢出:${dimensions};放映时可上下滚动,请优先拆页或精简内容`)
+ }
+ for (const error of result.errors)
+ lines.push(`WARN ${path.relative(cwd, error.deck.entry)}: 无法完成内容溢出检查:${error.message}`)
+ if (!lines.length)
+ lines.push('内容边界检查通过:未发现溢出')
+ return lines
+}
+
+export function overflowAuditFingerprint(result: OverflowAuditResult) {
+ return JSON.stringify({
+ errors: result.errors.map(error => [error.deck.entry, error.message]),
+ issues: result.issues.map(issue => [
+ issue.deck.entry,
+ issue.slide,
+ issue.heading,
+ issue.horizontal,
+ issue.vertical,
+ ]),
+ })
+}
+
+export function formatStaticOverflowResult(result: StaticOverflowResult, cwd = process.cwd()) {
+ const lines: string[] = []
+ for (const issue of result.issues) {
+ const directions = issue.directions
+ .map(direction => direction === 'vertical' ? '纵向' : '横向')
+ .join('、')
+ const heading = issue.heading ? `(${issue.heading})` : ''
+ lines.push(`WARN ${path.relative(cwd, issue.deck.entry)}: 第 ${issue.slide} 页${heading}存在静态${directions}溢出风险:${issue.reason};请优先拆页或精简内容(最终以 build 渲染检查为准)`)
+ }
+ for (const error of result.errors)
+ lines.push(`WARN ${path.relative(cwd, error.deck.entry)}: 无法完成静态内容检查:${error.message}`)
+ if (!lines.length)
+ lines.push('静态内容检查通过:未发现明显溢出风险(最终以 build 渲染检查为准)')
+ return lines
+}
+
+export function staticOverflowFingerprint(result: StaticOverflowResult) {
+ return JSON.stringify({
+ errors: result.errors.map(error => [error.deck.entry, error.message]),
+ issues: result.issues.map(issue => [
+ issue.deck.entry,
+ issue.slide,
+ issue.heading,
+ issue.directions,
+ issue.reason,
+ ]),
+ })
+}
diff --git a/scripts/lib/run-slidev.ts b/scripts/lib/run-slidev.ts
new file mode 100644
index 0000000..8bf8adc
--- /dev/null
+++ b/scripts/lib/run-slidev.ts
@@ -0,0 +1,41 @@
+import { spawn } from 'node:child_process'
+import type { ChildProcess } from 'node:child_process'
+import { createRequire } from 'node:module'
+import { hashPresenterToken } from './token.ts'
+
+const require = createRequire(import.meta.url)
+const slidevCli = require.resolve('@slidev/cli/bin/slidev.mjs')
+
+interface RunSlidevOptions {
+ env?: NodeJS.ProcessEnv
+}
+
+export function startSlidev(args: string[], options: RunSlidevOptions = {}) {
+ const token = options.env?.PRESENTER_TOKEN ?? process.env.PRESENTER_TOKEN
+ const presenterHash = token?.trim() ? hashPresenterToken(token) : ''
+ return spawn(process.execPath, [slidevCli, ...args], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ ...options.env,
+ VITE_EASY_SLIDES_PRESENTER_HASH: presenterHash,
+ },
+ stdio: 'inherit',
+ })
+}
+
+export async function waitForSlidev(child: ChildProcess) {
+ await new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.once('exit', (code, signal) => {
+ if (code === 0)
+ resolve()
+ else
+ reject(new Error(`Slidev 退出:${signal ?? `code ${code}`}`))
+ })
+ })
+}
+
+export async function runSlidev(args: string[], options: RunSlidevOptions = {}) {
+ await waitForSlidev(startSlidev(args, options))
+}
diff --git a/scripts/lib/site-base.ts b/scripts/lib/site-base.ts
new file mode 100644
index 0000000..318376c
--- /dev/null
+++ b/scripts/lib/site-base.ts
@@ -0,0 +1,17 @@
+export function normalizeSiteBase(value = '/') {
+ const trimmed = value.trim()
+ if (!trimmed || trimmed === '/')
+ return '/'
+
+ return `/${trimmed.replace(/^\/+|\/+$/g, '')}/`
+}
+
+export function joinBase(base: string, ...parts: string[]) {
+ const normalized = normalizeSiteBase(base)
+ const suffix = parts
+ .map(part => part.replace(/^\/+|\/+$/g, ''))
+ .filter(Boolean)
+ .join('/')
+
+ return suffix ? `${normalized}${suffix}/` : normalized
+}
diff --git a/scripts/lib/static-overflow.ts b/scripts/lib/static-overflow.ts
new file mode 100644
index 0000000..cbdafbf
--- /dev/null
+++ b/scripts/lib/static-overflow.ts
@@ -0,0 +1,394 @@
+import { readFile } from 'node:fs/promises'
+import { parseSync } from '@slidev/parser'
+import type { DeckMetadata } from './content.ts'
+
+export type StaticOverflowDirection = 'horizontal' | 'vertical'
+
+export interface StaticOverflowIssue {
+ deck: DeckMetadata
+ directions: StaticOverflowDirection[]
+ heading: string
+ reason: string
+ slide: number
+}
+
+export interface StaticOverflowResult {
+ errors: Array<{ deck: DeckMetadata, message: string }>
+ issues: StaticOverflowIssue[]
+}
+
+interface StaticOverflowAuditorOptions {
+ onAnalyzeSlide?: (deck: DeckMetadata, slide: number) => void
+}
+
+interface CachedSlide {
+ fingerprint: string
+ issues: StaticOverflowIssue[]
+}
+
+interface RegionEstimate {
+ height: number
+ horizontalRisk: boolean
+}
+
+interface SlideSource {
+ content: string
+ frontmatter: Record
+ revision?: string
+ title?: string
+}
+
+const canvasHeight = 768
+const canvasWidth = 1024
+const slidePaddingX = 52
+const slidePaddingY = 42
+const contentHeight = canvasHeight - slidePaddingY * 2
+const contentWidth = canvasWidth - slidePaddingX * 2
+const twoColumnGap = 46
+const baseFontSize = 32
+const baseLineHeight = baseFontSize * 1.42
+const verticalRiskTolerance = 1.08
+
+const codeHeightPresets = new Map([
+ ['easy-code-height-sm', 180],
+ ['easy-code-height-md', 300],
+ ['easy-code-height-lg', 420],
+])
+
+export class StaticOverflowAuditor {
+ private cache = new Map>()
+ private onAnalyzeSlide?: StaticOverflowAuditorOptions['onAnalyzeSlide']
+
+ constructor(options: StaticOverflowAuditorOptions = {}) {
+ this.onAnalyzeSlide = options.onAnalyzeSlide
+ }
+
+ async audit(decks: DeckMetadata[]): Promise {
+ const result: StaticOverflowResult = { errors: [], issues: [] }
+ for (const deck of decks) {
+ try {
+ result.issues.push(...await this.auditDeck(deck))
+ }
+ catch (error) {
+ result.errors.push({
+ deck,
+ message: error instanceof Error ? error.message : String(error),
+ })
+ }
+ }
+ return result
+ }
+
+ private async auditDeck(deck: DeckMetadata) {
+ const source = await readFile(deck.entry, 'utf8')
+ const parsed = parseSync(source, deck.entry)
+ const previous = this.cache.get(deck.entry) ?? new Map()
+ const next = new Map()
+ const issues: StaticOverflowIssue[] = []
+
+ parsed.slides.forEach((slide, index) => {
+ const number = index + 1
+ const fingerprint = slide.revision ?? slide.raw
+ const cached = previous.get(number)
+ if (cached?.fingerprint === fingerprint) {
+ next.set(number, cached)
+ issues.push(...cached.issues)
+ return
+ }
+
+ this.onAnalyzeSlide?.(deck, number)
+ const slideIssues = analyzeSlide(deck, number, slide)
+ next.set(number, { fingerprint, issues: slideIssues })
+ issues.push(...slideIssues)
+ })
+
+ this.cache.set(deck.entry, next)
+ return issues
+ }
+}
+
+export async function auditStaticDecks(decks: DeckMetadata[]) {
+ return await new StaticOverflowAuditor().audit(decks)
+}
+
+function analyzeSlide(deck: DeckMetadata, number: number, slide: SlideSource): StaticOverflowIssue[] {
+ const content = stripComments(slide.content)
+ const layout = String(slide.frontmatter.layout ?? (number === 1 ? 'cover' : 'default'))
+ const classNames = normalizeClasses(slide.frontmatter.class)
+ const heading = slide.title?.trim() || extractHeading(content)
+ const estimates = estimateSlide(content, layout, classNames, String(slide.frontmatter.imagePlacement ?? 'auto'))
+ const directions: StaticOverflowDirection[] = []
+
+ if (estimates.some(estimate => estimate.height > contentHeight * verticalRiskTolerance))
+ directions.push('vertical')
+ if (estimates.some(estimate => estimate.horizontalRisk))
+ directions.push('horizontal')
+ if (!directions.length)
+ return []
+
+ const reason = directions.length === 2
+ ? '估算内容高度超过画布容量,且存在无法可靠换行的宽内容'
+ : directions[0] === 'vertical'
+ ? '估算内容高度超过 easy-jyy 画布容量'
+ : '存在无法可靠换行的宽内容'
+
+ return [{ deck, directions, heading, reason, slide: number }]
+}
+
+function estimateSlide(content: string, layout: string, classNames: string[], imagePlacement: string) {
+ if (layout === 'two-cols') {
+ const slots = splitTwoColumnContent(content)
+ const headingEstimate = estimateRegion(slots.before, contentWidth, layout, classNames)
+ const columnWidth = (contentWidth - twoColumnGap) / 2
+ const left = estimateRegion(slots.left, columnWidth, layout, classNames)
+ const right = estimateRegion(slots.right, columnWidth, layout, classNames)
+ return [
+ {
+ height: headingEstimate.height + Math.max(left.height, right.height),
+ horizontalRisk: headingEstimate.horizontalRisk || left.horizontalRisk || right.horizontalRisk,
+ },
+ ]
+ }
+
+ const splitImageLayout = ['image-left', 'image-right'].includes(layout)
+ || (layout === 'image-auto' && ['left', 'right'].includes(imagePlacement))
+ const width = splitImageLayout ? (contentWidth - 42) / 2 : contentWidth
+ return [estimateRegion(content, width, layout, classNames)]
+}
+
+function splitTwoColumnContent(content: string) {
+ const leftMarker = /^::left::\s*$/m
+ const rightMarker = /^::right::\s*$/m
+ const leftMatch = leftMarker.exec(content)
+ const rightMatch = rightMarker.exec(content)
+ if (!leftMatch && !rightMatch)
+ return { before: content, left: '', right: '' }
+
+ const firstIndex = Math.min(leftMatch?.index ?? Number.POSITIVE_INFINITY, rightMatch?.index ?? Number.POSITIVE_INFINITY)
+ const before = content.slice(0, firstIndex)
+ const left = leftMatch
+ ? content.slice(leftMatch.index + leftMatch[0].length, rightMatch && rightMatch.index > leftMatch.index ? rightMatch.index : undefined)
+ : ''
+ const right = rightMatch
+ ? content.slice(rightMatch.index + rightMatch[0].length, leftMatch && leftMatch.index > rightMatch.index ? leftMatch.index : undefined)
+ : ''
+ return { before, left, right }
+}
+
+function estimateRegion(markdown: string, width: number, layout: string, classNames: string[]): RegionEstimate {
+ const lines = markdown.replace(/\r/g, '').split('\n')
+ let height = 0
+ let horizontalRisk = false
+
+ for (let index = 0; index < lines.length;) {
+ const raw = lines[index]
+ const line = raw.trim()
+ if (!line || isStructuralLine(line)) {
+ index++
+ continue
+ }
+
+ const fence = /^(?:`{3,}|~{3,})([^\s{]*)/.exec(line)
+ if (fence) {
+ const marker = line.startsWith('`') ? '`' : '~'
+ const markerLength = line.match(new RegExp(`^\\${marker}+`))?.[0].length ?? 3
+ const language = fence[1].toLowerCase()
+ let end = index + 1
+ while (end < lines.length && !new RegExp(`^\\s*\\${marker}{${markerLength},}`).test(lines[end]))
+ end++
+ if (language === 'mermaid')
+ height += 180
+ else
+ height += explicitCodeHeight(lines, index, classNames)
+ index = Math.min(lines.length, end + 1)
+ continue
+ }
+
+ const heading = /^(#{1,4})\s+(.+)$/.exec(line)
+ if (heading) {
+ const level = heading[1].length
+ const fontSize = level === 1 ? (layout === 'cover' ? 68 : 60) : level === 2 ? 48 : level === 3 ? 38 : 32
+ const wraps = wrappedLines(heading[2], width, fontSize)
+ const margins = level === 1 ? 26 : level === 2 ? 39 : level === 3 ? 34 : 24
+ height += wraps * fontSize * 1.15 + margins
+ horizontalRisk ||= hasHorizontalRisk(heading[2], width, fontSize)
+ index++
+ continue
+ }
+
+ if (line === '$$' || line.startsWith('$$')) {
+ let end = index + 1
+ while (end < lines.length && !lines[end].includes('$$'))
+ end++
+ height += 82 + Math.max(0, end - index - 2) * 38
+ index = Math.min(lines.length, end + 1)
+ continue
+ }
+
+ if (isStandaloneImage(line)) {
+ height += canvasHeight * 0.46 + 16
+ index++
+ continue
+ }
+
+ if (isTableRow(line) && isTableSeparator(lines[index + 1] ?? '')) {
+ const tableLines: string[] = [line]
+ index += 2
+ while (index < lines.length && isTableRow(lines[index].trim())) {
+ tableLines.push(lines[index].trim())
+ index++
+ }
+ const columnCount = Math.max(1, splitTableRow(tableLines[0]).length)
+ const cellWidth = width / columnCount - 28
+ for (const row of tableLines) {
+ const rowWraps = Math.max(...splitTableRow(row).map(cell => wrappedLines(cell, cellWidth, 30)), 1)
+ height += rowWraps * 30 * 1.42 + 20
+ }
+ continue
+ }
+
+ if (isListLine(line)) {
+ const items: string[] = []
+ while (index < lines.length && (isListLine(lines[index].trim()) || /^\s{2,}\S/.test(lines[index]))) {
+ if (isListLine(lines[index].trim()))
+ items.push(lines[index].trim().replace(/^(?:[-+*]|\d+[.)])\s+/, ''))
+ index++
+ }
+ const itemHeight = items.reduce((sum, item) => sum + wrappedLines(item, width - baseFontSize * 1.2, baseFontSize) * baseLineHeight, 0)
+ height += itemHeight + Math.max(0, items.length - 1) * 10 + 28
+ horizontalRisk ||= items.some(item => hasHorizontalRisk(item, width - baseFontSize * 1.2, baseFontSize))
+ continue
+ }
+
+ if (line.startsWith('>')) {
+ const quote: string[] = []
+ while (index < lines.length && lines[index].trim().startsWith('>')) {
+ quote.push(lines[index].trim().replace(/^>\s?/, ''))
+ index++
+ }
+ const fontSize = layout === 'quote' ? 46 : baseFontSize
+ height += wrappedLines(quote.join(' '), width - 29, fontSize) * fontSize * (layout === 'quote' ? 1.35 : 1.42) + 48
+ horizontalRisk ||= hasHorizontalRisk(quote.join(' '), width - 29, fontSize)
+ continue
+ }
+
+ const paragraph: string[] = []
+ while (index < lines.length && !isBlockStart(lines, index)) {
+ const text = stripMarkup(lines[index])
+ if (text)
+ paragraph.push(text)
+ index++
+ }
+ if (paragraph.length) {
+ const text = paragraph.join(' ')
+ height += wrappedLines(text, width, baseFontSize) * baseLineHeight + 28
+ horizontalRisk ||= hasHorizontalRisk(text, width, baseFontSize)
+ }
+ else {
+ index++
+ }
+ }
+
+ horizontalRisk ||= explicitWidthRisk(markdown, width)
+ return { height, horizontalRisk }
+}
+
+function isBlockStart(lines: string[], index: number) {
+ const line = lines[index].trim()
+ if (!line || isStructuralLine(line) || /^(?:`{3,}|~{3,}|#{1,4}\s|\$\$|>)/.test(line))
+ return true
+ if (isStandaloneImage(line) || isListLine(line))
+ return true
+ return isTableRow(line) && isTableSeparator(lines[index + 1] ?? '')
+}
+
+function explicitCodeHeight(lines: string[], fenceIndex: number, classNames: string[]) {
+ const nearby = `${classNames.join(' ')} ${lines.slice(Math.max(0, fenceIndex - 8), fenceIndex + 1).join(' ')}`
+ for (const [className, height] of codeHeightPresets) {
+ if (new RegExp(`(?:^|\\s|["'])${className}(?:$|\\s|["'])`).test(nearby))
+ return height
+ }
+ const inlineHeight = /(?:height|max-height)\s*:\s*(\d+(?:\.\d+)?)px/i.exec(nearby)
+ return inlineHeight ? Number.parseFloat(inlineHeight[1]) : 0
+}
+
+function wrappedLines(value: string, width: number, fontSize: number) {
+ const available = Math.max(1, width / fontSize)
+ return Math.max(1, Math.ceil(displayWidth(stripMarkup(value)) / available))
+}
+
+function displayWidth(value: string) {
+ let width = 0
+ for (const character of value) {
+ if (/\s/.test(character))
+ width += 0.33
+ else if (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/.test(character))
+ width += 1
+ else
+ width += 0.56
+ }
+ return width
+}
+
+function hasHorizontalRisk(value: string, width: number, fontSize: number) {
+ const available = width / fontSize
+ return stripMarkup(value)
+ .split(/\s+/)
+ .some(token => !/[\u2e80-\u9fff]/.test(token) && displayWidth(token) > available * 1.05)
+}
+
+function explicitWidthRisk(markdown: string, width: number) {
+ for (const match of markdown.matchAll(/(?:width\s*:\s*|width\s*=\s*["']?)(\d+(?:\.\d+)?)px/gi)) {
+ if (Number.parseFloat(match[1]) > width)
+ return true
+ }
+ return false
+}
+
+function normalizeClasses(value: unknown) {
+ if (Array.isArray(value))
+ return value.flatMap(item => String(item).split(/\s+/)).filter(Boolean)
+ return String(value ?? '').split(/\s+/).filter(Boolean)
+}
+
+function extractHeading(markdown: string) {
+ return /^(?:#{1,4})\s+(.+)$/m.exec(markdown)?.[1].trim() ?? ''
+}
+
+function stripComments(markdown: string) {
+ return markdown.replace(//g, '')
+}
+
+function stripMarkup(value: string) {
+ return value
+ .replace(/!\[[^\]]*\]\([^)]*\)(?:\{[^}]*\})?/g, '')
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/[`*_~]/g, '')
+ .trim()
+}
+
+function isStructuralLine(line: string) {
+ return /^::(?:left|right|image)::$/i.test(line)
+ || /^<\/?(?:v-clicks|div|span|template|slot)(?:\s[^>]*)?>$/i.test(line)
+}
+
+function isStandaloneImage(line: string) {
+ return /^!\[[^\]]*\]\([^)]*\)(?:\{[^}]*\})?$/.test(line)
+}
+
+function isListLine(line: string) {
+ return /^(?:[-+*]|\d+[.)])\s+/.test(line)
+}
+
+function isTableRow(line: string) {
+ return line.includes('|')
+}
+
+function isTableSeparator(line: string) {
+ return /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*\|?\s*$/.test(line)
+}
+
+function splitTableRow(line: string) {
+ return line.replace(/^\s*\||\|\s*$/g, '').split('|').map(cell => cell.trim())
+}
diff --git a/scripts/lib/theme.ts b/scripts/lib/theme.ts
new file mode 100644
index 0000000..79824b8
--- /dev/null
+++ b/scripts/lib/theme.ts
@@ -0,0 +1,17 @@
+import { existsSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const directory = path.dirname(fileURLToPath(import.meta.url))
+const bundledTheme = path.resolve(directory, '..', 'theme')
+const workspaceTheme = path.resolve(directory, '..', '..', 'packages', 'slidev-theme-easy-jyy')
+
+export function resolveTheme(theme?: string) {
+ if (theme)
+ return theme
+ if (existsSync(path.join(bundledTheme, 'package.json')))
+ return bundledTheme
+ if (existsSync(path.join(workspaceTheme, 'package.json')))
+ return workspaceTheme
+ throw new Error('easy-slides 主题不存在;请重新安装或构建 @easy-slides/cli')
+}
diff --git a/scripts/lib/token.ts b/scripts/lib/token.ts
new file mode 100644
index 0000000..18cf943
--- /dev/null
+++ b/scripts/lib/token.ts
@@ -0,0 +1,9 @@
+import { createHash } from 'node:crypto'
+
+export function hashPresenterToken(token: string) {
+ return createHash('sha256').update(token.trim()).digest('hex')
+}
+
+export function hasPresenterToken(env: NodeJS.ProcessEnv = process.env) {
+ return Boolean(env.PRESENTER_TOKEN?.trim())
+}
diff --git a/scripts/present.ts b/scripts/present.ts
new file mode 100644
index 0000000..d40ce02
--- /dev/null
+++ b/scripts/present.ts
@@ -0,0 +1,44 @@
+import 'dotenv/config'
+import { networkInterfaces } from 'node:os'
+import qrcode from 'qrcode-terminal'
+import { loadProjectConfig } from './lib/config.ts'
+import { resolveDeck } from './lib/content.ts'
+import { runSlidev } from './lib/run-slidev.ts'
+import { resolveTheme } from './lib/theme.ts'
+
+const input = process.argv.slice(2).find(argument => !argument.startsWith('-'))
+const config = await loadProjectConfig()
+const deck = await resolveDeck(input, process.cwd(), config.slidesDir)
+const port = process.env.PORT || '3030'
+const address = firstLanAddress()
+const remoteToken = process.env.SLIDEV_REMOTE_TOKEN || 'easy-slides-local'
+const remotePassword = encodeURIComponent(remoteToken)
+const presenterUrl = `http://${address}:${port}/presenter/?password=${remotePassword}`
+const remoteUrl = `http://${address}:${port}/entry?password=${remotePassword}`
+
+console.log(`\n观众视图:http://${address}:${port}/`)
+console.log(`演示者视图:${presenterUrl}`)
+console.log(`手机遥控器:${remoteUrl}`)
+qrcode.generate(presenterUrl, { small: true })
+
+await runSlidev([
+ deck.entry,
+ '--theme',
+ resolveTheme(config.theme),
+ '--port',
+ port,
+ '--remote',
+ remoteToken,
+ '--bind',
+ '0.0.0.0',
+])
+
+function firstLanAddress() {
+ for (const entries of Object.values(networkInterfaces())) {
+ for (const entry of entries ?? []) {
+ if (entry.family === 'IPv4' && !entry.internal)
+ return entry.address
+ }
+ }
+ return 'localhost'
+}