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
+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 }
}