Files
baoyu-skills/skills/baoyu-format-markdown/scripts/main.ts
T
Jim Liu 宝玉 40363194be feat(baoyu-format-markdown): add markdown formatter skill
Formats plain text or markdown with frontmatter, titles, summaries,
headings, bold, lists, code blocks, and CJK typography fixes.
2026-01-28 21:37:23 -06:00

222 lines
6.2 KiB
TypeScript

import { readFileSync, writeFileSync } from "fs";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import remarkStringify from "remark-stringify";
import { visit } from "unist-util-visit";
import YAML from "yaml";
import {
fixCjkEmphasisSpacing,
CJK_CLOSING_PUNCT_RE,
CJK_OPENING_PUNCT_RE,
CJK_CHAR_RE,
} from "./cjk-emphasis";
import { replaceQuotes } from "./quotes";
import { applyAutocorrect } from "./autocorrect";
export interface FormatOptions {
quotes?: boolean;
spacing?: boolean;
emphasis?: boolean;
}
export interface FormatResult {
success: boolean;
filePath: string;
quotesFixed: boolean;
spacingApplied: boolean;
emphasisFixed: boolean;
error?: string;
}
const DEFAULT_OPTIONS: Required<FormatOptions> = {
quotes: false,
spacing: true,
emphasis: true,
};
function formatFrontmatter(value: string): string | null {
try {
const doc = YAML.parseDocument(value);
return doc.toString({ lineWidth: 0 }).trimEnd();
} catch {
return null;
}
}
function formatMarkdownContent(
content: string,
options: Required<FormatOptions>
): string {
if (options.emphasis) {
content = fixCjkEmphasisSpacing(content);
}
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFrontmatter, ["yaml"])
.use(remarkStringify, {
wrap: false,
});
const tree = processor.parse(content);
visit(tree, (node, _index, parent) => {
if (node.type === "text" && options.quotes) {
const textNode = node as { value: string };
textNode.value = replaceQuotes(textNode.value);
return;
}
if (node.type === "yaml") {
const yamlNode = node as { value: string };
const formatted = formatFrontmatter(yamlNode.value);
if (formatted !== null) {
yamlNode.value = formatted;
}
return;
}
if (
options.emphasis &&
(node.type === "strong" ||
node.type === "emphasis" ||
node.type === "delete") &&
parent
) {
const siblings = (parent as { children: typeof node[] }).children;
const idx = siblings.indexOf(node);
const children = (node as { children: typeof node[] }).children;
if (!children || children.length === 0) return;
const lastChild = children[children.length - 1];
if (lastChild.type === "text") {
const lastText = (lastChild as { value: string }).value;
if (
CJK_CLOSING_PUNCT_RE.test(lastText.slice(-1)) &&
idx + 1 < siblings.length
) {
const nextSib = siblings[idx + 1];
if (nextSib.type === "text") {
const nextText = (nextSib as { value: string }).value;
if (CJK_CHAR_RE.test(nextText.charAt(0))) {
(nextSib as { value: string }).value = " " + nextText;
}
}
}
}
const firstChild = children[0];
if (firstChild.type === "text") {
const firstText = (firstChild as { value: string }).value;
if (CJK_OPENING_PUNCT_RE.test(firstText) && idx > 0) {
const prevSib = siblings[idx - 1];
if (prevSib.type === "text") {
const prevText = (prevSib as { value: string }).value;
if (CJK_CHAR_RE.test(prevText.charAt(prevText.length - 1))) {
(prevSib as { value: string }).value = prevText + " ";
}
}
}
}
}
});
let result = processor.stringify(tree);
if (options.emphasis) {
result = fixCjkEmphasisSpacing(result);
}
return result;
}
export function formatMarkdown(
filePath: string,
options?: FormatOptions
): FormatResult {
const opts: Required<FormatOptions> = { ...DEFAULT_OPTIONS, ...options };
const result: FormatResult = {
success: false,
filePath,
quotesFixed: false,
spacingApplied: false,
emphasisFixed: false,
};
try {
const content = readFileSync(filePath, "utf-8");
const formattedContent = formatMarkdownContent(content, opts);
result.quotesFixed = opts.quotes;
result.emphasisFixed = opts.emphasis;
writeFileSync(filePath, formattedContent, "utf-8");
if (opts.spacing) {
result.spacingApplied = applyAutocorrect(filePath);
}
result.success = true;
console.log(`✓ Formatted: ${filePath}`);
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
console.error(`✗ Format failed: ${result.error}`);
}
return result;
}
function parseArgs(args: string[]): { filePath: string; options: FormatOptions } {
const options: FormatOptions = {};
let filePath = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--quotes" || arg === "-q") {
options.quotes = true;
} else if (arg === "--no-quotes") {
options.quotes = false;
} else if (arg === "--spacing" || arg === "-s") {
options.spacing = true;
} else if (arg === "--no-spacing") {
options.spacing = false;
} else if (arg === "--emphasis" || arg === "-e") {
options.emphasis = true;
} else if (arg === "--no-emphasis") {
options.emphasis = false;
} else if (arg === "--help" || arg === "-h") {
console.log(`Usage: npx -y bun scripts/main.ts <file.md> [options]
Options:
-q, --quotes Replace ASCII quotes with fullwidth quotes (default: false)
--no-quotes Do not replace quotes
-s, --spacing Add CJK/English spacing via autocorrect (default: true)
--no-spacing Do not add CJK/English spacing
-e, --emphasis Fix CJK emphasis punctuation issues (default: true)
--no-emphasis Do not fix CJK emphasis issues
-h, --help Show this help message`);
process.exit(0);
} else if (!arg.startsWith("-")) {
filePath = arg;
}
}
return { filePath, options };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { filePath, options } = parseArgs(process.argv.slice(2));
if (!filePath) {
console.error("Usage: npx -y bun scripts/main.ts <file.md> [options]");
console.error("Use --help for more information.");
process.exit(1);
}
const result = formatMarkdown(filePath, options);
if (!result.success) {
process.exit(1);
}
}