build: add precompiled CLI distribution
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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
|
||||
Reference in New Issue
Block a user