Skip to content

Commit 5863732

Browse files
committed
feat(docs-sync): derive title from first H1 and reject duplicate bindings
- Extract interface title from first Markdown H1 or Setext style - Fallback to filename stem when H1 is missing - Enforce unique directory bindings to prevent conflicts - Add --d2-sketch option for D2 hand-drawn diagrams - Bump version to 0.3.16
1 parent e0d12a6 commit 5863732

File tree

5 files changed

+180
-14
lines changed

5 files changed

+180
-14
lines changed

packages/yapi-mcp/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ yapi docs-sync
132132

133133
说明:
134134
- 绑定配置保存在 `.yapi/docs-sync.json`(自动维护 `files`:文件名 → API id)
135+
- **接口标题(title)默认取 Markdown 内第一个 H1(`# 标题` / Setext `===`**;如果没写 H1,则回退到文件名(不含扩展名)。
136+
- 接口路径(path)使用文件名(不含扩展名)生成:`/${stem}`。建议文件名用稳定的 slug(如日期/英文),标题用中文写在文档 H1。
135137
- 绑定模式同步后会写入 `.yapi/docs-sync.links.json`(本地文档 → YApi 文档 URL)
136138
- 绑定模式同步后会写入 `.yapi/docs-sync.projects.json`(项目元数据/环境缓存)
137139
- 绑定模式同步后会写入 `.yapi/docs-sync.deployments.json`(本地文档 → 已部署 URL)
@@ -147,6 +149,7 @@ yapi docs-sync
147149
- `pandoc` 需手动安装(用于完整 Markdown 渲染)
148150
- 如需跳过 Mermaid 渲染,使用 `--no-mermaid`
149151
- 如需回到经典风格,使用 `--mermaid-classic`
152+
- 如需 D2 手绘风格,使用 `--d2-sketch`
150153

151154
### 手动方式:使用 npx(无需安装)
152155

packages/yapi-mcp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@leeguoo/yapi-mcp",
3-
"version": "0.3.14",
3+
"version": "0.3.16",
44
"description": "YApi Auto MCP Server - Model Context Protocol server for YApi integration, enables AI tools like Cursor to interact with YApi API documentation",
55
"main": "dist/index.js",
66
"bin": {

packages/yapi-mcp/skill-template/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ yapi --path /api/interface/list_cat --query catid=123
9393
## Docs sync
9494
- Bind local docs to YApi category with `yapi docs-sync bind add --name <binding> --dir <path> --project-id <id> --catid <id>` (stored in `.yapi/docs-sync.json`).
9595
- Sync with `yapi docs-sync --binding <binding>` or run all bindings with `yapi docs-sync`.
96+
- Title defaults to the first Markdown H1 (`# Title` / Setext `===`); falls back to filename stem when missing.
97+
- Path uses the filename stem: `/${stem}`.
9698
- Default syncs only changed files; use `--force` to sync everything.
9799
- Mermaid rendering depends on `mmdc` (hand-drawn look by default; auto-installed if possible; failures do not block sync).
98100
- PlantUML rendering depends on `plantuml` (requires Java).

packages/yapi-mcp/src/docs/markdown.ts

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ export type MarkdownRenderOptions = {
1010
logDiagrams?: boolean;
1111
mermaidLook?: "classic" | "handDrawn";
1212
mermaidHandDrawnSeed?: number;
13+
d2Sketch?: boolean;
1314
logger?: (message: string) => void;
1415
onMermaidError?: (error: unknown) => void;
16+
onDiagramError?: (error: unknown) => void;
1517
};
1618

1719
let cachedPandocAvailable: boolean | null = null;
@@ -23,7 +25,9 @@ let cachedGraphvizAvailable: boolean | null = null;
2325
let cachedD2Available: boolean | null = null;
2426

2527
function stripAnsi(input: string): string {
26-
return input.replace(/\u001b\[[0-9;]*m/g, "");
28+
// Some TS parsers/lint setups dislike control characters in regex literals.
29+
const pattern = new RegExp("\\x1b\\[[0-9;]*m", "g");
30+
return input.replace(pattern, "");
2731
}
2832

2933
function formatRenderError(error: unknown): string {
@@ -303,14 +307,19 @@ function renderGraphvizToSvg(source: string): string {
303307
}
304308
}
305309

306-
function renderD2ToSvg(source: string): string {
310+
function renderD2ToSvg(source: string, options?: { sketch?: boolean }): string {
307311
ensureD2();
308312
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "yapi-docs-sync-"));
309313
const inputPath = path.join(tmpDir, "diagram.d2");
310314
const outputPath = path.join(tmpDir, "diagram.svg");
311315
try {
312316
fs.writeFileSync(inputPath, source, "utf8");
313-
execFileSync(resolveLocalBin("d2"), [inputPath, outputPath], { stdio: "pipe" });
317+
const args = [];
318+
if (options?.sketch) {
319+
args.push("--sketch");
320+
}
321+
args.push(inputPath, outputPath);
322+
execFileSync(resolveLocalBin("d2"), args, { stdio: "pipe" });
314323
const svg = fs.readFileSync(outputPath, "utf8");
315324
return stripSvgProlog(svg);
316325
} finally {
@@ -330,12 +339,17 @@ function buildCodeBlockPattern(languages: string[]): RegExp {
330339
function renderDiagramBlocks(
331340
markdown: string,
332341
renderer: DiagramRenderer,
333-
options: { shouldLog: boolean; logger: (message: string) => void },
342+
options: {
343+
shouldLog: boolean;
344+
logger: (message: string) => void;
345+
onError?: (error: unknown) => void;
346+
},
334347
): string {
335348
const pattern = buildCodeBlockPattern(renderer.languages);
336349
const available = renderer.isAvailable();
337350
let index = 0;
338351
let missingLogged = false;
352+
let missingReported = false;
339353
return markdown.replace(pattern, (match, _lang: string, content: string) => {
340354
index += 1;
341355
if (!match) return "";
@@ -347,6 +361,10 @@ function renderDiagramBlocks(
347361
options.logger(`${renderer.label} 未安装,相关图示将被跳过。`);
348362
missingLogged = true;
349363
}
364+
if (options.onError && !missingReported) {
365+
options.onError(new Error(`${renderer.label} renderer not available`));
366+
missingReported = true;
367+
}
350368
return "";
351369
}
352370
if (options.shouldLog) {
@@ -363,6 +381,9 @@ function renderDiagramBlocks(
363381
const message = formatRenderError(error);
364382
options.logger(`${renderer.label} 块 #${index} 渲染失败,已跳过。原因: ${message}`);
365383
}
384+
if (options.onError) {
385+
options.onError(error);
386+
}
366387
return "";
367388
}
368389
});
@@ -375,6 +396,10 @@ export function preprocessMarkdown(markdown: string, options: MarkdownRenderOpti
375396
const logger = options.logger || console.log;
376397
const mermaidConfig = resolveMermaidConfig(options);
377398

399+
if (!options.noMermaid && !isMmdcAvailable() && options.onMermaidError) {
400+
options.onMermaidError(new Error("mmdc not available"));
401+
}
402+
378403
if (!options.noMermaid && isMmdcAvailable()) {
379404
const pattern = /```mermaid\s*\r?\n([\s\S]*?)\r?\n```/g;
380405
let index = 0;
@@ -426,12 +451,16 @@ export function preprocessMarkdown(markdown: string, options: MarkdownRenderOpti
426451
label: "D2",
427452
languages: ["d2"],
428453
isAvailable: isD2Available,
429-
render: renderD2ToSvg,
454+
render: (source: string) => renderD2ToSvg(source, { sketch: options.d2Sketch }),
430455
},
431456
];
432457

433458
for (const renderer of renderers) {
434-
output = renderDiagramBlocks(output, renderer, { shouldLog: shouldLogDiagrams, logger });
459+
output = renderDiagramBlocks(output, renderer, {
460+
shouldLog: shouldLogDiagrams,
461+
logger,
462+
onError: options.onDiagramError,
463+
});
435464
}
436465

437466
return output;
@@ -464,3 +493,64 @@ export function renderMarkdownToHtml(
464493
): string {
465494
return markdownToHtml(preprocessMarkdown(markdown, options));
466495
}
496+
497+
export function extractFirstMarkdownH1Title(markdown: string): string {
498+
const raw = String(markdown || "");
499+
if (!raw.trim()) return "";
500+
501+
const lines = raw.split(/\r?\n/);
502+
503+
let index = 0;
504+
const firstNonEmpty = lines.findIndex((line) => Boolean(String(line || "").trim()));
505+
if (firstNonEmpty !== -1 && String(lines[firstNonEmpty]).trim() === "---") {
506+
index = firstNonEmpty + 1;
507+
while (index < lines.length) {
508+
const line = String(lines[index] || "").trim();
509+
if (line === "---" || line === "...") {
510+
index += 1;
511+
break;
512+
}
513+
index += 1;
514+
}
515+
}
516+
517+
let inFence = false;
518+
let fenceMarker = "";
519+
const fenceStartPattern = /^\s*(```+|~~~+)/;
520+
521+
for (; index < lines.length; index += 1) {
522+
const line = String(lines[index] || "");
523+
524+
const fenceMatch = line.match(fenceStartPattern);
525+
if (fenceMatch) {
526+
const marker = fenceMatch[1] || "";
527+
if (!inFence) {
528+
inFence = true;
529+
fenceMarker = marker;
530+
} else {
531+
const trimmed = line.trimStart();
532+
if (trimmed.startsWith(fenceMarker)) {
533+
inFence = false;
534+
fenceMarker = "";
535+
}
536+
}
537+
continue;
538+
}
539+
if (inFence) continue;
540+
541+
const atxMatch = line.match(/^\s*#\s+(.+?)\s*$/);
542+
if (atxMatch) {
543+
return String(atxMatch[1] || "").trim();
544+
}
545+
546+
const current = line.trim();
547+
if (current && index + 1 < lines.length) {
548+
const next = String(lines[index + 1] || "");
549+
if (/^\s*=+\s*$/.test(next)) {
550+
return current;
551+
}
552+
}
553+
}
554+
555+
return "";
556+
}

packages/yapi-mcp/src/yapi-cli.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isMmdcAvailable,
1212
isPandocAvailable,
1313
isPlantUmlAvailable,
14+
extractFirstMarkdownH1Title,
1415
renderMarkdownToHtml,
1516
} from "./docs/markdown";
1617
import { runInstallSkill } from "./skill/install";
@@ -57,6 +58,7 @@ type DocsSyncOptions = {
5758
noMermaid?: boolean;
5859
mermaidLook?: "classic" | "handDrawn";
5960
mermaidHandDrawnSeed?: number;
61+
d2Sketch?: boolean;
6062
force?: boolean;
6163
help?: boolean;
6264
};
@@ -599,6 +601,10 @@ function parseDocsSyncArgs(argv: string[]): DocsSyncOptions {
599601
if (!options.mermaidLook) options.mermaidLook = "handDrawn";
600602
continue;
601603
}
604+
if (arg === "--d2-sketch") {
605+
options.d2Sketch = true;
606+
continue;
607+
}
602608
if (arg === "--force") {
603609
options.force = true;
604610
continue;
@@ -721,6 +727,7 @@ function usage(): string {
721727
" --mermaid-hand-drawn force mermaid hand-drawn look (default)",
722728
" --mermaid-classic render mermaid with classic look",
723729
" --mermaid-hand-drawn-seed <n> hand-drawn seed (implies hand-drawn look)",
730+
" --d2-sketch render D2 diagrams in sketch style",
724731
" --force sync all files even if unchanged",
725732
"Docs-sync bind actions:",
726733
" list, get, add, update, remove",
@@ -752,6 +759,7 @@ function docsSyncUsage(): string {
752759
" --mermaid-hand-drawn force mermaid hand-drawn look (default)",
753760
" --mermaid-classic render mermaid with classic look",
754761
" --mermaid-hand-drawn-seed <n> hand-drawn seed (implies hand-drawn look)",
762+
" --d2-sketch render D2 diagrams in sketch style",
755763
" --force sync all files even if unchanged",
756764
" -h, --help show help",
757765
].join("\n");
@@ -1348,6 +1356,9 @@ function buildDocsSyncHash(markdown: string, options: DocsSyncOptions): string {
13481356
hash.update(`mermaid-seed:${options.mermaidHandDrawnSeed}\n`);
13491357
}
13501358
}
1359+
if (options.d2Sketch) {
1360+
hash.update("d2-sketch\n");
1361+
}
13511362
hash.update(markdown);
13521363
return hash.digest("hex");
13531364
}
@@ -1550,11 +1561,16 @@ async function addInterface(
15501561

15511562
async function updateInterface(
15521563
docId: number,
1564+
title: string | undefined,
15531565
markdown: string,
15541566
html: string,
15551567
request: YapiRequest,
15561568
): Promise<void> {
1557-
const resp = await request("/api/interface/up", "POST", {}, { id: docId, markdown, desc: html });
1569+
const payload: Record<string, unknown> = { id: docId, markdown, desc: html };
1570+
if (title) {
1571+
payload.title = title;
1572+
}
1573+
const resp = await request("/api/interface/up", "POST", {}, payload);
15581574
if (resp?.errcode !== 0) {
15591575
throw new Error(`interface up failed: ${resp?.errmsg || "unknown error"}`);
15601576
}
@@ -1601,16 +1617,19 @@ async function syncDocsDir(
16011617
const relName = path.basename(mdPath);
16021618
const apiPath = `/${stem}`;
16031619

1620+
const markdown = fs.readFileSync(mdPath, "utf8");
1621+
const desiredTitle = extractFirstMarkdownH1Title(markdown).trim() || stem;
1622+
16041623
let docId = mapping.files[relName];
16051624
if (!docId) {
1606-
docId = byPath[apiPath] || byTitle[stem];
1625+
docId = byPath[apiPath] || byTitle[desiredTitle] || byTitle[stem];
16071626
if (docId) mapping.files[relName] = docId;
16081627
}
16091628

16101629
if (!docId) {
16111630
created += 1;
16121631
if (!options.dryRun) {
1613-
docId = await addInterface(stem, apiPath, mapping, request);
1632+
docId = await addInterface(desiredTitle, apiPath, mapping, request);
16141633
mapping.files[relName] = docId;
16151634
}
16161635
}
@@ -1620,30 +1639,49 @@ async function syncDocsDir(
16201639
fileInfos[relName] = { docId: Number(docId), apiPath: resolvedPath };
16211640
}
16221641

1623-
const markdown = fs.readFileSync(mdPath, "utf8");
16241642
const contentHash = buildDocsSyncHash(markdown, options);
16251643
const previousHash = mapping.file_hashes[relName];
1626-
if (!options.force && docId && previousHash && previousHash === contentHash) {
1644+
1645+
const currentTitle = docId ? byId[String(docId)]?.title : "";
1646+
const titleToUpdate = !docId
1647+
? undefined
1648+
: !currentTitle || currentTitle !== desiredTitle
1649+
? desiredTitle
1650+
: undefined;
1651+
const shouldSyncTitle = Boolean(titleToUpdate);
1652+
1653+
if (
1654+
!options.force &&
1655+
docId &&
1656+
previousHash &&
1657+
previousHash === contentHash &&
1658+
!shouldSyncTitle
1659+
) {
16271660
skipped += 1;
16281661
continue;
16291662
}
16301663

16311664
const logPrefix = `[docs-sync:${relName}]`;
16321665
let mermaidFailed = false;
1666+
let diagramFailed = false;
16331667
const html = renderMarkdownToHtml(markdown, {
16341668
noMermaid: options.noMermaid,
16351669
logMermaid: true,
16361670
mermaidLook: options.mermaidLook,
16371671
mermaidHandDrawnSeed: options.mermaidHandDrawnSeed,
1672+
d2Sketch: options.d2Sketch,
16381673
logger: (message) => console.log(`${logPrefix} ${message}`),
16391674
onMermaidError: () => {
16401675
mermaidFailed = true;
16411676
},
1677+
onDiagramError: () => {
1678+
diagramFailed = true;
1679+
},
16421680
});
16431681
if (!options.dryRun && docId) {
1644-
await updateInterface(docId, markdown, html, request);
1682+
await updateInterface(docId, titleToUpdate, markdown, html, request);
16451683
}
1646-
if (docId && !mermaidFailed) {
1684+
if (docId && !mermaidFailed && !diagramFailed) {
16471685
mapping.file_hashes[relName] = contentHash;
16481686
}
16491687
updated += 1;
@@ -2102,6 +2140,39 @@ async function runDocsSync(rawArgs: string[]): Promise<number> {
21022140
if (useBindings) {
21032141
const rootDir = path.dirname(docsSyncHome!);
21042142
const configForBindings = docsSyncConfig!;
2143+
2144+
const dirToBindings = new Map<string, string[]>();
2145+
for (const name of bindingNames) {
2146+
const binding = configForBindings.bindings[name];
2147+
if (!binding) {
2148+
throw new Error(`binding not found: ${name}`);
2149+
}
2150+
const dirPath = resolveBindingDir(rootDir, binding.dir);
2151+
const existing = dirToBindings.get(dirPath) || [];
2152+
existing.push(name);
2153+
dirToBindings.set(dirPath, existing);
2154+
}
2155+
const duplicates = Array.from(dirToBindings.entries()).filter(
2156+
([, names]) => names.length > 1,
2157+
);
2158+
if (duplicates.length) {
2159+
const lines: string[] = [];
2160+
lines.push("invalid docs-sync bindings: multiple bindings share the same dir");
2161+
duplicates.forEach(([dirPath, names]) => {
2162+
lines.push(`- dir=${dirPath} bindings=${names.join(", ")}`);
2163+
});
2164+
lines.push("");
2165+
lines.push("Fix: split docs into separate directories (recommended).");
2166+
lines.push("Example:");
2167+
lines.push(
2168+
" yapi docs-sync bind update --name <bindingA> --dir docs/yapi-sync/<bindingA>",
2169+
);
2170+
lines.push(
2171+
" yapi docs-sync bind update --name <bindingB> --dir docs/yapi-sync/<bindingB>",
2172+
);
2173+
throw new Error(lines.join("\n"));
2174+
}
2175+
21052176
const bindingResults: Record<
21062177
string,
21072178
{ binding: DocsSyncBinding; files: Record<string, DocsSyncFileInfo> }

0 commit comments

Comments
 (0)