Files
easy-slides/scripts/lib/content.ts
T
2026-08-29 18:59:33 +08:00

164 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}