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.
This commit is contained in:
parent
06c3916a95
commit
a20063c624
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 : '未知错误'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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 : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,14 +64,11 @@ 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[][] = [];
|
||||
|
|
@ -91,13 +79,16 @@ function generateCardCSV(
|
|||
|
||||
// 为每个字段生成值
|
||||
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];
|
||||
|
|
@ -114,117 +105,35 @@ function generateCardCSV(
|
|||
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;
|
||||
|
|
@ -238,7 +147,7 @@ function autoGenerateLayers(fields: CardField[]): string {
|
|||
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
||||
}
|
||||
|
||||
return layers.join(' ');
|
||||
return layers.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -256,7 +165,7 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
|||
card_count = 10,
|
||||
card_template,
|
||||
deck_config = {},
|
||||
description
|
||||
description,
|
||||
} = params;
|
||||
|
||||
// 确保输出目录存在
|
||||
|
|
@ -265,52 +174,57 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
|||
}
|
||||
|
||||
// 生成文件名
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue