app/lib/novels/layout.ts
183 lines and 23 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| isBookFolder | 13 to 15 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| isBookPath | 21 to 32 | 13 | 100% | WatchCRAP 13 | No data: mutation testing has not run on this repo yet |
| <anonymous:24> | 24 to 24 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| kindOf | 34 to 42 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| titleFromSegment | 45 to 49 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| slugify | 51 to 60 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| nextNumbered | 63 to 67 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:64> | 64 to 64 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| fallbackName | 100 to 103 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| wordCount | 108 to 113 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| readMeta | 115 to 160 | 11 | 100% | WatchCRAP 11 | No data: mutation testing has not run on this repo yet |
| describe | 162 to 165 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| countProseWords | 167 to 169 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| scene | 172 to 173 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| character | 174 to 174 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| place | 175 to 175 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| rule | 176 to 185 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| orderKey | 188 to 190 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:189> | 189 to 189 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| readingOrder | 193 to 200 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:195> | 195 to 195 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:196> | 196 to 199 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| chapterOf | 202 to 205 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/lib/novels/frontmatter.ts. Imported by app/lib/books.server.ts, app/lib/mcp/tools.ts, app/lib/novels/checks.ts, app/lib/writing/binder.ts, app/routes/book.chapter.tsx, app/routes/book.corkboard.tsx, app/routes/book.file.tsx, app/routes/book.history.tsx, app/routes/book.outliner.tsx, app/routes/book.tsx.
Source
// The writing repository's layout, from the design job: shared/ for voice passages, checks, assembly
// and templates; one folder per book with bible/, outline/, chapters/NN-name/NN-scene.md, and build/
// ignored. Paths here are relative to the book's folder unless a name says otherwise.
import { flag, list, parseFile, text, type HeaderValue } from "./frontmatter";
export type FileKind = "scene" | "character" | "place" | "rule" | "outline" | "book" | "note";
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const NUMBERED = /^(\d{2,3})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
/** A book folder name: lower case, digits and single hyphens, as every segment is. */
export function isBookFolder(value: string): boolean {
return SEGMENT.test(value) && value !== "shared" && value.length <= 80;
}
/**
* A Markdown file Carrel may open and save inside a book: known folders only, plain segments, no
* `..`, nothing under build/. Anything else is refused before Git is asked.
*/
export function isBookPath(path: string): boolean {
if (!path.endsWith(".md") || path.length > 200) return false;
const parts = path.slice(0, -3).split("/");
if (!parts.every((p) => SEGMENT.test(p))) return false;
if (parts.length === 1) return parts[0] === "book";
const [top] = parts;
if (top === "chapters") return parts.length === 3 && NUMBERED.test(parts[1]!) && NUMBERED.test(parts[2]!);
if (top === "bible") return parts.length === 2 || (parts.length === 3 && ["characters", "places", "rules"].includes(parts[1]!));
// Notes (design 4.1): flat like the outline, read by no check and never exported.
if (top === "outline" || top === "notes") return parts.length === 2;
return false;
}
export function kindOf(path: string): FileKind {
if (path === "book.md") return "book";
if (path.startsWith("chapters/")) return "scene";
if (path.startsWith("bible/characters/")) return "character";
if (path.startsWith("bible/places/")) return "place";
if (path.startsWith("bible/rules/")) return "rule";
if (path.startsWith("outline/")) return "outline";
return "note";
}
/** "03-the-long-road" to "The long road". */
export function titleFromSegment(segment: string): string {
const name = NUMBERED.exec(segment)?.[2] ?? segment;
const words = name.replace(/-/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function slugify(title: string): string {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
}
/** The next NN- prefix after the ones present, two digits until a hundred. */
export function nextNumbered(existing: string[], name: string): string {
const numbers = existing.map((s) => Number(NUMBERED.exec(s)?.[1] ?? 0));
const next = Math.max(0, ...numbers) + 1;
return `${String(next).padStart(2, "0")}-${name}`;
}
export type SceneHeader = {
pov: string;
date: string;
location: string;
characters: string[];
goal: string;
conflict: string;
outcome: string;
flashback: boolean;
/** Writing-desk keys (ruling 8): a label from the project's status list, a line for the outliner, a word goal. */
status: string;
summary: string;
target: number | null;
/** A ceiling, not a goal: a journal section's word limit. */
limit: number | null;
};
export type CharacterEntry = { name: string; aliases: string[]; born: string; died: string };
export type PlaceEntry = { name: string; aliases: string[] };
export type RuleEntry = { name: string; forbidden: string[] };
type BookEntry = { title: string; author: string; language: string; target: number | null; deadline: string };
export type Meta =
| { kind: "scene"; header: SceneHeader; hasHeader: boolean }
| { kind: "character"; entry: CharacterEntry }
| { kind: "place"; entry: PlaceEntry }
| { kind: "rule"; entry: RuleEntry }
| { kind: "book"; entry: BookEntry }
| { kind: "outline" | "note" };
/** The name a bible file stands for when its header gives none: its file name. */
function fallbackName(path: string): string {
const file = path.split("/").pop()!.replace(/\.md$/, "");
return titleFromSegment(file);
}
const DAY = /^\d{4}-\d{2}-\d{2}$/;
/** A whole number of words, written as 2000, 2,000 or 2_000. Anything else is no target, not a guess. */
function wordCount(data: Record<string, HeaderValue>, key: string): number | null {
const raw = text(data, key).replace(/[,_]/g, "");
if (!/^\d{1,7}$/.test(raw)) return null;
const n = Number(raw);
return n > 0 ? n : null;
}
export function readMeta(path: string, data: Record<string, HeaderValue>, hasHeader: boolean): Meta {
const kind = kindOf(path);
switch (kind) {
case "scene":
return {
kind,
hasHeader,
header: {
pov: text(data, "pov"),
date: text(data, "date"),
location: text(data, "location"),
characters: list(data, "characters"),
goal: text(data, "goal"),
conflict: text(data, "conflict"),
outcome: text(data, "outcome"),
flashback: flag(data, "flashback"),
status: text(data, "status"),
summary: text(data, "summary"),
target: wordCount(data, "target"),
limit: wordCount(data, "limit"),
},
};
case "character":
return {
kind,
entry: { name: text(data, "name") || fallbackName(path), aliases: list(data, "aliases"), born: text(data, "born"), died: text(data, "died") },
};
case "place":
return { kind, entry: { name: text(data, "name") || fallbackName(path), aliases: list(data, "aliases") } };
case "rule":
return { kind, entry: { name: text(data, "name") || fallbackName(path), forbidden: list(data, "forbidden") } };
case "book":
return {
kind,
entry: {
title: text(data, "title"),
author: text(data, "author"),
language: text(data, "language") || "en",
target: wordCount(data, "target"),
deadline: DAY.test(text(data, "deadline")) ? text(data, "deadline") : "",
},
};
default:
return { kind };
}
}
export function describe(path: string, source: string): { meta: Meta; words: number } {
const parsed = parseFile(source);
return { meta: readMeta(path, parsed.data, Object.keys(parsed.data).length > 0), words: countProseWords(parsed.body) };
}
export function countProseWords(body: string): number {
return body.match(/[\p{L}\p{N}]+(?:['\u2019][\p{L}]+)*/gu)?.length ?? 0;
}
export const TEMPLATES: Record<"scene" | "character" | "place" | "rule", (name: string) => string> = {
scene: () =>
["---", "status:", "summary:", "target:", "pov:", "date:", "location:", "characters: []", "goal:", "conflict:", "outcome:", "---", "", ""].join("\n"),
character: (name) => ["---", `name: ${name}`, "aliases: []", "born:", "died:", "---", "", ""].join("\n"),
place: (name) => ["---", `name: ${name}`, "aliases: []", "---", "", ""].join("\n"),
rule: (name) =>
[
"---",
`name: ${name}`,
"# Each pattern is matched against every scene, ignoring case. A match is flagged, never refused.",
"forbidden: []",
"---",
"",
"",
].join("\n"),
};
function orderKey(path: string): number[] {
return path.split("/").slice(1).map((segment) => Number(NUMBERED.exec(segment.replace(/\.md$/, ""))?.[1] ?? 0));
}
/** Scenes in reading order: chapters by their number, then scenes by theirs (so 100 follows 99). */
export function readingOrder<T extends { path: string }>(files: T[]): T[] {
return files
.filter((f) => kindOf(f.path) === "scene")
.sort((a, b) => {
const [ka, kb] = [orderKey(a.path), orderKey(b.path)];
return ka[0]! - kb[0]! || ka[1]! - kb[1]! || (a.path < b.path ? -1 : 1);
});
}
export function chapterOf(path: string): string | null {
const parts = path.split("/");
return parts[0] === "chapters" && parts.length === 3 ? parts[1]! : null;
}