app/lib/novels/frontmatter.ts
97 lines and 10 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| unquote | 18 to 24 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| splitList | 27 to 47 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| <anonymous:46> | 46 to 46 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| scalar | 49 to 55 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| parseFile | 57 to 90 | 13 | 100% | WatchCRAP 13 | No data: mutation testing has not run on this repo yet |
| <anonymous:60> | 60 to 60 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| text | 92 to 97 | 3 | 83% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| list | 99 to 104 | 4 | 75% | OkCRAP 4.3 | No data: mutation testing has not run on this repo yet |
| <anonymous:101> | 101 to 101 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| flag | 106 to 108 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. Imported by app/lib/books.server.ts, app/lib/novels/checks.ts, app/lib/novels/layout.ts, app/routes/book.chapter.tsx, app/routes/book.corkboard.tsx, app/routes/book.file.tsx.
Source
// The header at the top of a book file: a narrow YAML subset, enough for scene headers and bible
// entries and small enough to test completely. Keys hold a string, a boolean, or a list of strings
// (inline `[a, b]` or one `- item` per line). Anything else is reported, never guessed at.
export type HeaderValue = string | boolean | string[];
export type Parsed = {
data: Record<string, HeaderValue>;
body: string;
/** The 1-based line of the file where the body starts, so a finding in the body names the file's line. */
bodyLine: number;
/** Lines the parser did not understand, by file line. A file with no header has none. */
problems: { line: number; text: string }[];
};
const KEY = /^([A-Za-z][A-Za-z0-9_-]*):(?:\s+(.*))?$/;
function unquote(value: string): string {
const v = value.trim();
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
return v.slice(1, -1);
}
return v;
}
/** Splits `a, "b, c", d` on the commas outside quotes. */
function splitList(inner: string): string[] {
const items: string[] = [];
let current = "";
let quote: string | null = null;
for (const ch of inner) {
if (quote) {
if (ch === quote) quote = null;
current += ch;
} else if (ch === '"' || ch === "'") {
quote = ch;
current += ch;
} else if (ch === ",") {
items.push(current);
current = "";
} else {
current += ch;
}
}
items.push(current);
return items.map(unquote).filter((item) => item.length > 0);
}
function scalar(raw: string): HeaderValue {
const v = raw.trim();
if (v.startsWith("[") && v.endsWith("]")) return splitList(v.slice(1, -1));
if (v === "true") return true;
if (v === "false") return false;
return unquote(v);
}
export function parseFile(source: string): Parsed {
const lines = source.replace(/\r\n/g, "\n").split("\n");
if (lines[0]?.trim() !== "---") return { data: {}, body: lines.join("\n"), bodyLine: 1, problems: [] };
const end = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
if (end === -1) {
return { data: {}, body: lines.join("\n"), bodyLine: 1, problems: [{ line: 1, text: "The header opens with --- and never closes." }] };
}
const data: Record<string, HeaderValue> = {};
const problems: Parsed["problems"] = [];
// A key with nothing after it is an empty value until a `- item` line makes it a list.
let listKey: string | null = null;
for (let i = 1; i < end; i++) {
const line = lines[i]!;
if (line.trim() === "" || line.trim().startsWith("#")) continue;
const item = /^\s*-\s+(.*)$/.exec(line);
if (item && listKey) {
const current = data[listKey];
data[listKey] = [...(Array.isArray(current) ? current : []), unquote(item[1]!)];
continue;
}
const match = KEY.exec(line);
if (!match) {
problems.push({ line: i + 1, text: line });
listKey = null;
continue;
}
const [, key, value] = match;
const empty = value === undefined || value.trim() === "";
data[key!] = empty ? "" : scalar(value);
listKey = empty ? key! : null;
}
return { data, body: lines.slice(end + 1).join("\n"), bodyLine: end + 2, problems };
}
export function text(data: Record<string, HeaderValue>, key: string): string {
const value = data[key];
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) return value.join(", ");
return "";
}
export function list(data: Record<string, HeaderValue>, key: string): string[] {
const value = data[key];
if (Array.isArray(value)) return value.map((v) => v.trim()).filter(Boolean);
if (typeof value === "string" && value.trim()) return splitList(value);
return [];
}
export function flag(data: Record<string, HeaderValue>, key: string): boolean {
return data[key] === true;
}