mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 20:54:02 +00:00
fix: require full frontmatter delimiter lines (#101795)
Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
23
src/skills/workshop/frontmatter.test.ts
Normal file
23
src/skills/workshop/frontmatter.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user