mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: parse skill frontmatter as YAML (#25610)
This commit is contained in:
@@ -98,7 +98,7 @@ describe("parsePersonalSkillMarkdown", () => {
|
||||
it("parses SKILL.md frontmatter and body", () => {
|
||||
expect(
|
||||
parsePersonalSkillMarkdown(
|
||||
'---\nname: test-skill\ndescription: "Does a thing"\n---\n\nUse this skill.',
|
||||
'---\nname: "test-skill"\ndescription: "Does a thing"\n---\n\nUse this skill.',
|
||||
),
|
||||
).toEqual({
|
||||
name: "test-skill",
|
||||
@@ -107,14 +107,62 @@ describe("parsePersonalSkillMarkdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses backend-compatible parsing for YAML-comment-sensitive values", () => {
|
||||
it("parses folded YAML description values", () => {
|
||||
expect(
|
||||
parsePersonalSkillMarkdown(
|
||||
[
|
||||
"---",
|
||||
"name: brainstorming",
|
||||
"description: >",
|
||||
" Use before any creative work: features, components, functionality changes,",
|
||||
" or behavior modifications. Turns ideas into approved designs through",
|
||||
" collaborative dialog. Hard gate: no implementation action until the",
|
||||
" design is presented and approved.",
|
||||
"---",
|
||||
"Use this skill.",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual({
|
||||
name: "brainstorming",
|
||||
description: [
|
||||
"Use before any creative work: features, components, functionality changes,",
|
||||
"or behavior modifications. Turns ideas into approved designs through",
|
||||
"collaborative dialog. Hard gate: no implementation action until the",
|
||||
"design is presented and approved.",
|
||||
].join(" "),
|
||||
body: "Use this skill.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses YAML comment semantics in frontmatter", () => {
|
||||
expect(
|
||||
parsePersonalSkillMarkdown(
|
||||
"---\nname: test-skill\ndescription: Build # test\n---\nBody",
|
||||
),
|
||||
).toEqual({
|
||||
name: "test-skill",
|
||||
description: "Build # test",
|
||||
description: "Build",
|
||||
body: "Body",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string frontmatter fields", () => {
|
||||
expect(() =>
|
||||
parsePersonalSkillMarkdown("---\nname: null\n---\nBody"),
|
||||
).toThrow("Skill name must be a string.");
|
||||
expect(() =>
|
||||
parsePersonalSkillMarkdown(
|
||||
"---\nname: test-skill\ndescription: null\n---\nBody",
|
||||
),
|
||||
).toThrow("Skill description must be a string.");
|
||||
});
|
||||
|
||||
it("allows whitespace around frontmatter delimiters", () => {
|
||||
expect(
|
||||
parsePersonalSkillMarkdown(" --- \nname: test-skill\n --- \nBody"),
|
||||
).toEqual({
|
||||
name: "test-skill",
|
||||
description: "",
|
||||
body: "Body",
|
||||
});
|
||||
});
|
||||
@@ -207,6 +255,29 @@ describe("buildPersonalSkillMarkdown", () => {
|
||||
).toBe("---\nname: test-skill\n---\nUse this skill.\n");
|
||||
});
|
||||
|
||||
it("quotes skill names that YAML would otherwise coerce", () => {
|
||||
const content = buildPersonalSkillMarkdown({
|
||||
name: "true",
|
||||
description: "",
|
||||
body: "Use this skill.",
|
||||
});
|
||||
|
||||
expect(content).toContain('name: "true"');
|
||||
expect(parsePersonalSkillMarkdown(content)).toMatchObject({
|
||||
name: "true",
|
||||
});
|
||||
|
||||
const numericNameContent = buildPersonalSkillMarkdown({
|
||||
name: "123",
|
||||
description: "",
|
||||
body: "Use this skill.",
|
||||
});
|
||||
expect(numericNameContent).toContain('name: "123"');
|
||||
expect(parsePersonalSkillMarkdown(numericNameContent)).toMatchObject({
|
||||
name: "123",
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes quoted description values", () => {
|
||||
const content = buildPersonalSkillMarkdown({
|
||||
name: "test-skill",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import frontMatter from "front-matter";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
|
||||
export const PERSONAL_SKILL_MAX_SIZE_BYTES = 64 * 1024;
|
||||
@@ -87,34 +88,26 @@ export const filterPersonalSkills = (
|
||||
|
||||
class PersonalSkillMarkdownError extends Error {}
|
||||
|
||||
const unquoteFrontmatterScalar = (value: string): string => {
|
||||
if (value.length < 2) {
|
||||
return value;
|
||||
const frontmatterStringField = (
|
||||
attributes: Record<string, unknown>,
|
||||
key: "name" | "description",
|
||||
): string => {
|
||||
const value = attributes[key];
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if (first !== last) {
|
||||
return value;
|
||||
if (typeof value !== "string") {
|
||||
throw new PersonalSkillMarkdownError(`Skill ${key} must be a string.`);
|
||||
}
|
||||
|
||||
const inner = value.slice(1, -1);
|
||||
if (first === '"') {
|
||||
return inner.replaceAll('\\"', '"').replaceAll("\\\\", "\\");
|
||||
}
|
||||
if (first === "'") {
|
||||
return inner;
|
||||
}
|
||||
return value;
|
||||
return value.replace(/[\r\n]+$/, "");
|
||||
};
|
||||
|
||||
// This parser is only for projecting SKILL.md content into form fields.
|
||||
// The API reparses and validates saved content on submit, so this mirrors the
|
||||
// backend scalar subset instead of accepting full YAML semantics.
|
||||
// The API re-validates on submit; this only projects content into form fields.
|
||||
export const parsePersonalSkillMarkdown = (
|
||||
content: string,
|
||||
): PersonalSkillFormValues => {
|
||||
const lines = content.replace(/^\uFEFF/, "").split("\n");
|
||||
const normalizedContent = content.replace(/^\uFEFF/, "");
|
||||
const lines = normalizedContent.split("\n");
|
||||
if (lines[0]?.trim() !== "---") {
|
||||
throw new PersonalSkillMarkdownError(
|
||||
"Missing opening frontmatter delimiter.",
|
||||
@@ -130,28 +123,24 @@ export const parsePersonalSkillMarkdown = (
|
||||
);
|
||||
}
|
||||
|
||||
let name = "";
|
||||
let description = "";
|
||||
for (const line of lines.slice(1, closingIndex)) {
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex < 0) {
|
||||
continue;
|
||||
const parseableContent = [
|
||||
"---",
|
||||
...lines.slice(1, closingIndex),
|
||||
"---",
|
||||
...lines.slice(closingIndex + 1),
|
||||
].join("\n");
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return frontMatter<Record<string, unknown>>(parseableContent);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
throw new PersonalSkillMarkdownError(`Invalid frontmatter: ${message}`);
|
||||
}
|
||||
const key = line.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = unquoteFrontmatterScalar(
|
||||
line.slice(separatorIndex + 1).trim(),
|
||||
);
|
||||
if (key === "name") {
|
||||
name = value;
|
||||
} else if (key === "description") {
|
||||
description = value;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const body = lines
|
||||
.slice(closingIndex + 1)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const name = frontmatterStringField(parsed.attributes, "name");
|
||||
const description = frontmatterStringField(parsed.attributes, "description");
|
||||
const body = parsed.body.trim();
|
||||
|
||||
if (!name) {
|
||||
throw new PersonalSkillMarkdownError("Skill name is required.");
|
||||
@@ -185,6 +174,14 @@ const frontmatterLineValue = (value: string): string =>
|
||||
const frontmatterStringValue = (value: string): string =>
|
||||
`"${frontmatterLineValue(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
|
||||
const frontmatterNameValue = (value: string): string => {
|
||||
const lineValue = frontmatterLineValue(value);
|
||||
if (/^(?:true|false|null)$/.test(lineValue) || /^[0-9]/.test(lineValue)) {
|
||||
return frontmatterStringValue(lineValue);
|
||||
}
|
||||
return lineValue;
|
||||
};
|
||||
|
||||
export const isValidPersonalSkillDescription = (description: string): boolean =>
|
||||
getPersonalSkillContentSizeBytes(description) <=
|
||||
PERSONAL_SKILL_MAX_DESCRIPTION_BYTES;
|
||||
@@ -192,7 +189,7 @@ export const isValidPersonalSkillDescription = (description: string): boolean =>
|
||||
export const buildPersonalSkillMarkdown = (
|
||||
values: PersonalSkillFormValues,
|
||||
): string => {
|
||||
const name = frontmatterLineValue(values.name);
|
||||
const name = frontmatterNameValue(values.name);
|
||||
const description = frontmatterLineValue(values.description);
|
||||
const body = values.body.trim();
|
||||
const frontmatter = ["---", `name: ${name}`];
|
||||
|
||||
Reference in New Issue
Block a user