Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 读取文件。
Expand Down
2 changes: 1 addition & 1 deletion browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
<main><h1>检查 Skill 文档</h1><p>粘贴 SKILL.md,读取 YAML 文档头和正文。内容只在当前标签页处理。</p>
<div class="columns"><section><label for="source">SKILL.md</label><textarea id="source" maxlength="24576" spellcheck="false" aria-describedby="limit"></textarea><small id="limit">最多 24 KiB。网页检查文档格式,本机 CLI 可继续检查来源、权限和交付物。</small>
<div class="actions"><button id="check">检查文档</button><button id="example" class="secondary">载入示例</button></div></section>
<section aria-live="polite"><h2>解析结果</h2><strong id="status">等待检查</strong><p id="message">运行检查后,这里会列出文档头字段和正文长度。</p><dl id="fields"></dl></section></div></main>
<section aria-live="polite"><h2>解析结果</h2><strong id="status">等待检查</strong><p id="message">运行检查后,这里会列出文档头字段和正文长度。</p><button id="locate" type="button" class="secondary" hidden>定位错误</button><dl id="fields"></dl></section></div></main>
<script type="module" src="./main.ts"></script></body></html>
26 changes: 25 additions & 1 deletion browser/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import { parseSkillDocument } from "../src/domain/frontmatter";
const input = document.querySelector<HTMLTextAreaElement>("#source")!;
const status = document.querySelector<HTMLElement>("#status")!;
const message = document.querySelector<HTMLElement>("#message")!;
const locate = document.querySelector<HTMLButtonElement>("#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<HTMLElement>("#fields")!;
const example = "---\nname: inventory-check\ndescription: 对照清单核对库存。\n---\n\n先读取清单,再逐项核对应有与实收数量。";
const byteLimit = 24 * 1024;
Expand All @@ -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。请缩短文档后重试。";
Expand All @@ -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;
Expand Down
13 changes: 10 additions & 3 deletions src/domain/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parse } from "yaml";
import { parse, YAMLParseError } from "yaml";

export type ParsedSkillDocument = {
frontmatter: Record<string, unknown>;
Expand All @@ -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);
Expand All @@ -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.",
};
}
Expand All @@ -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.",
};
}
Expand All @@ -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)}`,
};
Expand All @@ -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 },
};
}

Expand Down
7 changes: 7 additions & 0 deletions tests/domain/frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading