From 68c894dc1899aa80ea631f36cf47e2e4433a6326 Mon Sep 17 00:00:00 2001 From: chumanic Date: Wed, 11 Nov 2026 21:51:44 +0800 Subject: [PATCH] Jump from a YAML diagnostic to its location in the original document --- README.md | 2 ++ browser/index.html | 2 +- browser/main.ts | 26 +++++++++++++++++++++++++- src/domain/frontmatter.ts | 13 ++++++++++--- tests/domain/frontmatter.test.ts | 7 +++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c7936e7..2c8e418 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ node dist/cli/index.js verify --path fixtures/product/trust-loop/review --target 运行 `node dist/cli/index.js gui` 打开本机界面。修复前可查看计划,确认后再应用。默认未启用真实执行。 +网页检查会标出 YAML 语法错误所在的行和列。点击「定位错误」可回到原文修正,继续编辑会清除上一次的位置。 + ## 常见问题 **网页能检查本机目录吗?** 网页只解析粘贴的文档。完整的目录、权限和交付物检查由本机 CLI 读取文件。 diff --git a/browser/index.html b/browser/index.html index 055d801..59601e4 100644 --- a/browser/index.html +++ b/browser/index.html @@ -7,5 +7,5 @@

检查 Skill 文档

粘贴 SKILL.md,读取 YAML 文档头和正文。内容只在当前标签页处理。

最多 24 KiB。网页检查文档格式,本机 CLI 可继续检查来源、权限和交付物。
-

解析结果

等待检查

运行检查后,这里会列出文档头字段和正文长度。

+

解析结果

等待检查

运行检查后,这里会列出文档头字段和正文长度。

diff --git a/browser/main.ts b/browser/main.ts index e914ba8..d5ab2ff 100644 --- a/browser/main.ts +++ b/browser/main.ts @@ -2,6 +2,17 @@ import { parseSkillDocument } from "../src/domain/frontmatter"; const input = document.querySelector("#source")!; const status = document.querySelector("#status")!; const message = document.querySelector("#message")!; +const locate = document.querySelector("#locate")!; +let errorLocation: {start: number; end: number} | undefined; +function resetLocation() { + errorLocation = undefined; + locate.hidden = true; +} +locate.addEventListener("click", () => { + if (!errorLocation) return; + input.focus(); + input.setSelectionRange(errorLocation.start, errorLocation.end); +}); const fields = document.querySelector("#fields")!; const example = "---\nname: inventory-check\ndescription: 对照清单核对库存。\n---\n\n先读取清单,再逐项核对应有与实收数量。"; const byteLimit = 24 * 1024; @@ -10,6 +21,7 @@ function tooLarge(value: string) { return value.length > byteLimit || encoder.encode(value).length > byteLimit; } function oversized() { + resetLocation(); fields.replaceChildren(); status.textContent = "文档过大"; message.textContent = "最多可检查 24 KiB。请缩短文档后重试。"; @@ -27,18 +39,30 @@ input.addEventListener("beforeinput", event => { if (event.cancelable && event.inputType.startsWith("insert")) checkInsertion(event, event.data); }); function clear() { + resetLocation(); status.textContent = "等待检查"; message.textContent = "文档已更新,请重新检查。"; fields.replaceChildren(); } function check() { + resetLocation(); fields.replaceChildren(); if (tooLarge(input.value)) { oversized(); return; } const parsed = parseSkillDocument(input.value); if (!parsed.ok) { - status.textContent = "无法解析文档"; message.textContent = parsed.error; return; + status.textContent = "无法解析文档"; + errorLocation = parsed.location; + if (errorLocation) { + const lines = input.value.slice(0, errorLocation.start).split("\n"); + const column = Array.from(lines[lines.length-1] ?? "").length + 1; + message.textContent = "第 " + lines.length + " 行,第 " + column + " 列。" + parsed.error; + locate.hidden = false; + } else { + message.textContent = parsed.error; + } + return; } status.textContent = "文档可解析"; const {frontmatter, body} = parsed.value; diff --git a/src/domain/frontmatter.ts b/src/domain/frontmatter.ts index 8ec3d4a..ded11f3 100644 --- a/src/domain/frontmatter.ts +++ b/src/domain/frontmatter.ts @@ -1,4 +1,4 @@ -import { parse } from "yaml"; +import { parse, YAMLParseError } from "yaml"; export type ParsedSkillDocument = { frontmatter: Record; @@ -8,7 +8,7 @@ export type ParsedSkillDocument = { export type ParseSkillDocumentResult = | { ok: true; value: ParsedSkillDocument } - | { ok: false; error: string }; + | { ok: false; error: string; location?: { start: number; end: number } }; function parserError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); @@ -20,6 +20,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { if (!opening) { return { ok: false, + location: { start: 0, end: 0 }, error: "Skill document must start with a YAML frontmatter delimiter.", }; } @@ -29,6 +30,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { if (!closing) { return { ok: false, + location: { start: content.length, end: content.length }, error: "Skill frontmatter closing delimiter is missing.", }; } @@ -39,9 +41,13 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { let parsed: unknown; try { - parsed = parse(frontmatterText, { uniqueKeys: true }); + parsed = parse(frontmatterText, { uniqueKeys: true, prettyErrors: false }); } catch (error: unknown) { + const location = error instanceof YAMLParseError + ? { start: Math.min(yamlEnd, openingEnd + error.pos[0]), end: Math.min(yamlEnd, openingEnd + error.pos[1]) } + : undefined; return { + ...(location && { location }), ok: false, error: `Invalid YAML frontmatter: ${parserError(error)}`, }; @@ -55,6 +61,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { return { ok: false, error: "Skill frontmatter must be a YAML mapping.", + location: { start: openingEnd, end: yamlEnd }, }; } diff --git a/tests/domain/frontmatter.test.ts b/tests/domain/frontmatter.test.ts index 70e47b9..7fdfac8 100644 --- a/tests/domain/frontmatter.test.ts +++ b/tests/domain/frontmatter.test.ts @@ -23,6 +23,13 @@ describe("parseSkillDocument", () => { }); }); + it("locates a duplicate key in the original document including CRLF and Unicode", () => { + const text = "---\r\nname: 鱼🐟\r\nname: b\r\n---\r\nbody"; + const result = parseSkillDocument(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.location?.start).toBe(text.lastIndexOf("name:")); + }); + it("rejects a document without an opening or closing delimiter", () => { const missingOpening = parseSkillDocument("name: review\n---\nbody"); const missingClosing = parseSkillDocument("---\nname: review\nbody");