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