feat: add easy-slides CLI
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user