Skip to the content

Capsid menu

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

The functions of layout.ts
FunctionLinesComplexityCoveredCRAPMutation
isBookFolder13 to 153100%OkCRAP 3No data: mutation testing has not run on this repo yet
isBookPath21 to 3213100%WatchCRAP 13No data: mutation testing has not run on this repo yet
<anonymous:24>24 to 241100%OkCRAP 1No data: mutation testing has not run on this repo yet
kindOf34 to 427100%WatchCRAP 7No data: mutation testing has not run on this repo yet
titleFromSegment45 to 492100%OkCRAP 2No data: mutation testing has not run on this repo yet
slugify51 to 601100%OkCRAP 1No data: mutation testing has not run on this repo yet
nextNumbered63 to 671100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:64>64 to 642100%OkCRAP 2No data: mutation testing has not run on this repo yet
fallbackName100 to 10310%OkCRAP 2No data: mutation testing has not run on this repo yet
wordCount108 to 1133100%OkCRAP 3No data: mutation testing has not run on this repo yet
readMeta115 to 16011100%WatchCRAP 11No data: mutation testing has not run on this repo yet
describe162 to 1651100%OkCRAP 1No data: mutation testing has not run on this repo yet
countProseWords167 to 1692100%OkCRAP 2No data: mutation testing has not run on this repo yet
scene172 to 1731100%OkCRAP 1No data: mutation testing has not run on this repo yet
character174 to 1741100%OkCRAP 1No data: mutation testing has not run on this repo yet
place175 to 17510%OkCRAP 2No data: mutation testing has not run on this repo yet
rule176 to 18510%OkCRAP 2No data: mutation testing has not run on this repo yet
orderKey188 to 1901100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:189>189 to 1892100%OkCRAP 2No data: mutation testing has not run on this repo yet
readingOrder193 to 2001100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:195>195 to 1951100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:196>196 to 1994100%OkCRAP 4No data: mutation testing has not run on this repo yet
chapterOf202 to 2053100%OkCRAP 3No 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;
}