src / wikiStore.ts
src / wikiStore.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";
import matter from "gray-matter";
import { BACKUP_DIR_NAME } from "./pathGuard";
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
export interface WikiLayout {
base: string;
agentsPath: string;
rawDir: string;
wikiDir: string;
indexPath: string;
logPath: string;
ragDir: string;
ragIndexPath: string;
}
export const WIKI_SUBDIRS = ["sources", "entities", "concepts", "analyses"] as const;
export function getLayout(base: string): WikiLayout {
const wikiDir = path.join(base, "wiki");
const ragDir = path.join(wikiDir, ".kwikines");
return {
base,
agentsPath: path.join(base, "AGENTS.md"),
rawDir: path.join(base, "raw"),
wikiDir,
indexPath: path.join(wikiDir, "index.md"),
logPath: path.join(wikiDir, "log.md"),
ragDir,
ragIndexPath: path.join(ragDir, "index.json"),
};
}
const INDEX_SEED = `# Index
Catalogue de tout le wiki (orienté contenu). Une ligne par page : lien + résumé.
## Synthèse
## Entités
## Concepts
## Sources
## Analyses
`;
const LOG_SEED = `# Journal
Historique chronologique (append-only) des opérations sur le wiki.
Chaque entrée commence par \`## [AAAA-MM-JJ] <type> | <sujet>\`.
`;
/** Create the wiki/ folder, its sub-directories and seed index.md + log.md if missing. */
export async function ensureWikiScaffold(layout: WikiLayout): Promise<void> {
await fs.mkdir(layout.wikiDir, { recursive: true });
for (const sub of WIKI_SUBDIRS) {
await fs.mkdir(path.join(layout.wikiDir, sub), { recursive: true });
}
await fs.mkdir(layout.rawDir, { recursive: true });
await seedIfMissing(layout.indexPath, INDEX_SEED);
await seedIfMissing(layout.logPath, LOG_SEED);
}
async function seedIfMissing(file: string, content: string): Promise<void> {
try {
await fs.access(file);
} catch {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content, "utf-8");
}
}
// ---------------------------------------------------------------------------
// Slugs & frontmatter
// ---------------------------------------------------------------------------
/** kebab-case, accent-free, space-free slug suitable for a wiki filename. */
export function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "") // strip combining diacritics
.toLowerCase()
.replace(/\.[a-z0-9]+$/i, "") // drop a trailing file extension
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "untitled";
}
export interface ParsedPage {
data: Record<string, unknown>;
body: string;
}
export function parsePage(content: string): ParsedPage {
try {
const parsed = matter(content);
return { data: parsed.data ?? {}, body: parsed.content ?? "" };
} catch {
// LLM-generated frontmatter is sometimes invalid YAML. Never let one bad
// page crash indexing/lint/query: strip the leading --- block heuristically
// and keep the body, with empty metadata.
const m = content.match(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
return { data: {}, body: m ? m[1] : content };
}
}
export function stringifyPage(data: Record<string, unknown>, body: string): string {
// gray-matter.stringify adds the --- fences and trailing newline handling.
return matter.stringify(body.startsWith("\n") ? body : `\n${body}`, data);
}
// ---------------------------------------------------------------------------
// Reading / listing
// ---------------------------------------------------------------------------
export async function readText(abs: string): Promise<string> {
return fs.readFile(abs, "utf-8");
}
export async function fileExists(abs: string): Promise<boolean> {
try {
await fs.access(abs);
return true;
} catch {
return false;
}
}
/** Recursively list *.md files under `dir`, skipping dot-directories and the RAG/backup folders. */
export async function listMarkdownFiles(dir: string): Promise<string[]> {
const out: string[] = [];
async function walk(current: string): Promise<void> {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name.startsWith(".")) continue;
const full = path.join(current, e.name);
if (e.isDirectory()) {
await walk(full);
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
out.push(full);
}
}
}
await walk(dir);
return out.sort();
}
/** Wiki pages eligible for RAG indexing: all wiki/*.md except log.md (chronological noise). */
export async function listIndexablePages(layout: WikiLayout): Promise<string[]> {
const all = await listMarkdownFiles(layout.wikiDir);
return all.filter((p) => path.resolve(p) !== path.resolve(layout.logPath));
}
export interface RawSource {
name: string;
abs: string;
relPath: string; // relative to base
ext: string;
sizeBytes: number;
ingested: boolean; // a wiki/sources/<slug>.md exists for it
needsConversion: boolean; // pdf/docx etc. must be converted to md first
derivedFrom?: string; // for a .md produced by converting a sibling (e.g. "Brief_ML.docx")
}
const NATIVE_EXTS = new Set([".md", ".markdown", ".txt"]);
// .docx (mammoth) and .pdf (vision-model transcription) are converted locally by
// kwikines itself, so they are ingestible directly. The others (.odt/.doc) still
// need an external conversion step first.
const CONVERT_EXTS = new Set([".pdf", ".docx", ".odt", ".doc"]);
const SELF_CONVERT_EXTS = new Set([".docx", ".pdf"]);
export async function listRawSources(layout: WikiLayout): Promise<RawSource[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(layout.rawDir, { withFileTypes: true });
} catch {
return [];
}
// Map basename (without extension) → set of extensions present, so we can
// detect a converted .md sitting next to the .docx/.pdf it came from and flag
// it as derived instead of presenting it as an independent source.
const byStem = new Map<string, Set<string>>();
for (const e of entries) {
if (!e.isFile() || e.name.startsWith(".")) continue;
const ext = path.extname(e.name).toLowerCase();
if (!NATIVE_EXTS.has(ext) && !CONVERT_EXTS.has(ext)) continue;
const stem = e.name.slice(0, e.name.length - path.extname(e.name).length);
(byStem.get(stem) ?? byStem.set(stem, new Set()).get(stem)!).add(ext);
}
const out: RawSource[] = [];
for (const e of entries) {
if (!e.isFile() || e.name.startsWith(".")) continue;
const abs = path.join(layout.rawDir, e.name);
const ext = path.extname(e.name).toLowerCase();
if (!NATIVE_EXTS.has(ext) && !CONVERT_EXTS.has(ext)) continue;
let sizeBytes = 0;
try {
sizeBytes = (await fs.stat(abs)).size;
} catch {
/* ignore */
}
const slug = slugify(e.name);
const ingested = await fileExists(path.join(layout.wikiDir, "sources", `${slug}.md`));
// A .md whose stem also exists as a self-convertible source (.docx) is the
// conversion output of that source — mark it derived.
let derivedFrom: string | undefined;
if (ext === ".md") {
const stem = e.name.slice(0, e.name.length - ext.length);
for (const sibExt of byStem.get(stem) ?? []) {
if (SELF_CONVERT_EXTS.has(sibExt)) { derivedFrom = `${stem}${sibExt}`; break; }
}
}
out.push({
name: e.name,
abs,
relPath: path.relative(layout.base, abs),
ext,
sizeBytes,
ingested,
needsConversion: CONVERT_EXTS.has(ext) && !SELF_CONVERT_EXTS.has(ext),
...(derivedFrom ? { derivedFrom } : {}),
});
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
export function sourceSlugForFile(filename: string): string {
return slugify(filename);
}
// ---------------------------------------------------------------------------
// Writing pages (with backup)
// ---------------------------------------------------------------------------
export interface WriteResult {
abs: string;
bytes: number;
backupPath: string | null;
created: boolean;
}
async function backupIfExists(abs: string, base: string): Promise<string | null> {
let isFile = false;
try {
isFile = (await fs.stat(abs)).isFile();
} catch {
return null;
}
if (!isFile) throw new Error(`"${abs}" exists but is not a regular file.`);
const rel = path.relative(base, abs);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(base, BACKUP_DIR_NAME, `${rel}.${ts}.bak`);
await fs.mkdir(path.dirname(backupPath), { recursive: true });
await fs.copyFile(abs, backupPath);
return backupPath;
}
/** Write a wiki page at absolute `abs`, backing up any existing file first. */
export async function writePage(layout: WikiLayout, abs: string, content: string): Promise<WriteResult> {
const existed = await fileExists(abs);
const backupPath = await backupIfExists(abs, layout.base);
await fs.mkdir(path.dirname(abs), { recursive: true });
const normalized = content.endsWith("\n") ? content : `${content}\n`;
await fs.writeFile(abs, normalized, "utf-8");
const bytes = Buffer.byteLength(normalized, "utf-8");
return { abs, bytes, backupPath, created: !existed };
}
// ---------------------------------------------------------------------------
// index.md / log.md (human-facing, distinct from the audit log)
// ---------------------------------------------------------------------------
export async function readIndex(layout: WikiLayout): Promise<string> {
try {
return await fs.readFile(layout.indexPath, "utf-8");
} catch {
return "";
}
}
export async function writeIndex(layout: WikiLayout, content: string): Promise<void> {
await fs.mkdir(path.dirname(layout.indexPath), { recursive: true });
await fs.writeFile(layout.indexPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
}
/** Append a pre-formatted block to wiki/log.md, creating it if needed. */
export async function appendWikiLog(layout: WikiLayout, block: string): Promise<void> {
await seedIfMissing(layout.logPath, LOG_SEED);
const trimmed = block.trim();
await fs.appendFile(layout.logPath, `\n${trimmed}\n`, "utf-8");
}
/** Format a standard log entry block: `## [date] kind | subject` + bullet lines. */
export function formatLogEntry(kind: string, subject: string, bullets: string[]): string {
const date = new Date().toISOString().slice(0, 10);
const lines = [`## [${date}] ${kind} | ${subject}`];
for (const b of bullets) lines.push(`- ${b}`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// index.md catalogue maintenance
// ---------------------------------------------------------------------------
const CATEGORY_HEADINGS: Record<string, string> = {
sources: "## Sources",
entities: "## Entités",
concepts: "## Concepts",
analyses: "## Analyses",
};
/** Map a wiki-relative page path (e.g. "entities/x.md") to its index category. */
export function categoryFromPath(relPathWithinWiki: string): keyof typeof CATEGORY_HEADINGS | null {
const top = relPathWithinWiki.replace(/\\/g, "/").split("/")[0];
return top in CATEGORY_HEADINGS ? (top as keyof typeof CATEGORY_HEADINGS) : null;
}
export interface IndexEntry {
wikilink: string; // e.g. "entities/marie-curie"
category: keyof typeof CATEGORY_HEADINGS;
summary: string;
}
/**
* Ensure each entry has a `- [[wikilink]] — summary` line under its category
* heading in index.md. Existing lines for the same wikilink are replaced.
*/
export async function upsertIndexEntries(layout: WikiLayout, entries: IndexEntry[]): Promise<void> {
if (entries.length === 0) return;
let content = await readIndex(layout);
if (!content.trim()) content = INDEX_SEED;
const lines = content.split(/\r?\n/);
for (const entry of entries) {
const heading = CATEGORY_HEADINGS[entry.category];
const newLine = `- [[${entry.wikilink}]] — ${entry.summary}`.trim();
const linkRe = new RegExp(`\\[\\[${escapeRe(entry.wikilink)}\\]\\]`);
// Remove any existing line for this wikilink.
for (let i = lines.length - 1; i >= 0; i--) {
if (linkRe.test(lines[i]) && lines[i].trimStart().startsWith("-")) lines.splice(i, 1);
}
// Find the category heading; create it at the end if absent.
let h = lines.findIndex((l) => l.trim() === heading);
if (h === -1) {
lines.push("", heading);
h = lines.length - 1;
}
lines.splice(h + 1, 0, newLine);
}
await writeIndex(layout, lines.join("\n"));
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import * as fs from "node:fs/promises";
import * as path from "node:path";
import matter from "gray-matter";
import { BACKUP_DIR_NAME } from "./pathGuard";
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
export interface WikiLayout {
base: string;
agentsPath: string;
rawDir: string;
wikiDir: string;
indexPath: string;
logPath: string;
ragDir: string;
ragIndexPath: string;
}
export const WIKI_SUBDIRS = ["sources", "entities", "concepts", "analyses"] as const;
export function getLayout(base: string): WikiLayout {
const wikiDir = path.join(base, "wiki");
const ragDir = path.join(wikiDir, ".kwikines");
return {
base,
agentsPath: path.join(base, "AGENTS.md"),
rawDir: path.join(base, "raw"),
wikiDir,
indexPath: path.join(wikiDir, "index.md"),
logPath: path.join(wikiDir, "log.md"),
ragDir,
ragIndexPath: path.join(ragDir, "index.json"),
};
}
const INDEX_SEED = `# Index
Catalogue de tout le wiki (orienté contenu). Une ligne par page : lien + résumé.
## Synthèse
## Entités
## Concepts
## Sources
## Analyses
`;
const LOG_SEED = `# Journal
Historique chronologique (append-only) des opérations sur le wiki.
Chaque entrée commence par \`## [AAAA-MM-JJ] <type> | <sujet>\`.
`;
/** Create the wiki/ folder, its sub-directories and seed index.md + log.md if missing. */
export async function ensureWikiScaffold(layout: WikiLayout): Promise<void> {
await fs.mkdir(layout.wikiDir, { recursive: true });
for (const sub of WIKI_SUBDIRS) {
await fs.mkdir(path.join(layout.wikiDir, sub), { recursive: true });
}
await fs.mkdir(layout.rawDir, { recursive: true });
await seedIfMissing(layout.indexPath, INDEX_SEED);
await seedIfMissing(layout.logPath, LOG_SEED);
}
async function seedIfMissing(file: string, content: string): Promise<void> {
try {
await fs.access(file);
} catch {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content, "utf-8");
}
}
// ---------------------------------------------------------------------------
// Slugs & frontmatter
// ---------------------------------------------------------------------------
/** kebab-case, accent-free, space-free slug suitable for a wiki filename. */
export function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "") // strip combining diacritics
.toLowerCase()
.replace(/\.[a-z0-9]+$/i, "") // drop a trailing file extension
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "untitled";
}
export interface ParsedPage {
data: Record<string, unknown>;
body: string;
}
export function parsePage(content: string): ParsedPage {
try {
const parsed = matter(content);
return { data: parsed.data ?? {}, body: parsed.content ?? "" };
} catch {
// LLM-generated frontmatter is sometimes invalid YAML. Never let one bad
// page crash indexing/lint/query: strip the leading --- block heuristically
// and keep the body, with empty metadata.
const m = content.match(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
return { data: {}, body: m ? m[1] : content };
}
}
export function stringifyPage(data: Record<string, unknown>, body: string): string {
// gray-matter.stringify adds the --- fences and trailing newline handling.
return matter.stringify(body.startsWith("\n") ? body : `\n${body}`, data);
}
// ---------------------------------------------------------------------------
// Reading / listing
// ---------------------------------------------------------------------------
export async function readText(abs: string): Promise<string> {
return fs.readFile(abs, "utf-8");
}
export async function fileExists(abs: string): Promise<boolean> {
try {
await fs.access(abs);
return true;
} catch {
return false;
}
}
/** Recursively list *.md files under `dir`, skipping dot-directories and the RAG/backup folders. */
export async function listMarkdownFiles(dir: string): Promise<string[]> {
const out: string[] = [];
async function walk(current: string): Promise<void> {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name.startsWith(".")) continue;
const full = path.join(current, e.name);
if (e.isDirectory()) {
await walk(full);
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
out.push(full);
}
}
}
await walk(dir);
return out.sort();
}
/** Wiki pages eligible for RAG indexing: all wiki/*.md except log.md (chronological noise). */
export async function listIndexablePages(layout: WikiLayout): Promise<string[]> {
const all = await listMarkdownFiles(layout.wikiDir);
return all.filter((p) => path.resolve(p) !== path.resolve(layout.logPath));
}
export interface RawSource {
name: string;
abs: string;
relPath: string; // relative to base
ext: string;
sizeBytes: number;
ingested: boolean; // a wiki/sources/<slug>.md exists for it
needsConversion: boolean; // pdf/docx etc. must be converted to md first
derivedFrom?: string; // for a .md produced by converting a sibling (e.g. "Brief_ML.docx")
}
const NATIVE_EXTS = new Set([".md", ".markdown", ".txt"]);
// .docx (mammoth) and .pdf (vision-model transcription) are converted locally by
// kwikines itself, so they are ingestible directly. The others (.odt/.doc) still
// need an external conversion step first.
const CONVERT_EXTS = new Set([".pdf", ".docx", ".odt", ".doc"]);
const SELF_CONVERT_EXTS = new Set([".docx", ".pdf"]);
export async function listRawSources(layout: WikiLayout): Promise<RawSource[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(layout.rawDir, { withFileTypes: true });
} catch {
return [];
}
// Map basename (without extension) → set of extensions present, so we can
// detect a converted .md sitting next to the .docx/.pdf it came from and flag
// it as derived instead of presenting it as an independent source.
const byStem = new Map<string, Set<string>>();
for (const e of entries) {
if (!e.isFile() || e.name.startsWith(".")) continue;
const ext = path.extname(e.name).toLowerCase();
if (!NATIVE_EXTS.has(ext) && !CONVERT_EXTS.has(ext)) continue;
const stem = e.name.slice(0, e.name.length - path.extname(e.name).length);
(byStem.get(stem) ?? byStem.set(stem, new Set()).get(stem)!).add(ext);
}
const out: RawSource[] = [];
for (const e of entries) {
if (!e.isFile() || e.name.startsWith(".")) continue;
const abs = path.join(layout.rawDir, e.name);
const ext = path.extname(e.name).toLowerCase();
if (!NATIVE_EXTS.has(ext) && !CONVERT_EXTS.has(ext)) continue;
let sizeBytes = 0;
try {
sizeBytes = (await fs.stat(abs)).size;
} catch {
/* ignore */
}
const slug = slugify(e.name);
const ingested = await fileExists(path.join(layout.wikiDir, "sources", `${slug}.md`));
// A .md whose stem also exists as a self-convertible source (.docx) is the
// conversion output of that source — mark it derived.
let derivedFrom: string | undefined;
if (ext === ".md") {
const stem = e.name.slice(0, e.name.length - ext.length);
for (const sibExt of byStem.get(stem) ?? []) {
if (SELF_CONVERT_EXTS.has(sibExt)) { derivedFrom = `${stem}${sibExt}`; break; }
}
}
out.push({
name: e.name,
abs,
relPath: path.relative(layout.base, abs),
ext,
sizeBytes,
ingested,
needsConversion: CONVERT_EXTS.has(ext) && !SELF_CONVERT_EXTS.has(ext),
...(derivedFrom ? { derivedFrom } : {}),
});
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
export function sourceSlugForFile(filename: string): string {
return slugify(filename);
}
// ---------------------------------------------------------------------------
// Writing pages (with backup)
// ---------------------------------------------------------------------------
export interface WriteResult {
abs: string;
bytes: number;
backupPath: string | null;
created: boolean;
}
async function backupIfExists(abs: string, base: string): Promise<string | null> {
let isFile = false;
try {
isFile = (await fs.stat(abs)).isFile();
} catch {
return null;
}
if (!isFile) throw new Error(`"${abs}" exists but is not a regular file.`);
const rel = path.relative(base, abs);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(base, BACKUP_DIR_NAME, `${rel}.${ts}.bak`);
await fs.mkdir(path.dirname(backupPath), { recursive: true });
await fs.copyFile(abs, backupPath);
return backupPath;
}
/** Write a wiki page at absolute `abs`, backing up any existing file first. */
export async function writePage(layout: WikiLayout, abs: string, content: string): Promise<WriteResult> {
const existed = await fileExists(abs);
const backupPath = await backupIfExists(abs, layout.base);
await fs.mkdir(path.dirname(abs), { recursive: true });
const normalized = content.endsWith("\n") ? content : `${content}\n`;
await fs.writeFile(abs, normalized, "utf-8");
const bytes = Buffer.byteLength(normalized, "utf-8");
return { abs, bytes, backupPath, created: !existed };
}
// ---------------------------------------------------------------------------
// index.md / log.md (human-facing, distinct from the audit log)
// ---------------------------------------------------------------------------
export async function readIndex(layout: WikiLayout): Promise<string> {
try {
return await fs.readFile(layout.indexPath, "utf-8");
} catch {
return "";
}
}
export async function writeIndex(layout: WikiLayout, content: string): Promise<void> {
await fs.mkdir(path.dirname(layout.indexPath), { recursive: true });
await fs.writeFile(layout.indexPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
}
/** Append a pre-formatted block to wiki/log.md, creating it if needed. */
export async function appendWikiLog(layout: WikiLayout, block: string): Promise<void> {
await seedIfMissing(layout.logPath, LOG_SEED);
const trimmed = block.trim();
await fs.appendFile(layout.logPath, `\n${trimmed}\n`, "utf-8");
}
/** Format a standard log entry block: `## [date] kind | subject` + bullet lines. */
export function formatLogEntry(kind: string, subject: string, bullets: string[]): string {
const date = new Date().toISOString().slice(0, 10);
const lines = [`## [${date}] ${kind} | ${subject}`];
for (const b of bullets) lines.push(`- ${b}`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// index.md catalogue maintenance
// ---------------------------------------------------------------------------
const CATEGORY_HEADINGS: Record<string, string> = {
sources: "## Sources",
entities: "## Entités",
concepts: "## Concepts",
analyses: "## Analyses",
};
/** Map a wiki-relative page path (e.g. "entities/x.md") to its index category. */
export function categoryFromPath(relPathWithinWiki: string): keyof typeof CATEGORY_HEADINGS | null {
const top = relPathWithinWiki.replace(/\\/g, "/").split("/")[0];
return top in CATEGORY_HEADINGS ? (top as keyof typeof CATEGORY_HEADINGS) : null;
}
export interface IndexEntry {
wikilink: string; // e.g. "entities/marie-curie"
category: keyof typeof CATEGORY_HEADINGS;
summary: string;
}
/**
* Ensure each entry has a `- [[wikilink]] — summary` line under its category
* heading in index.md. Existing lines for the same wikilink are replaced.
*/
export async function upsertIndexEntries(layout: WikiLayout, entries: IndexEntry[]): Promise<void> {
if (entries.length === 0) return;
let content = await readIndex(layout);
if (!content.trim()) content = INDEX_SEED;
const lines = content.split(/\r?\n/);
for (const entry of entries) {
const heading = CATEGORY_HEADINGS[entry.category];
const newLine = `- [[${entry.wikilink}]] — ${entry.summary}`.trim();
const linkRe = new RegExp(`\\[\\[${escapeRe(entry.wikilink)}\\]\\]`);
// Remove any existing line for this wikilink.
for (let i = lines.length - 1; i >= 0; i--) {
if (linkRe.test(lines[i]) && lines[i].trimStart().startsWith("-")) lines.splice(i, 1);
}
// Find the category heading; create it at the end if absent.
let h = lines.findIndex((l) => l.trim() === heading);
if (h === -1) {
lines.push("", heading);
h = lines.length - 1;
}
lines.splice(h + 1, 0, newLine);
}
await writeIndex(layout, lines.join("\n"));
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}