fuzzyworld

auto-print / files / src / config.ts

config.ts

4.7 KB · history · blame

1// The config file (books.json): the list of books, and the settings they share.
2
3import fs from "node:fs";
4import path from "node:path";
5import { fail } from "./util.ts";
6
7export type Format = "epub" | "docx" | "pdf" | "kpf";
8
9export const allFormats: Format[] = ["epub", "docx", "pdf", "kpf"];
10
11/** Settings that a book can have, and that the "defaults" block can set for every book. */
12export interface Settings {
13 author: string;
14 language: string;
15 /** Heading whose content is the story. null means "use the whole file". */
16 section: string | null;
17 formats: Format[];
18 /**
19 * epub only. true: every chapter starts on a new page, as its own file.
20 * false: the chapters flow continuously, with only a heading between them.
21 */
22 chapterBreaks: boolean;
23}
24
25export interface BookEntry extends Partial<Settings> {
26 /** Used for the output folder and file names. */
27 slug: string;
28 title: string;
29 /** Markdown file, relative to the config file. */
30 source: string;
31 /** Cover image, relative to the config file. If missing, a text cover is generated. */
32 cover?: string;
33 /** Headings in the source file whose sections become pages before the story, in this order. */
34 front?: string[];
35 /** Headings in the source file whose sections become pages after the story, in this order. */
36 back?: string[];
37}
38
39/** A book with every setting filled in, and every path made absolute. */
40export interface Book extends Settings {
41 slug: string;
42 title: string;
43 source: string;
44 cover: string | null;
45 front: string[];
46 back: string[];
47}
48
49export interface Config {
50 /** Absolute path of the config file. */
51 path: string;
52 /** Absolute path of the folder that gets one subfolder of output files per book. */
53 outDir: string;
54 books: Book[];
55}
56
57const builtInDefaults: Omit<Settings, "author"> = {
58 language: "en-US",
59 section: "Writing",
60 formats: allFormats,
61 chapterBreaks: false,
62};
63
64const defaultOutDir = "auto-print-out";
65
66function isStringList(value: unknown): value is string[] {
67 return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0);
68}
69
70/** Reads and checks a config file. Every problem is reported with the file name and the book it is in. */
71export function loadConfig(configPath: string): Config {
72 const absolutePath = path.resolve(configPath);
73 const name = path.basename(absolutePath);
74 if (!fs.existsSync(absolutePath)) fail(`Config file not found: ${absolutePath}`);
75
76 let raw: { outDir?: unknown; defaults?: Partial<Settings>; books?: unknown };
77 try {
78 raw = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
79 } catch (error) {
80 fail(`${name} is not valid JSON: ${(error as Error).message}`);
81 }
82 if (!Array.isArray(raw.books)) fail(`${name}: needs a "books" list.`);
83 if (raw.outDir !== undefined && (typeof raw.outDir !== "string" || !raw.outDir)) {
84 fail(`${name}: "outDir" must be a folder name.`);
85 }
86
87 const baseDir = path.dirname(absolutePath);
88 const defaults = { ...builtInDefaults, ...raw.defaults };
89 const seen = new Set<string>();
90 const books = (raw.books as BookEntry[]).map((entry): Book => {
91 const label = `${name}: book "${entry.slug ?? entry.title ?? JSON.stringify(entry)}"`;
92 if (!entry.slug || !entry.title || typeof entry.source !== "string" || !entry.source) {
93 fail(`${name}: every book needs "slug", "title" and "source" (one file). Problem entry: ${JSON.stringify(entry)}`);
94 }
95 if (seen.has(entry.slug)) fail(`${name}: the slug "${entry.slug}" is used twice.`);
96 seen.add(entry.slug);
97
98 const author = entry.author ?? defaults.author;
99 if (typeof author !== "string" || !author.trim()) {
100 fail(`${label} needs an "author", in the book or in the "defaults" block.`);
101 }
102 for (const position of ["front", "back"] as const) {
103 if (entry[position] !== undefined && !isStringList(entry[position])) {
104 fail(`${label}: "${position}" must be a list of heading names, like ["Dedication"].`);
105 }
106 }
107 const formats = entry.formats ?? defaults.formats;
108 for (const format of formats) {
109 if (!allFormats.includes(format)) fail(`${label}: unknown format "${format}". Allowed: ${allFormats.join(", ")}.`);
110 }
111
112 return {
113 slug: entry.slug,
114 title: entry.title,
115 source: path.resolve(baseDir, entry.source),
116 cover: entry.cover ? path.resolve(baseDir, entry.cover) : null,
117 front: entry.front ?? [],
118 back: entry.back ?? [],
119 author,
120 language: entry.language ?? defaults.language,
121 section: entry.section !== undefined ? entry.section : defaults.section,
122 formats,
123 chapterBreaks: entry.chapterBreaks ?? defaults.chapterBreaks,
124 };
125 });
126
127 return {
128 path: absolutePath,
129 outDir: path.resolve(baseDir, (raw.outDir as string | undefined) ?? defaultOutDir),
130 books,
131 };
132}