feat: add easy-slides CLI

This commit is contained in:
2026-08-29 18:59:33 +08:00
parent 2ca064d303
commit 33152a90dc
19 changed files with 1692 additions and 0 deletions
+70
View File
@@ -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
}
}
+163
View File
@@ -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 }
}
+109
View File
@@ -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)
},
}
}
+276
View File
@@ -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'
}
+64
View File
@@ -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,
]),
})
}
+41
View File
@@ -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))
}
+17
View File
@@ -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
}
+394
View File
@@ -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())
}
+17
View File
@@ -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')
}
+9
View File
@@ -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())
}