feat: add easy-slides CLI
This commit is contained in:
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string) {
|
||||||
|
return escapeHtml(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -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}/<slug>/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()
|
||||||
@@ -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 <package-spec>]
|
||||||
|
easy-slides dev [slug|slides.md]
|
||||||
|
easy-slides present <slug>
|
||||||
|
easy-slides build
|
||||||
|
easy-slides check
|
||||||
|
easy-slides export <slug> --format pdf|png|pptx
|
||||||
|
|
||||||
|
全局选项:
|
||||||
|
--root <directory> 在指定内容项目中运行`)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)}`)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { loadProjectConfig, normalizeConfig } from './lib/config.ts'
|
||||||
|
export type { EasySlidesConfig } from './lib/config.ts'
|
||||||
+134
@@ -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.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Speaker notes are written in HTML comments.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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("'", "\\'")
|
||||||
|
}
|
||||||
@@ -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<EasySlidesConfig> {
|
||||||
|
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<EasySlidesConfig> | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeckMetadata[]> {
|
||||||
|
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<DeckMetadata> {
|
||||||
|
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<ValidationIssue[]> {
|
||||||
|
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<string, string> }> = []
|
||||||
|
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<string, string> = {}
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<void>
|
||||||
|
ready: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> | undefined
|
||||||
|
let closed = false
|
||||||
|
let fingerprint = ''
|
||||||
|
let queued = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OverflowAuditResult> {
|
||||||
|
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<void>
|
||||||
|
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<OverflowAuditResult> {
|
||||||
|
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<void>(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<HTMLElement>(`.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<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||||
|
const measurement = await page.evaluate((number) => {
|
||||||
|
const layouts = Array.from(document.querySelectorAll<HTMLElement>(`.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<void>((resolve) => {
|
||||||
|
image.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
image.addEventListener('error', () => resolve(), { once: true })
|
||||||
|
}))),
|
||||||
|
new Promise<void>(resolve => setTimeout(resolve, 5_000)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSlideAssets(page: Page, slide: number) {
|
||||||
|
await page.evaluate(async (number) => {
|
||||||
|
const layout = Array.from(document.querySelectorAll<HTMLElement>(`.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<void>((resolve) => {
|
||||||
|
image.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
image.addEventListener('error', () => resolve(), { once: true })
|
||||||
|
}))),
|
||||||
|
new Promise<void>(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<void>((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<string, string>)[extension] ?? 'application/octet-stream'
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<void>((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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<string, Map<number, CachedSlide>>()
|
||||||
|
private onAnalyzeSlide?: StaticOverflowAuditorOptions['onAnalyzeSlide']
|
||||||
|
|
||||||
|
constructor(options: StaticOverflowAuditorOptions = {}) {
|
||||||
|
this.onAnalyzeSlide = options.onAnalyzeSlide
|
||||||
|
}
|
||||||
|
|
||||||
|
async audit(decks: DeckMetadata[]): Promise<StaticOverflowResult> {
|
||||||
|
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<number, CachedSlide>()
|
||||||
|
const next = new Map<number, CachedSlide>()
|
||||||
|
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(/<!--[\s\S]*?-->/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())
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user