From a20063c624d68c0580b6cc6e4e72685aab969ccf Mon Sep 17 00:00:00 2001 From: hyper Date: Thu, 9 Jul 2026 14:31:08 +0800 Subject: [PATCH] refactor(cli): Extract shared deck and frontmatter utils Create deck-utils.ts and frontmatter/shared.ts to consolidate duplicated logic. Rename ensure-deck-preview.ts to preview-deck.ts. --- docs/mcp.md | 3 +- src/cli/tools/card/card-crud.ts | 144 +++++------ src/cli/tools/deck-utils.ts | 134 ++++++++++ src/cli/tools/ensure-deck-preview.ts | 217 ---------------- src/cli/tools/frontmatter/read-frontmatter.ts | 44 +--- src/cli/tools/frontmatter/shared.ts | 48 ++++ .../tools/frontmatter/write-frontmatter.ts | 73 ++---- src/cli/tools/generate-card-deck.ts | 234 ++++++------------ src/cli/tools/preview-deck.ts | 201 ++++----------- 9 files changed, 409 insertions(+), 689 deletions(-) create mode 100644 src/cli/tools/deck-utils.ts delete mode 100644 src/cli/tools/ensure-deck-preview.ts create mode 100644 src/cli/tools/frontmatter/shared.ts diff --git a/docs/mcp.md b/docs/mcp.md index 04599bd..6346552 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -535,7 +535,8 @@ src/cli/ │ │ └── write-frontmatter.ts │ ├── card/ │ │ └── card-crud.ts -│ ├── ensure-deck-preview.ts +│ ├── deck-utils.ts +│ ├── preview-deck.ts │ └── generate-card-deck.ts └── index.ts ``` diff --git a/src/cli/tools/card/card-crud.ts b/src/cli/tools/card/card-crud.ts index 01ffc5c..ec7f378 100644 --- a/src/cli/tools/card/card-crud.ts +++ b/src/cli/tools/card/card-crud.ts @@ -1,8 +1,11 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { parse } from 'csv-parse/browser/esm/sync'; -import { stringify } from 'csv-stringify/browser/esm/sync'; -import yaml from 'js-yaml'; -import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js'; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { parse } from "csv-parse/browser/esm/sync"; +import { stringify } from "csv-stringify/browser/esm/sync"; +import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js"; +import { + parseFrontMatter, + serializeFrontMatter, +} from "../frontmatter/shared.js"; /** * 卡牌数据 @@ -23,7 +26,7 @@ export interface CardCrudParams { /** * 操作类型 */ - action: 'create' | 'read' | 'update' | 'delete'; + action: "create" | "read" | "update" | "delete"; /** * 卡牌数据(单张或数组) */ @@ -44,41 +47,6 @@ export interface CardCrudResult { count?: number; } -/** - * 解析 CSV 文件的 frontmatter - */ -function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { - const parts = content.trim().split(/(?:^|\n)---\s*\n/g); - - if (parts.length !== 3 || parts[0] !== '') { - return { csvContent: content }; - } - - try { - const frontmatterStr = parts[1].trim(); - const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; - const csvContent = parts.slice(2).join('---\n').trimStart(); - return { frontmatter, csvContent }; - } catch (error) { - console.warn('Failed to parse front matter:', error); - return { csvContent: content }; - } -} - -/** - * 序列化 frontmatter 为 YAML 字符串 - */ -function serializeFrontMatter(frontmatter: DeckFrontmatter): string { - const yamlStr = yaml.dump(frontmatter, { - indent: 2, - lineWidth: -1, - noRefs: true, - quotingType: '"', - forceQuotes: false - }); - return `---\n${yamlStr}---\n`; -} - /** * 加载 CSV 数据(包含 frontmatter) */ @@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): { records: CardData[]; headers: string[]; } { - const content = readFileSync(filePath, 'utf-8'); + const content = readFileSync(filePath, "utf-8"); const { frontmatter, csvContent } = parseFrontMatter(content); const records = parse(csvContent, { columns: true, - comment: '#', + comment: "#", trim: true, - skipEmptyLines: true + skipEmptyLines: true, }) as CardData[]; // 获取表头 - const firstLine = csvContent.split('\n')[0]; - const headers = firstLine.split(',').map(h => h.trim()); + const firstLine = csvContent.split("\n")[0]; + const headers = firstLine.split(",").map((h) => h.trim()); return { frontmatter, records, headers }; } @@ -111,30 +79,30 @@ function saveCSVWithFrontmatter( filePath: string, frontmatter: DeckFrontmatter | undefined, records: CardData[], - headers?: string[] + headers?: string[], ): void { // 序列化 frontmatter - const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ''; + const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ""; // 确定表头 if (!headers || headers.length === 0) { // 从 records 和 frontmatter.fields 推断表头 - headers = ['label']; + headers = ["label"]; if (frontmatter?.fields && Array.isArray(frontmatter.fields)) { for (const field of frontmatter.fields) { - if (field.name && typeof field.name === 'string') { + if (field.name && typeof field.name === "string") { headers.push(field.name); } } } - headers.push('body'); + headers.push("body"); } // 确保所有 record 都有 headers 中的列 for (const record of records) { for (const header of headers) { if (!(header in record)) { - record[header] = ''; + record[header] = ""; } } } @@ -142,11 +110,11 @@ function saveCSVWithFrontmatter( // 序列化 CSV const csvContent = stringify(records, { header: true, - columns: headers + columns: headers, }); // 写入文件 - writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8'); + writeFileSync(filePath, frontmatterStr + csvContent, "utf-8"); } /** @@ -167,10 +135,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { const { csv_file, action, cards, label } = params; // 检查文件是否存在(create 操作可以不存在) - if (action !== 'create' && !existsSync(csv_file)) { + if (action !== "create" && !existsSync(csv_file)) { return { success: false, - message: `文件不存在:${csv_file}` + message: `文件不存在:${csv_file}`, }; } @@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { // 执行操作 switch (action) { - case 'create': { - const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); + case "create": { + const newCards = Array.isArray(cards) ? cards : cards ? [cards] : []; for (const card of newCards) { if (!card.label) { card.label = generateNextLabel(records); @@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { success: true, message: `成功创建 ${newCards.length} 张卡牌`, cards: newCards, - count: newCards.length + count: newCards.length, }; } - case 'read': { - const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null); + case "read": { + const labelsToRead = Array.isArray(label) + ? label + : label + ? [label] + : null; let resultCards: CardData[]; if (labelsToRead && labelsToRead.length > 0) { - resultCards = records.filter(r => labelsToRead.includes(r.label || '')); + resultCards = records.filter((r) => + labelsToRead.includes(r.label || ""), + ); } else { resultCards = records; } @@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { success: true, message: `成功读取 ${resultCards.length} 张卡牌`, cards: resultCards, - count: resultCards.length + count: resultCards.length, }; } - case 'update': { - const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null); - const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); + case "update": { + const labelsToUpdate = Array.isArray(label) + ? label + : label + ? [label] + : null; + const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : []; let updatedCount = 0; if (labelsToUpdate && labelsToUpdate.length > 0) { // 按 label 更新 for (const updateCard of updateCards) { - const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length]; - const index = records.findIndex(r => r.label === targetLabel); + const targetLabel = + updateCard.label || + labelsToUpdate[updatedCount % labelsToUpdate.length]; + const index = records.findIndex((r) => r.label === targetLabel); if (index !== -1) { records[index] = { ...records[index], ...updateCard }; updatedCount++; @@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { // 按 cards 中的 label 更新 for (const updateCard of updateCards) { if (updateCard.label) { - const index = records.findIndex(r => r.label === updateCard.label); + const index = records.findIndex( + (r) => r.label === updateCard.label, + ); if (index !== -1) { records[index] = { ...records[index], ...updateCard }; updatedCount++; @@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { success: true, message: `成功更新 ${updatedCount} 张卡牌`, cards: updateCards, - count: updatedCount + count: updatedCount, }; } - case 'delete': { - const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null); + case "delete": { + const labelsToDelete = Array.isArray(label) + ? label + : label + ? [label] + : null; let deletedCount = 0; if (labelsToDelete && labelsToDelete.length > 0) { const beforeCount = records.length; - records = records.filter(r => !labelsToDelete.includes(r.label || '')); + records = records.filter( + (r) => !labelsToDelete.includes(r.label || ""), + ); deletedCount = beforeCount - records.length; } else if (cards) { const cardsToDelete = Array.isArray(cards) ? cards : [cards]; const beforeCount = records.length; - records = records.filter(r => - !cardsToDelete.some(c => c.label && r.label === c.label) + records = records.filter( + (r) => !cardsToDelete.some((c) => c.label && r.label === c.label), ); deletedCount = beforeCount - records.length; } @@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult { return { success: true, message: `成功删除 ${deletedCount} 张卡牌`, - count: deletedCount + count: deletedCount, }; } default: return { success: false, - message: `未知操作:${action}` + message: `未知操作:${action}`, }; } } catch (error) { return { success: false, - message: `操作失败:${error instanceof Error ? error.message : '未知错误'}` + message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`, }; } } diff --git a/src/cli/tools/deck-utils.ts b/src/cli/tools/deck-utils.ts new file mode 100644 index 0000000..6261b3f --- /dev/null +++ b/src/cli/tools/deck-utils.ts @@ -0,0 +1,134 @@ +/** + * Shared deck utility functions — used by generate-card-deck and preview-deck. + */ + +import { dirname, join, basename, extname, relative } from "path"; +import type { + DeckConfig, + DeckFrontmatter, +} from "./frontmatter/read-frontmatter.js"; +import { parseFrontMatter } from "./frontmatter/shared.js"; +import { readFileSync } from "fs"; + +/** + * Build a `:md-deck[path]{attrs}` directive string from a CSV path and config. + */ +export function buildDeckComponent( + csvPath: string, + config?: DeckConfig, +): string { + const parts = [`:md-deck[${csvPath}]`]; + const attrs: string[] = []; + + if (config?.size) { + attrs.push(`size="${config.size}"`); + } + + if (config?.grid) { + attrs.push(`grid="${config.grid}"`); + } + + if (config?.bleed !== undefined && config.bleed !== 1) { + attrs.push(`bleed="${config.bleed}"`); + } + + if (config?.padding !== undefined && config.padding !== 2) { + attrs.push(`padding="${config.padding}"`); + } + + if (config?.shape && config.shape !== "rectangle") { + attrs.push(`shape="${config.shape}"`); + } + + if (config?.layers) { + attrs.push(`layers="${config.layers}"`); + } + + if (config?.back_layers) { + attrs.push(`back-layers="${config.back_layers}"`); + } + + if (attrs.length > 0) { + parts.push(`{${attrs.join(" ")}}`); + } + + return parts.join(""); +} + +/** + * Derive a human-readable title from a CSV filename. + * e.g. "my-deck.csv" → "My Deck" + */ +export function titleFromCsvPath(csvPath: string): string { + const name = basename(csvPath, extname(csvPath)); + return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Compute the relative path from a markdown file to its CSV. + * Handles the case where they are in the same or different directories. + */ +export function relativeCsvPath( + mdFilePath: string, + csvFilePath: string, +): string { + const mdDir = dirname(mdFilePath); + const rel = relative(mdDir, csvFilePath); + return rel.startsWith(".") ? rel : `./${rel}`; +} + +/** + * Determine the default markdown file path for a CSV. + * e.g. "foo/bar.csv" → "foo/bar.md" + */ +export function defaultMdPath(csvFilePath: string): string { + const dir = dirname(csvFilePath); + const name = basename(csvFilePath, extname(csvFilePath)); + return join(dir, `${name}.md`); +} + +/** + * Read DeckConfig from a CSV file's frontmatter, if present. + */ +export function readDeckConfig(csvFilePath: string): DeckConfig | undefined { + try { + const content = readFileSync(csvFilePath, "utf-8"); + const { frontmatter } = parseFrontMatter(content); + return frontmatter?.deck; + } catch { + return undefined; + } +} + +/** + * Generate the standard markdown content for a deck preview file. + */ +export function generateDeckPreviewMd( + deckTitle: string, + deckComponent: string, + description?: string, +): string { + const lines: string[] = []; + + lines.push(`# ${deckTitle}`); + lines.push(""); + + if (description) { + lines.push(description); + lines.push(""); + } + + lines.push("## 卡牌预览"); + lines.push(""); + lines.push(deckComponent); + lines.push(""); + + lines.push("## 使用说明"); + lines.push(""); + lines.push("- 点击卡牌可以查看详情"); + lines.push("- 使用右上角的按钮可以随机抽取卡牌"); + lines.push("- 可以通过编辑面板调整卡牌样式和布局"); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/cli/tools/ensure-deck-preview.ts b/src/cli/tools/ensure-deck-preview.ts deleted file mode 100644 index d2c9478..0000000 --- a/src/cli/tools/ensure-deck-preview.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { dirname, join, basename, extname } from 'path'; -import yaml from 'js-yaml'; -import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; - -/** - * 确保 Deck 预览文件的参数 - */ -export interface EnsureDeckPreviewParams { - /** - * CSV 文件路径 - */ - csv_file: string; - /** - * Markdown 文件路径(可选,默认与 CSV 同名) - */ - md_file?: string; - /** - * 标题(可选,默认从 CSV 文件名推断) - */ - title?: string; - /** - * 描述(可选) - */ - description?: string; -} - -/** - * 确保 Deck 预览文件的结果 - */ -export interface EnsureDeckPreviewResult { - success: boolean; - message: string; - md_file?: string; - created?: boolean; -} - -/** - * 解析 CSV 文件的 frontmatter - */ -function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { - const parts = content.trim().split(/(?:^|\n)---\s*\n/g); - - if (parts.length !== 3 || parts[0] !== '') { - return { csvContent: content }; - } - - try { - const frontmatterStr = parts[1].trim(); - const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; - const csvContent = parts.slice(2).join('---\n').trimStart(); - return { frontmatter, csvContent }; - } catch (error) { - console.warn('Failed to parse front matter:', error); - return { csvContent: content }; - } -} - -/** - * 构建 md-deck 组件代码 - */ -function buildDeckComponent(csvPath: string, config?: DeckConfig): string { - const parts = [`:md-deck[${csvPath}]`]; - const attrs: string[] = []; - - if (config?.size) { - attrs.push(`size="${config.size}"`); - } - - if (config?.grid) { - attrs.push(`grid="${config.grid}"`); - } - - if (config?.bleed !== undefined && config.bleed !== 1) { - attrs.push(`bleed="${config.bleed}"`); - } - - if (config?.padding !== undefined && config.padding !== 2) { - attrs.push(`padding="${config.padding}"`); - } - - if (config?.shape && config.shape !== 'rectangle') { - attrs.push(`shape="${config.shape}"`); - } - - if (config?.layers) { - attrs.push(`layers="${config.layers}"`); - } - - if (config?.back_layers) { - attrs.push(`back-layers="${config.back_layers}"`); - } - - if (attrs.length > 0) { - parts.push(`{${attrs.join(' ')}}`); - } - - return parts.join(''); -} - -/** - * 确保 Markdown 预览文件存在 - */ -export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult { - const { csv_file, md_file, title, description } = params; - - // 检查 CSV 文件是否存在 - if (!existsSync(csv_file)) { - return { - success: false, - message: `CSV 文件不存在:${csv_file}` - }; - } - - // 确定 Markdown 文件路径 - let mdFilePath = md_file; - if (!mdFilePath) { - // 使用与 CSV 相同的路径和文件名,只是扩展名不同 - const dir = dirname(csv_file); - const name = basename(csv_file, extname(csv_file)); - mdFilePath = join(dir, `${name}.md`); - } - - const created = !existsSync(mdFilePath); - - // 如果文件已存在,检查是否已有 md-deck 组件 - if (!created) { - try { - const existingContent = readFileSync(mdFilePath, 'utf-8'); - // 如果已有 md-deck 组件引用该 CSV,直接返回 - if (existingContent.includes(`:md-deck[${csv_file}]`) || - existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) { - return { - success: true, - message: `预览文件 ${mdFilePath} 已存在`, - md_file: mdFilePath, - created: false - }; - } - } catch (error) { - // 读取失败,继续创建 - } - } - - // 读取 CSV 的 frontmatter 获取配置 - let deckConfig: DeckConfig | undefined; - try { - const csvContent = readFileSync(csv_file, 'utf-8'); - const { frontmatter } = parseFrontMatter(csvContent); - deckConfig = frontmatter?.deck; - } catch (error) { - // 忽略错误,使用默认配置 - } - - // 确定标题 - let deckTitle = title; - if (!deckTitle) { - // 从 CSV 文件名推断 - const name = basename(csv_file, extname(csv_file)); - deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - } - - // 构建 md-deck 组件代码(使用相对路径) - const mdDir = dirname(mdFilePath); - const csvDir = dirname(csv_file); - let relativeCsvPath: string; - - if (mdDir === csvDir) { - relativeCsvPath = `./${basename(csv_file)}`; - } else { - relativeCsvPath = `./${basename(csv_file)}`; - } - - const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig); - - // 生成 Markdown 内容 - const mdLines: string[] = []; - - mdLines.push(`# ${deckTitle}`); - mdLines.push(''); - - if (description) { - mdLines.push(description); - mdLines.push(''); - } - - mdLines.push('## 卡牌预览'); - mdLines.push(''); - mdLines.push(deckComponent); - mdLines.push(''); - - mdLines.push('## 使用说明'); - mdLines.push(''); - mdLines.push('- 点击卡牌可以查看详情'); - mdLines.push('- 使用右上角的按钮可以随机抽取卡牌'); - mdLines.push('- 可以通过编辑面板调整卡牌样式和布局'); - mdLines.push(''); - - // 写入文件 - try { - writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8'); - - return { - success: true, - message: created - ? `创建预览文件 ${mdFilePath}` - : `更新预览文件 ${mdFilePath}`, - md_file: mdFilePath, - created - }; - } catch (error) { - return { - success: false, - message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` - }; - } -} diff --git a/src/cli/tools/frontmatter/read-frontmatter.ts b/src/cli/tools/frontmatter/read-frontmatter.ts index 509cebb..9ac98d2 100644 --- a/src/cli/tools/frontmatter/read-frontmatter.ts +++ b/src/cli/tools/frontmatter/read-frontmatter.ts @@ -1,5 +1,5 @@ -import { readFileSync, existsSync } from 'fs'; -import yaml from 'js-yaml'; +import { readFileSync, existsSync } from "fs"; +import { parseFrontMatter } from "./shared.js"; /** * 读取 CSV frontmatter 的参数 @@ -52,7 +52,7 @@ export interface CardFieldStyle { /** * 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东) */ - up?: 'n' | 'w' | 's' | 'e'; + up?: "n" | "w" | "s" | "e"; } /** @@ -74,7 +74,7 @@ export interface DeckConfig { grid?: string; bleed?: number; padding?: number; - shape?: 'rectangle' | 'circle' | 'hex' | 'diamond'; + shape?: "rectangle" | "circle" | "hex" | "diamond"; layers?: string; back_layers?: string; [key: string]: unknown; @@ -89,45 +89,25 @@ export interface ReadFrontmatterResult { message: string; } -/** - * 解析 CSV 文件的 frontmatter - */ -function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { - const parts = content.trim().split(/(?:^|\n)---\s*\n/g); - - // 至少需要三个部分:空字符串、front matter、CSV 内容 - if (parts.length !== 3 || parts[0] !== '') { - return { csvContent: content }; - } - - try { - const frontmatterStr = parts[1].trim(); - const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; - const csvContent = parts.slice(2).join('---\n').trimStart(); - return { frontmatter, csvContent }; - } catch (error) { - console.warn('Failed to parse front matter:', error); - return { csvContent: content }; - } -} - /** * 读取 CSV 文件的 frontmatter */ -export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult { +export function readFrontmatter( + params: ReadFrontmatterParams, +): ReadFrontmatterResult { const { csv_file } = params; // 检查文件是否存在 if (!existsSync(csv_file)) { return { success: false, - message: `文件不存在:${csv_file}` + message: `文件不存在:${csv_file}`, }; } try { // 读取文件内容 - const content = readFileSync(csv_file, 'utf-8'); + const content = readFileSync(csv_file, "utf-8"); // 解析 frontmatter const { frontmatter } = parseFrontMatter(content); @@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR return { success: true, frontmatter: {}, - message: `文件 ${csv_file} 没有 frontmatter,返回空对象` + message: `文件 ${csv_file} 没有 frontmatter,返回空对象`, }; } return { success: true, frontmatter, - message: `成功读取 ${csv_file} 的 frontmatter` + message: `成功读取 ${csv_file} 的 frontmatter`, }; } catch (error) { return { success: false, - message: `读取失败:${error instanceof Error ? error.message : '未知错误'}` + message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`, }; } } diff --git a/src/cli/tools/frontmatter/shared.ts b/src/cli/tools/frontmatter/shared.ts new file mode 100644 index 0000000..a57e786 --- /dev/null +++ b/src/cli/tools/frontmatter/shared.ts @@ -0,0 +1,48 @@ +/** + * Shared frontmatter utilities — used by read-frontmatter, write-frontmatter, + * card-crud, preview-deck, and generate-card-deck. + */ + +import yaml from "js-yaml"; +import type { DeckFrontmatter } from "./read-frontmatter.js"; + +/** + * Parse YAML frontmatter from a CSV file's raw content. + * Expects `---\n...\n---\n` delimited frontmatter at the top of the file. + */ +export function parseFrontMatter(content: string): { + frontmatter?: DeckFrontmatter; + csvContent: string; +} { + const parts = content.trim().split(/(?:^|\n)---\s*\n/g); + + if (parts.length !== 3 || parts[0] !== "") { + return { csvContent: content }; + } + + try { + const frontmatterStr = parts[1].trim(); + const frontmatter = yaml.load(frontmatterStr) as + | DeckFrontmatter + | undefined; + const csvContent = parts.slice(2).join("---\n").trimStart(); + return { frontmatter, csvContent }; + } catch (error) { + console.warn("Failed to parse front matter:", error); + return { csvContent: content }; + } +} + +/** + * Serialize a DeckFrontmatter object to YAML frontmatter string. + */ +export function serializeFrontMatter(frontmatter: DeckFrontmatter): string { + const yamlStr = yaml.dump(frontmatter, { + indent: 2, + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + return `---\n${yamlStr}---\n`; +} diff --git a/src/cli/tools/frontmatter/write-frontmatter.ts b/src/cli/tools/frontmatter/write-frontmatter.ts index 464cbd6..c61e88a 100644 --- a/src/cli/tools/frontmatter/write-frontmatter.ts +++ b/src/cli/tools/frontmatter/write-frontmatter.ts @@ -1,6 +1,6 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import yaml from 'js-yaml'; -import type { DeckFrontmatter } from './read-frontmatter.js'; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import type { DeckFrontmatter } from "./read-frontmatter.js"; +import { parseFrontMatter, serializeFrontMatter } from "./shared.js"; /** * 写入 CSV frontmatter 的参数 @@ -29,70 +29,37 @@ export interface WriteFrontmatterResult { csv_file?: string; } -/** - * 解析 CSV 文件的 frontmatter - */ -function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { - const parts = content.trim().split(/(?:^|\n)---\s*\n/g); - - // 至少需要三个部分:空字符串、front matter、CSV 内容 - if (parts.length !== 3 || parts[0] !== '') { - return { csvContent: content }; - } - - try { - const frontmatterStr = parts[1].trim(); - const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; - const csvContent = parts.slice(2).join('---\n').trimStart(); - return { frontmatter, csvContent }; - } catch (error) { - console.warn('Failed to parse front matter:', error); - return { csvContent: content }; - } -} - -/** - * 序列化 frontmatter 为 YAML 字符串 - */ -function serializeFrontMatter(frontmatter: DeckFrontmatter): string { - const yamlStr = yaml.dump(frontmatter, { - indent: 2, - lineWidth: -1, // 不限制行宽 - noRefs: true, // 不使用引用 - quotingType: '"', - forceQuotes: false - }); - return `---\n${yamlStr}---\n`; -} - /** * 写入/更新 CSV 文件的 frontmatter */ -export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult { +export function writeFrontmatter( + params: WriteFrontmatterParams, +): WriteFrontmatterResult { const { csv_file, frontmatter, merge = true } = params; - let csvContent = ''; + let csvContent = ""; let existingFrontmatter: DeckFrontmatter | undefined; // 如果文件存在且需要合并,先读取现有内容 if (merge && existsSync(csv_file)) { try { - const content = readFileSync(csv_file, 'utf-8'); + const content = readFileSync(csv_file, "utf-8"); const result = parseFrontMatter(content); existingFrontmatter = result.frontmatter; csvContent = result.csvContent; } catch (error) { return { success: false, - message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}` + message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`, }; } } // 合并或替换 frontmatter - const finalFrontmatter = merge && existingFrontmatter - ? { ...existingFrontmatter, ...frontmatter } - : frontmatter; + const finalFrontmatter = + merge && existingFrontmatter + ? { ...existingFrontmatter, ...frontmatter } + : frontmatter; // 序列化 frontmatter const frontmatterStr = serializeFrontMatter(finalFrontmatter); @@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt // 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容 if (!csvContent.trim()) { // 从 frontmatter 推断 CSV 表头 - const headers = ['label']; + const headers = ["label"]; if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) { for (const field of finalFrontmatter.fields) { - if (field.name && typeof field.name === 'string') { + if (field.name && typeof field.name === "string") { headers.push(field.name); } } } - headers.push('body'); - csvContent = headers.join(',') + '\n'; + headers.push("body"); + csvContent = headers.join(",") + "\n"; } // 写入文件 try { const fullContent = frontmatterStr + csvContent; - writeFileSync(csv_file, fullContent, 'utf-8'); + writeFileSync(csv_file, fullContent, "utf-8"); return { success: true, message: `成功写入 frontmatter 到 ${csv_file}`, - csv_file + csv_file, }; } catch (error) { return { success: false, - message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` + message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`, }; } } diff --git a/src/cli/tools/generate-card-deck.ts b/src/cli/tools/generate-card-deck.ts index d15fc31..9960d77 100644 --- a/src/cli/tools/generate-card-deck.ts +++ b/src/cli/tools/generate-card-deck.ts @@ -1,5 +1,7 @@ -import { writeFileSync, mkdirSync, existsSync } from 'fs'; -import { join } from 'path'; +import { writeFileSync, mkdirSync, existsSync } from "fs"; +import { join } from "path"; +import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js"; +import type { DeckConfig } from "./frontmatter/read-frontmatter.js"; /** * 字段样式配置 @@ -24,7 +26,7 @@ export interface CardFieldStyle { /** * 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东) */ - up?: 'n' | 'w' | 's' | 'e'; + up?: "n" | "w" | "s" | "e"; } /** @@ -45,18 +47,7 @@ export interface CardTemplate { examples?: Record[]; } -/** - * Deck 配置 - */ -export interface DeckConfig { - size?: string; - grid?: string; - bleed?: number; - padding?: number; - shape?: 'rectangle' | 'circle' | 'hex' | 'diamond'; - layers?: string; - backLayers?: string; -} +export type { DeckConfig }; /** * 生成卡牌组的参数 @@ -73,31 +64,31 @@ export interface GenerateCardDeckParams { /** * 生成卡牌数据 CSV */ -function generateCardCSV( - template: CardTemplate, - cardCount: number -): string { +function generateCardCSV(template: CardTemplate, cardCount: number): string { const fields = template.fields; - + // 构建 CSV 表头 - const headers = ['label', ...fields.map(f => f.name), 'body']; - + const headers = ["label", ...fields.map((f) => f.name), "body"]; + // 生成示例数据 const rows: string[][] = []; const examples = template.examples || []; - + for (let i = 0; i < cardCount; i++) { const row: string[] = [(i + 1).toString()]; - + // 为每个字段生成值 for (const field of fields) { - let value = ''; - + let value = ""; + if (examples.length > 0) { // 从示例中循环取值 const exampleIndex = i % examples.length; const example = examples[exampleIndex]; - value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || ''; + value = + example[field.name] || + field.examples?.[i % (field.examples?.length || 1)] || + ""; } else if (field.examples && field.examples.length > 0) { // 从字段的示例中取值 value = field.examples[i % field.examples.length]; @@ -105,131 +96,49 @@ function generateCardCSV( // 默认占位符 value = `{{${field.name}_${i + 1}}}`; } - + row.push(value); } - + // body 列使用模板语法 const bodyParts: string[] = []; for (const field of fields) { bodyParts.push(`**${field.name}:** {{${field.name}}}`); } - row.push(bodyParts.join('\n\n')); - + row.push(bodyParts.join("\n\n")); + rows.push(row); } - + // 组合 CSV 内容 - const csvLines = [headers.join(',')]; + const csvLines = [headers.join(",")]; for (const row of rows) { - csvLines.push(row.map(cell => { - // 处理包含逗号或换行的单元格 - if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) { - return `"${cell.replace(/"/g, '""')}"`; - } - return cell; - }).join(',')); + csvLines.push( + row + .map((cell) => { + // 处理包含逗号或换行的单元格 + if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) { + return `"${cell.replace(/"/g, '""')}"`; + } + return cell; + }) + .join(","), + ); } - - return csvLines.join('\n'); -} -/** - * 生成卡牌介绍的 Markdown 文件 - */ -function generateDeckMarkdown( - deckName: string, - csvFileName: string, - deckConfig: DeckConfig, - description?: string -): string { - const mdLines: string[] = []; - - // 标题 - mdLines.push(`# ${deckName}`); - mdLines.push(''); - - // 描述 - if (description) { - mdLines.push(description); - mdLines.push(''); - } - - // 卡牌预览组件 - mdLines.push('## 卡牌预览'); - mdLines.push(''); - - // 构建 :md-deck 组件代码 - const deckComponent = buildDeckComponent(csvFileName, deckConfig); - mdLines.push(deckComponent); - mdLines.push(''); - - // 使用说明 - mdLines.push('## 使用说明'); - mdLines.push(''); - mdLines.push('- 点击卡牌可以查看详情'); - mdLines.push('- 使用右上角的按钮可以随机抽取卡牌'); - mdLines.push('- 可以通过编辑面板调整卡牌样式和布局'); - mdLines.push(''); - - return mdLines.join('\n'); -} - -/** - * 构建 :md-deck 组件代码 - */ -function buildDeckComponent( - csvFileName: string, - config: DeckConfig -): string { - const parts = [`:md-deck[${csvFileName}]`]; - const attrs: string[] = []; - - if (config.size) { - attrs.push(`size="${config.size}"`); - } - - if (config.grid) { - attrs.push(`grid="${config.grid}"`); - } - - if (config.bleed !== undefined && config.bleed !== 1) { - attrs.push(`bleed="${config.bleed}"`); - } - - if (config.padding !== undefined && config.padding !== 2) { - attrs.push(`padding="${config.padding}"`); - } - - if (config.shape && config.shape !== 'rectangle') { - attrs.push(`shape="${config.shape}"`); - } - - if (config.layers) { - attrs.push(`layers="${config.layers}"`); - } - - if (config.backLayers) { - attrs.push(`back-layers="${config.backLayers}"`); - } - - if (attrs.length > 0) { - parts.push(`{${attrs.join(' ')}}`); - } - - return parts.join(''); + return csvLines.join("\n"); } /** * 自动生成图层配置 */ function autoGenerateLayers(fields: CardField[]): string { - if (fields.length === 0) return ''; - + if (fields.length === 0) return ""; + const layers: string[] = []; const totalHeight = 8; const heightPerField = Math.floor((totalHeight - 2) / fields.length); - + for (let i = 0; i < fields.length; i++) { const field = fields[i]; const y1 = 2 + i * heightPerField; @@ -237,8 +146,8 @@ function autoGenerateLayers(fields: CardField[]): string { const fontSize = Math.min(12, Math.floor(80 / fields.length)); layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`); } - - return layers.join(' '); + + return layers.join(" "); } /** @@ -256,61 +165,66 @@ export function generateCardDeck(params: GenerateCardDeckParams): { card_count = 10, card_template, deck_config = {}, - description + description, } = params; - + // 确保输出目录存在 if (!existsSync(output_dir)) { mkdirSync(output_dir, { recursive: true }); } - + // 生成文件名 - const safeName = deck_name.toLowerCase().replace(/\s+/g, '-'); + const safeName = deck_name.toLowerCase().replace(/\s+/g, "-"); const csvFileName = `${safeName}.csv`; const mdFileName = `${safeName}.md`; - + // 创建默认模板(如果没有提供) const template: CardTemplate = card_template || { fields: [ - { name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] }, - { name: 'type', description: '卡牌类型', examples: ['物品', '法术'] }, - { name: 'cost', description: '费用', examples: ['1', '2'] }, - { name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] } - ] + { + name: "name", + description: "卡牌名称", + examples: ["示例卡牌 1", "示例卡牌 2"], + }, + { name: "type", description: "卡牌类型", examples: ["物品", "法术"] }, + { name: "cost", description: "费用", examples: ["1", "2"] }, + { + name: "description", + description: "效果描述", + examples: ["这是一个效果描述", "这是另一个效果"], + }, + ], }; - + // 创建默认配置(如果没有提供) const config: DeckConfig = { - size: deck_config.size || '54x86', - grid: deck_config.grid || '5x8', + size: deck_config.size || "54x86", + grid: deck_config.grid || "5x8", bleed: deck_config.bleed ?? 1, padding: deck_config.padding ?? 2, - shape: deck_config.shape || 'rectangle', - layers: deck_config.layers || autoGenerateLayers(template.fields) + shape: deck_config.shape || "rectangle", + layers: deck_config.layers || autoGenerateLayers(template.fields), }; - + // 生成 CSV 内容 const csvContent = generateCardCSV(template, card_count); const csvPath = join(output_dir, csvFileName); - writeFileSync(csvPath, csvContent, 'utf-8'); - + writeFileSync(csvPath, csvContent, "utf-8"); + // 生成 Markdown 内容 - const mdContent = generateDeckMarkdown( + const deckComponent = buildDeckComponent(`./${csvFileName}`, config); + const mdContent = generateDeckPreviewMd( deck_name, - `./${csvFileName}`, - config, - description + deckComponent, + description, ); const mdPath = join(output_dir, mdFileName); - writeFileSync(mdPath, mdContent, 'utf-8'); - - // 构建完整的 deck 组件代码 - const deckComponent = buildDeckComponent(`./${csvFileName}`, config); - + writeFileSync(mdPath, mdContent, "utf-8"); + return { mdFile: mdPath, csvFile: csvPath, deckComponent, - message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}` + message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`, }; } diff --git a/src/cli/tools/preview-deck.ts b/src/cli/tools/preview-deck.ts index eafb3fa..ba5ca61 100644 --- a/src/cli/tools/preview-deck.ts +++ b/src/cli/tools/preview-deck.ts @@ -1,8 +1,13 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { dirname, join, basename, extname, relative } from 'path'; -import { execSync } from 'child_process'; -import yaml from 'js-yaml'; -import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { relative } from "path"; +import { execSync } from "child_process"; +import { + buildDeckComponent, + titleFromCsvPath, + defaultMdPath, + readDeckConfig, + generateDeckPreviewMd, +} from "./deck-utils.js"; /** * 预览 Deck 的参数 @@ -37,88 +42,35 @@ export interface PreviewDeckResult { preview_url?: string; } -/** - * 解析 CSV 文件的 frontmatter - */ -function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { - const parts = content.trim().split(/(?:^|\n)---\s*\n/g); - - if (parts.length !== 3 || parts[0] !== '') { - return { csvContent: content }; - } - - try { - const frontmatterStr = parts[1].trim(); - const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; - const csvContent = parts.slice(2).join('---\n').trimStart(); - return { frontmatter, csvContent }; - } catch (error) { - console.warn('Failed to parse front matter:', error); - return { csvContent: content }; - } -} - -/** - * 构建 md-deck 组件代码 - */ -function buildDeckComponent(csvPath: string, config?: DeckConfig): string { - const parts = [`:md-deck[${csvPath}]`]; - const attrs: string[] = []; - - if (config?.size) { - attrs.push(`size="${config.size}"`); - } - - if (config?.grid) { - attrs.push(`grid="${config.grid}"`); - } - - if (config?.bleed !== undefined && config.bleed !== 1) { - attrs.push(`bleed="${config.bleed}"`); - } - - if (config?.padding !== undefined && config.padding !== 2) { - attrs.push(`padding="${config.padding}"`); - } - - if (config?.shape && config.shape !== 'rectangle') { - attrs.push(`shape="${config.shape}"`); - } - - if (config?.layers) { - attrs.push(`layers="${config.layers}"`); - } - - if (config?.back_layers) { - attrs.push(`back-layers="${config.back_layers}"`); - } - - if (attrs.length > 0) { - parts.push(`{${attrs.join(' ')}}`); - } - - return parts.join(''); -} - /** * 打开浏览器 */ function openBrowser(url: string) { try { - // Windows 使用 start 命令 - if (process.platform === 'win32') { - execSync(`start "" "${url}"`, { stdio: 'ignore' }); - } else if (process.platform === 'darwin') { - execSync(`open "${url}"`, { stdio: 'ignore' }); + if (process.platform === "win32") { + execSync(`start "" "${url}"`, { stdio: "ignore" }); + } else if (process.platform === "darwin") { + execSync(`open "${url}"`, { stdio: "ignore" }); } else { - execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); + execSync(`xdg-open "${url}"`, { stdio: "ignore" }); } console.error(`已打开浏览器:${url}`); } catch (error) { - console.error('打开浏览器失败:', error); + console.error("打开浏览器失败:", error); } } +/** + * 计算预览 URL + */ +function computePreviewUrl(mdFilePath: string): string { + const relativeMdPath = relative(process.cwd(), mdFilePath) + .split("\\") + .join("/") + .replace(/\.md$/, ""); + return `http://localhost:3000/${relativeMdPath}`; +} + /** * 预览 Deck - 保存 Markdown 并打开浏览器 */ @@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult { if (!existsSync(csv_file)) { return { success: false, - message: `CSV 文件不存在:${csv_file}` + message: `CSV 文件不存在:${csv_file}`, }; } // 确定 Markdown 文件路径 - let mdFilePath = md_file; - if (!mdFilePath) { - // 使用与 CSV 相同的路径和文件名,只是扩展名不同 - const dir = dirname(csv_file); - const name = basename(csv_file, extname(csv_file)); - mdFilePath = join(dir, `${name}.md`); - } - + const mdFilePath = md_file || defaultMdPath(csv_file); const created = !existsSync(mdFilePath); // 如果文件已存在,检查是否已有 md-deck 组件 if (!created) { try { - const existingContent = readFileSync(mdFilePath, 'utf-8'); - // 如果已有 md-deck 组件引用该 CSV,直接返回 - if (existingContent.includes(`:md-deck[${csv_file}]`) || - existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) { - // 仍然打开浏览器 - const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); - const previewUrl = `http://localhost:3000/${relativeMdPath}`; + const existingContent = readFileSync(mdFilePath, "utf-8"); + if ( + existingContent.includes(`:md-deck[${csv_file}]`) || + existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`) + ) { + const previewUrl = computePreviewUrl(mdFilePath); openBrowser(previewUrl); return { success: true, message: `预览文件 ${mdFilePath} 已存在`, md_file: mdFilePath, created: false, - preview_url: previewUrl + preview_url: previewUrl, }; } - } catch (error) { + } catch { // 读取失败,继续创建 } } // 读取 CSV 的 frontmatter 获取配置 - let deckConfig: DeckConfig | undefined; - try { - const csvContent = readFileSync(csv_file, 'utf-8'); - const { frontmatter } = parseFrontMatter(csvContent); - deckConfig = frontmatter?.deck; - } catch (error) { - // 忽略错误,使用默认配置 - } + const deckConfig = readDeckConfig(csv_file); // 确定标题 - let deckTitle = title; - if (!deckTitle) { - // 从 CSV 文件名推断 - const name = basename(csv_file, extname(csv_file)); - deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - } + const deckTitle = title || titleFromCsvPath(csv_file); - // 构建 md-deck 组件代码(使用相对路径) - const mdDir = dirname(mdFilePath); - const csvDir = dirname(csv_file); - let relativeCsvPath: string; - - if (mdDir === csvDir) { - relativeCsvPath = `./${basename(csv_file)}`; - } else { - relativeCsvPath = `./${basename(csv_file)}`; - } - - const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig); + // 构建 md-deck 组件代码 + const deckComponent = buildDeckComponent( + `./${csv_file.split("/").pop()}`, + deckConfig, + ); // 生成 Markdown 内容 - const mdLines: string[] = []; - - mdLines.push(`# ${deckTitle}`); - mdLines.push(''); - - if (description) { - mdLines.push(description); - mdLines.push(''); - } - - mdLines.push('## 卡牌预览'); - mdLines.push(''); - mdLines.push(deckComponent); - mdLines.push(''); - - mdLines.push('## 使用说明'); - mdLines.push(''); - mdLines.push('- 点击卡牌可以查看详情'); - mdLines.push('- 使用右上角的按钮可以随机抽取卡牌'); - mdLines.push('- 可以通过编辑面板调整卡牌样式和布局'); - mdLines.push(''); + const mdContent = generateDeckPreviewMd( + deckTitle, + deckComponent, + description, + ); // 写入文件 try { - writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8'); + writeFileSync(mdFilePath, mdContent, "utf-8"); - // 计算预览 URL - const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); - const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`; - - // 打开浏览器 + const previewUrl = computePreviewUrl(mdFilePath); openBrowser(previewUrl); return { @@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult { : `更新预览文件 ${mdFilePath}`, md_file: mdFilePath, created, - preview_url: previewUrl + preview_url: previewUrl, }; } catch (error) { return { success: false, - message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` + message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`, }; } }