refactor: combine frontmatter fields and block, add tests

This commit is contained in:
2026-07-18 07:11:02 +05:00
parent e2e194efb5
commit 3b34171ea0
5 changed files with 188 additions and 158 deletions
-148
View File
@@ -1,148 +0,0 @@
/** 0-based line/column range in the document. */
export type TextRange = {
startLine: number;
startCol: number;
endLine: number;
endCol: number;
};
export type FrontmatterBlock = {
/** YAML body between fences (no --- lines). */
yaml: string;
/** Line index of opening `---`. */
openLine: number;
/** Line index of closing `---`. */
closeLine: number;
/** Inclusive range covering the whole frontmatter including fences. */
range: TextRange;
};
/**
* Extract YAML frontmatter fenced by `---` lines at the start of the file.
* Returns null if there is no opening fence on line 0.
*/
export function extractFrontmatterBlock(
content: string,
): FrontmatterBlock | null {
const lines = content.split(/\r?\n/);
if (lines.length === 0 || lines[0].trim() !== "---") {
return null;
}
let closeLine = -1;
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].trim() === "---") {
closeLine = i;
break;
}
}
if (closeLine < 0) {
return {
yaml: lines.slice(1).join("\n"),
openLine: 0,
closeLine: -1,
range: {
startLine: 0,
startCol: 0,
endLine: Math.max(0, lines.length - 1),
endCol: lines[lines.length - 1]?.length ?? 0,
},
};
}
const yamlLines = lines.slice(1, closeLine);
return {
yaml: yamlLines.join("\n"),
openLine: 0,
closeLine,
range: {
startLine: 0,
startCol: 0,
endLine: closeLine,
endCol: lines[closeLine].length,
},
};
}
/** Range of `key:` line value inside frontmatter, or full key line / whole FM. */
export function findFieldRange(
content: string,
key: string,
): TextRange | undefined {
const block = extractFrontmatterBlock(content);
if (!block || block.closeLine < 0) {
return block?.range;
}
const lines = content.split(/\r?\n/);
const keyPattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:\\s*(.*)$`);
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
const line = lines[i] ?? "";
const match = keyPattern.exec(line);
if (!match) continue;
const indent = match[1] ?? "";
const valueText = match[3] ?? "";
const colonIndex = line.indexOf(":", indent.length);
const afterColon = colonIndex + 1;
let valueCol = afterColon;
while (
valueCol < line.length &&
(line[valueCol] === " " || line[valueCol] === "\t")
) {
valueCol += 1;
}
if (valueText.length === 0) {
return {
startLine: i,
startCol: 0,
endLine: i,
endCol: line.length,
};
}
return {
startLine: i,
startCol: valueCol,
endLine: i,
endCol: line.length,
};
}
return undefined;
}
/** Full line range for a frontmatter key (key + value). */
export function findFieldLineRange(
content: string,
key: string,
): TextRange | undefined {
const block = extractFrontmatterBlock(content);
if (!block || block.closeLine < 0) {
return undefined;
}
const lines = content.split(/\r?\n/);
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`);
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
const line = lines[i] ?? "";
if (keyPattern.test(line)) {
return {
startLine: i,
startCol: 0,
endLine: i,
endCol: line.length,
};
}
}
return undefined;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+53
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
extractFrontmatterBlock,
findFieldRange,
formatTagsYamlValue,
replaceFrontmatterField,
@@ -76,3 +77,55 @@ describe("setFrontmatterTags", () => {
expect(result.content).not.toContain("tags: [old]");
});
});
describe("extractFrontmatterBlock", () => {
it("extracts yaml from a valid block", () => {
const block = extractFrontmatterBlock(
"---\ntitle: Hello\nstatus: todo\n---\nbody",
);
expect(block).not.toBeNull();
expect(block!.yaml).toBe("title: Hello\nstatus: todo");
expect(block!.closeLine).toBe(3);
});
it("returns null when content does not start with ---", () => {
expect(extractFrontmatterBlock("no frontmatter")).toBeNull();
expect(extractFrontmatterBlock("")).toBeNull();
});
it("handles unclosed frontmatter with trailing newline", () => {
const block = extractFrontmatterBlock("---\ntitle: x\n");
expect(block).not.toBeNull();
expect(block!.closeLine).toBe(-1);
expect(block!.yaml).toBe("title: x\n");
});
it("handles unclosed frontmatter without trailing newline", () => {
const block = extractFrontmatterBlock("---\ntitle: x");
expect(block).not.toBeNull();
expect(block!.closeLine).toBe(-1);
expect(block!.yaml).toBe("title: x");
});
it("handles CRLF line endings (yaml is normalized to LF)", () => {
const block = extractFrontmatterBlock(
"---\r\ntitle: Hello\r\nstatus: todo\r\n---\r\nbody",
);
expect(block).not.toBeNull();
expect(block!.yaml).toBe("title: Hello\nstatus: todo");
});
it("handles --- with trailing whitespace", () => {
const block = extractFrontmatterBlock(
"--- \ntitle: Hello\n--- \nbody",
);
expect(block).not.toBeNull();
expect(block!.yaml).toBe("title: Hello");
expect(block!.closeLine).toBe(2);
});
it("only matches --- on the first line", () => {
const block = extractFrontmatterBlock("text\n---\ntitle: x\n---\n");
expect(block).toBeNull();
});
});
+133 -8
View File
@@ -1,13 +1,138 @@
import { yamlInlineScalar } from "@/utils/yamlScalar";
import {
extractFrontmatterBlock,
findFieldLineRange,
findFieldRange,
type TextRange,
} from "./frontmatterBlock";
export { findFieldLineRange, findFieldRange };
export type { TextRange };
/** 0-based line/column range in the document. */
export type TextRange = {
startLine: number;
startCol: number;
endLine: number;
endCol: number;
};
type FrontmatterBlock = {
yaml: string;
openLine: number;
closeLine: number;
range: TextRange;
};
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function extractFrontmatterBlock(content: string): FrontmatterBlock | null {
const NOT_FOUND = -1;
const lines = content.split(/\r?\n/);
if (lines.length === 0 || lines[0].trim() !== "---") {
return null;
}
let closeLine = NOT_FOUND;
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].trim() === "---") {
closeLine = i;
break;
}
}
if (closeLine === NOT_FOUND) {
return {
yaml: lines.slice(1).join("\n"),
openLine: 0,
closeLine: NOT_FOUND,
range: {
startLine: 0,
startCol: 0,
endLine: Math.max(0, lines.length - 1),
endCol: lines[lines.length - 1]?.length ?? 0,
},
};
}
const yamlLines = lines.slice(1, closeLine);
return {
yaml: yamlLines.join("\n"),
openLine: 0,
closeLine,
range: {
startLine: 0,
startCol: 0,
endLine: closeLine,
endCol: lines[closeLine].length,
},
};
}
export function findFieldRange(content: string, key: string): TextRange | undefined {
const block = extractFrontmatterBlock(content);
if (!block || block.closeLine < 0) {
return block?.range;
}
const lines = content.split(/\r?\n/);
const keyPattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:\\s*(.*)$`);
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
const line = lines[i] ?? "";
const match = keyPattern.exec(line);
if (!match) continue;
const [, indent, , valueText = ""] = match;
const colonIndex = line.indexOf(":", indent.length);
const afterColon = colonIndex + 1;
let valueCol = afterColon;
while (
valueCol < line.length &&
(line[valueCol] === " " || line[valueCol] === "\t")
) {
valueCol += 1;
}
if (valueText.length === 0) {
return {
startLine: i,
startCol: 0,
endLine: i,
endCol: line.length,
};
}
return {
startLine: i,
startCol: valueCol,
endLine: i,
endCol: line.length,
};
}
return undefined;
}
function findFieldLineRange(
content: string,
key: string,
): TextRange | undefined {
const block = extractFrontmatterBlock(content);
if (!block || block.closeLine < 0) {
return undefined;
}
const lines = content.split(/\r?\n/);
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`);
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
const line = lines[i] ?? "";
if (keyPattern.test(line)) {
return {
startLine: i,
startCol: 0,
endLine: i,
endCol: line.length,
};
}
}
return undefined;
}
export type ReplaceFrontmatterFieldResult =
| { ok: true; content: string; range: TextRange }
+1 -1
View File
@@ -1,5 +1,5 @@
import * as yaml from "js-yaml";
import { extractFrontmatterBlock } from "./frontmatterBlock";
import { extractFrontmatterBlock } from "./frontmatterFields";
/** Read current tags array from task file content (empty if missing/invalid). */
export function parseTaskTags(content: string): string[] {
+1 -1
View File
@@ -6,7 +6,7 @@ import {
extractFrontmatterBlock,
findFieldRange,
type TextRange,
} from "./frontmatterBlock";
} from "./frontmatterFields";
export type FrontmatterIssueSeverity = "error" | "warning";