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 + ? `` + : `` + const tags = deck.tags.map(tag => `
  • ${escapeHtml(tag)}
  • `).join('') + return `
    + ${cover} +
    +

    ${escapeHtml(deck.date || 'Undated')}${deck.author ? ` · ${escapeHtml(deck.author)}` : ''}

    +

    ${escapeHtml(deck.title)}

    + ${deck.description ? `

    ${escapeHtml(deck.description)}

    ` : ''} + ${tags ? `` : ''} +
    +
    ` + }).join('\n') + + return ` + + + + + + ${escapeHtml(config.title)} + + + +
    +
    +

    MARKDOWN PRESENTATIONS

    +

    ${escapeHtml(config.title)}

    +

    ${escapeHtml(config.description)}

    +
    +
    + ${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' +}