124 lines
5.2 KiB
JavaScript
124 lines
5.2 KiB
JavaScript
import { access, readFile } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import fg from 'fast-glob';
|
||
import matter from 'gray-matter';
|
||
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') {
|
||
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) {
|
||
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) => 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, 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) {
|
||
const issues = [];
|
||
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) {
|
||
const images = [];
|
||
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) {
|
||
const attributes = {};
|
||
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) {
|
||
if (!value)
|
||
return '';
|
||
if (value instanceof Date)
|
||
return value.toISOString().slice(0, 10);
|
||
return String(value).trim();
|
||
}
|
||
function compareDecks(a, b) {
|
||
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) {
|
||
return /^(?:https?:)?\/\//.test(value) || value.startsWith('data:');
|
||
}
|
||
function isSafeCssSize(value) {
|
||
return /^(?:\d+(?:\.\d+)?)(?:px|rem|em|vh|vw|%|vmin|vmax)$/.test(value);
|
||
}
|
||
async function exists(file) {
|
||
try {
|
||
await access(file);
|
||
return true;
|
||
}
|
||
catch {
|
||
return false;
|
||
}
|
||
}
|
||
function error(file, message) {
|
||
return { level: 'error', file, message };
|
||
}
|
||
function warning(file, message) {
|
||
return { level: 'warning', file, message };
|
||
}
|
||
//# sourceMappingURL=content.js.map
|