fix: require full frontmatter delimiter lines (#101795)

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
This commit is contained in:
NianJiu
2026-07-08 02:53:03 +08:00
committed by GitHub
parent 6235344006
commit dbbab1044e
11 changed files with 208 additions and 109 deletions

View File

@@ -1,7 +1,7 @@
// Markdown Core tests cover frontmatter behavior.
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { parseFrontmatterBlock } from "./frontmatter.js";
import { parseFrontmatterBlock, stripFrontmatterBlock } from "./frontmatter.js";
describe("parseFrontmatterBlock", () => {
it("parses YAML block scalars", () => {
@@ -103,6 +103,30 @@ metadata:
expect(parseFrontmatterBlock(content)).toStrictEqual({});
});
it("ignores non-delimiter opening prefixes", () => {
for (const prefix of ["---not", "----", "--- name"]) {
const content = `${prefix}
name: nope
---
Body text`;
expect(parseFrontmatterBlock(content)).toStrictEqual({});
}
});
it("ignores non-delimiter closing prefixes", () => {
const content = `---
name: nope
---not
Body text`;
expect(parseFrontmatterBlock(content)).toStrictEqual({});
});
it("accepts delimiter lines with trailing whitespace", () => {
const content = ["--- ", "name: sample", "---\t", "Body text"].join("\n");
expect(parseFrontmatterBlock(content)).toStrictEqual({ name: "sample" });
expect(stripFrontmatterBlock(content)).toBe("Body text");
});
it("preserves prototype-named keys when YAML value is null", () => {
const content = `---
title: Hello
@@ -133,3 +157,21 @@ Body text`;
expect(result.description).toBe("Written by PowerShell");
});
});
describe("stripFrontmatterBlock", () => {
it("removes a valid frontmatter block", () => {
const content = `---
name: sample
---
Body text`;
expect(stripFrontmatterBlock(content)).toBe("Body text");
});
it("preserves Markdown that starts with a non-delimiter prefix", () => {
const content = `---not
name: nope
---not
Body text`;
expect(stripFrontmatterBlock(content)).toBe(content);
});
});

View File

@@ -181,24 +181,72 @@ function shouldPreferInlineLineValue(params: {
return lineEntry.value.includes(":");
}
function extractFrontmatterBlock(content: string): string | undefined {
const normalized = content
export type ExtractedFrontmatterBlock = {
block: string;
body: string;
};
function normalizeFrontmatterContent(content: string): string {
return content
.replace(/^\uFEFF/, "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
if (!normalized.startsWith("---")) {
}
function isFrontmatterDelimiterLine(line: string): boolean {
return line.trimEnd() === "---";
}
function extractFrontmatterBlockFromNormalized(
normalized: string,
): ExtractedFrontmatterBlock | undefined {
const firstLineEnd = normalized.indexOf("\n");
const firstLine = firstLineEnd === -1 ? normalized : normalized.slice(0, firstLineEnd);
if (!isFrontmatterDelimiterLine(firstLine)) {
return undefined;
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
if (firstLineEnd === -1) {
return undefined;
}
return normalized.slice(4, endIndex);
const blockStart = firstLineEnd + 1;
let lineStart = blockStart;
while (lineStart <= normalized.length) {
const lineEnd = normalized.indexOf("\n", lineStart);
const currentLineEnd = lineEnd === -1 ? normalized.length : lineEnd;
const line = normalized.slice(lineStart, currentLineEnd);
if (isFrontmatterDelimiterLine(line)) {
const blockEnd = lineStart > blockStart ? lineStart - 1 : lineStart;
const bodyStart = lineEnd === -1 ? normalized.length : lineEnd + 1;
return {
block: normalized.slice(blockStart, blockEnd),
body: normalized.slice(bodyStart),
};
}
if (lineEnd === -1) {
break;
}
lineStart = lineEnd + 1;
}
return undefined;
}
/** Splits a complete leading YAML frontmatter block from its Markdown body. */
export function extractFrontmatterBlock(content: string): ExtractedFrontmatterBlock | undefined {
const normalized = normalizeFrontmatterContent(content);
return extractFrontmatterBlockFromNormalized(normalized);
}
/** Removes a leading YAML frontmatter block and returns the remaining Markdown body. */
export function stripFrontmatterBlock(content: string): string {
const normalized = normalizeFrontmatterContent(content);
return (extractFrontmatterBlockFromNormalized(normalized)?.body ?? normalized).trim();
}
/** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
const block = extractFrontmatterBlock(content);
const block = extractFrontmatterBlock(content)?.block;
if (!block) {
return {};
}

View File

@@ -24,4 +24,26 @@ describe("loadPromptTemplates", () => {
expect(templates).toHaveLength(1);
expect(templates[0]?.description).toBe(`${"a".repeat(59)}...`);
});
it("preserves dash-prefixed Markdown as prompt content", async () => {
const root = tempDirs.make("openclaw-prompt-templates-");
const promptsDir = join(root, "prompts");
await mkdir(promptsDir, { recursive: true });
const content = "----\nname: bogus\ndescription: must remain Markdown\n---\n# Body\n";
await writeFile(join(promptsDir, "dash-prefix.md"), content, "utf-8");
const templates = loadPromptTemplates({
cwd: root,
agentDir: join(root, "agent"),
promptPaths: [promptsDir],
includeDefaults: false,
});
expect(templates).toHaveLength(1);
expect(templates[0]).toMatchObject({
name: "dash-prefix",
description: "----",
content,
});
});
});

View File

@@ -5,6 +5,7 @@
* body while preserving normal content when no complete frontmatter fence exists.
*/
import { parse } from "yaml";
import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js";
/** Parsed frontmatter metadata plus the remaining document body. */
type ParsedFrontmatter<T extends Record<string, unknown>> = {
@@ -15,34 +16,17 @@ type ParsedFrontmatter<T extends Record<string, unknown>> = {
const normalizeNewlines = (value: string): string =>
value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
const normalized = normalizeNewlines(content);
if (!normalized.startsWith("---")) {
return { yamlString: null, body: normalized };
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return { yamlString: null, body: normalized };
}
return {
yamlString: normalized.slice(4, endIndex),
body: normalized.slice(endIndex + 4).trim(),
};
};
/** Parses optional YAML frontmatter from Markdown-like content. */
export const parseFrontmatter = <T extends Record<string, unknown> = Record<string, unknown>>(
content: string,
): ParsedFrontmatter<T> => {
const { yamlString, body } = extractFrontmatter(content);
if (!yamlString) {
return { frontmatter: {} as T, body };
const normalized = normalizeNewlines(content);
const extracted = extractFrontmatterBlock(normalized);
if (!extracted) {
return { frontmatter: {} as T, body: normalized };
}
const parsed = parse(yamlString);
return { frontmatter: (parsed ?? {}) as T, body };
const parsed = parse(extracted.block);
return { frontmatter: (parsed ?? {}) as T, body: extracted.body.trim() };
};
/** Removes YAML frontmatter from content when a complete frontmatter block exists. */

View File

@@ -8,6 +8,7 @@ import syncFs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { extractFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js";
import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js";
import { openRootFile } from "../infra/boundary-file-read.js";
import { pathExists } from "../infra/fs-safe.js";
@@ -133,17 +134,7 @@ async function readWorkspaceFileWithGuards(params: {
}
function stripFrontMatter(content: string): string {
if (!content.startsWith("---")) {
return content;
}
const endIndex = content.indexOf("\n---", 3);
if (endIndex === -1) {
return content;
}
const start = endIndex + "\n---".length;
let trimmed = content.slice(start);
trimmed = trimmed.replace(/^\s+/, "");
return trimmed;
return extractFrontmatterBlock(content)?.body.replace(/^\s+/, "") ?? content;
}
async function loadTemplate(name: string): Promise<string> {

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js";
import { resolveWorkspaceTemplateSearchDirs } from "../../agents/workspace-templates.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js";
@@ -29,14 +30,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise<string>
}
throw error;
}
if (!raw.startsWith("---")) {
return raw;
}
const endIndex = raw.indexOf("\n---", 3);
if (endIndex === -1) {
return raw;
}
return raw.slice(endIndex + "\n---".length).replace(/^\s+/, "");
return extractFrontmatterBlock(raw)?.body.replace(/^\s+/, "") ?? raw;
}
} catch {
return fallback;

View File

@@ -132,6 +132,10 @@ describe("loadEnabledClaudeBundleCommands", () => {
relativePath: "commands/disabled.md",
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
},
{
relativePath: "commands/not-frontmatter.md",
contents: ["---not", "name: nope", "---not", "Treat this as Markdown."],
},
],
});
@@ -145,6 +149,17 @@ describe("loadEnabledClaudeBundleCommands", () => {
});
expectEnabledClaudeBundleCommands(commands, [
{
pluginId: "compound-bundle",
rawName: "not-frontmatter",
description: "---not",
promptTemplate: "---not\nname: nope\n---not\nTreat this as Markdown.",
sourceFilePath: path.join(
resolveBundlePluginRoot(homeDir, "compound-bundle"),
"commands",
"not-frontmatter.md",
),
},
{
pluginId: "compound-bundle",
rawName: "office-hours",

View File

@@ -5,7 +5,10 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { parseFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js";
import {
parseFrontmatterBlock,
stripFrontmatterBlock,
} from "../../packages/markdown-core/src/frontmatter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readRootJsonObjectSync } from "../infra/json-files.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
@@ -43,18 +46,6 @@ function parseFrontmatterBool(value: string | undefined, fallback: boolean): boo
return fallback;
}
function stripFrontmatter(content: string): string {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) {
return normalized.trim();
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return normalized.trim();
}
return normalized.slice(endIndex + 4).trim();
}
function readClaudeBundleManifest(rootDir: string): Record<string, unknown> {
const result = readRootJsonObjectSync({
rootDir,
@@ -133,7 +124,7 @@ function loadBundleCommandsFromRoot(params: {
if (parseFrontmatterBool(frontmatter["disable-model-invocation"], false)) {
continue;
}
const promptTemplate = stripFrontmatter(raw);
const promptTemplate = stripFrontmatterBlock(raw);
if (!promptTemplate) {
continue;
}

View File

@@ -1,21 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { loadSkillsFromDir } from "./session.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("loadSkillsFromDir", () => {
const tempPaths: string[] = [];
afterEach(async () => {
await Promise.all(
tempPaths.splice(0).map((entry) => fs.rm(entry, { recursive: true, force: true })),
);
});
it("reports directory scan failures as diagnostics", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-scan-"));
tempPaths.push(tempDir);
const tempDir = tempDirs.make("openclaw-skill-scan-");
const regularFile = path.join(tempDir, "not-a-directory");
await fs.writeFile(regularFile, "not a skill directory");
@@ -26,4 +19,25 @@ describe("loadSkillsFromDir", () => {
expect.objectContaining({ type: "warning", path: regularFile }),
]);
});
it("does not load dash-prefixed Markdown as frontmatter", async () => {
const tempDir = tempDirs.make("openclaw-skill-scan-");
const skillDir = path.join(tempDir, "dash-prefix");
await fs.mkdir(skillDir);
const skillFile = path.join(skillDir, "SKILL.md");
await fs.writeFile(
skillFile,
"----\nname: bogus\ndescription: must remain Markdown\n---\n# Body\n",
"utf-8",
);
const result = loadSkillsFromDir({ dir: tempDir, source: "test" });
expect(result.skills).toEqual([]);
expect(result.diagnostics).toContainEqual({
type: "warning",
message: "description is required",
path: skillFile,
});
});
});

View File

@@ -0,0 +1,23 @@
// Workshop frontmatter tests cover proposal markdown extraction and stripping.
import { describe, expect, it } from "vitest";
import { renderProposalMarkdown, stripProposalFrontmatterForSkill } from "./frontmatter.js";
describe("workshop proposal frontmatter", () => {
it("preserves dash-prefixed Markdown when rendering proposals", () => {
const rendered = renderProposalMarkdown({
name: "dash-prefix",
description: "Preserve dash-prefixed Markdown",
content: "---not\nname: nope\n---not\n# Body\n",
date: "2026-07-07T00:00:00.000Z",
});
expect(rendered.startsWith('---\nname: "dash-prefix"')).toBe(true);
expect(rendered).toContain("\n---not\nname: nope\n---not\n# Body\n");
});
it("does not strip dash-prefixed Markdown as proposal frontmatter", () => {
expect(stripProposalFrontmatterForSkill("---not\nname: nope\n---not\n# Body\n")).toBe(
"---not\nname: nope\n---not\n# Body\n",
);
});
});

View File

@@ -1,4 +1,5 @@
// Workshop frontmatter helpers parse generated skill metadata before saving drafts.
import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js";
import { parseFrontmatter } from "../loading/frontmatter.js";
type ProposalFrontmatter = {
@@ -21,9 +22,9 @@ export function renderProposalMarkdown(params: {
date?: string;
}): string {
const originalFrontmatter =
extractFrontmatterBlock(params.content) ??
extractFrontmatterBlock(params.content)?.block ??
(params.fallbackFrontmatterContent
? extractFrontmatterBlock(params.fallbackFrontmatterContent)
? extractFrontmatterBlock(params.fallbackFrontmatterContent)?.block
: undefined);
const keptFrontmatter = originalFrontmatter
? filterFrontmatterBlock(originalFrontmatter, [
@@ -34,7 +35,8 @@ export function renderProposalMarkdown(params: {
"date",
])
: "";
const body = stripFrontmatterBlock(params.content).trimStart();
const extracted = extractFrontmatterBlock(params.content);
const body = (extracted?.body ?? normalizeNewlines(params.content)).trimStart();
const version = params.version ?? "v1";
const date = params.date ?? new Date().toISOString();
const frontmatter = [
@@ -64,18 +66,13 @@ export function readProposalFrontmatter(content: string): ProposalFrontmatter |
export function stripProposalFrontmatterForSkill(content: string): string {
const normalized = normalizeNewlines(content);
if (!normalized.startsWith("---")) {
return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
const extracted = extractFrontmatterBlock(normalized);
if (!extracted) {
return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
}
const rawBlock = normalized.slice(4, endIndex);
const bodyStart = endIndex + "\n---".length;
const body = normalized.slice(bodyStart).replace(/^\n+/, "");
const keptLines = rawBlock
const body = extracted.body.replace(/^\n+/, "");
const keptLines = extracted.block
.split("\n")
.filter((line) => {
const key = line.match(/^([\w-]+):/)?.[1]?.toLowerCase();
@@ -88,28 +85,6 @@ export function stripProposalFrontmatterForSkill(content: string): string {
return result.endsWith("\n") ? result : `${result}\n`;
}
function extractFrontmatterBlock(content: string): string | undefined {
const normalized = normalizeNewlines(content);
if (!normalized.startsWith("---")) {
return undefined;
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return undefined;
}
return normalized.slice(4, endIndex);
}
function stripFrontmatterBlock(content: string): string {
const normalized = normalizeNewlines(content);
const block = extractFrontmatterBlock(normalized);
if (block === undefined) {
return normalized;
}
const endIndex = normalized.indexOf("\n---", 3);
return normalized.slice(endIndex + "\n---".length).replace(/^\n+/, "");
}
function filterFrontmatterBlock(block: string, keysToDrop: readonly string[]): string {
const drop = new Set(keysToDrop.map((key) => key.toLowerCase()));
const lines = block.split("\n");