refactor: combine frontmatter fields and block, add tests
This commit is contained in:
@@ -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, "\\$&");
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
extractFrontmatterBlock,
|
||||||
findFieldRange,
|
findFieldRange,
|
||||||
formatTagsYamlValue,
|
formatTagsYamlValue,
|
||||||
replaceFrontmatterField,
|
replaceFrontmatterField,
|
||||||
@@ -76,3 +77,55 @@ describe("setFrontmatterTags", () => {
|
|||||||
expect(result.content).not.toContain("tags: [old]");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,138 @@
|
|||||||
import { yamlInlineScalar } from "@/utils/yamlScalar";
|
import { yamlInlineScalar } from "@/utils/yamlScalar";
|
||||||
import {
|
|
||||||
extractFrontmatterBlock,
|
|
||||||
findFieldLineRange,
|
|
||||||
findFieldRange,
|
|
||||||
type TextRange,
|
|
||||||
} from "./frontmatterBlock";
|
|
||||||
|
|
||||||
export { findFieldLineRange, findFieldRange };
|
/** 0-based line/column range in the document. */
|
||||||
export type { TextRange };
|
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 =
|
export type ReplaceFrontmatterFieldResult =
|
||||||
| { ok: true; content: string; range: TextRange }
|
| { ok: true; content: string; range: TextRange }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as yaml from "js-yaml";
|
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). */
|
/** Read current tags array from task file content (empty if missing/invalid). */
|
||||||
export function parseTaskTags(content: string): string[] {
|
export function parseTaskTags(content: string): string[] {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
extractFrontmatterBlock,
|
extractFrontmatterBlock,
|
||||||
findFieldRange,
|
findFieldRange,
|
||||||
type TextRange,
|
type TextRange,
|
||||||
} from "./frontmatterBlock";
|
} from "./frontmatterFields";
|
||||||
|
|
||||||
export type FrontmatterIssueSeverity = "error" | "warning";
|
export type FrontmatterIssueSeverity = "error" | "warning";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user