@openbooknetwork/kitap 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/build.mjs ADDED
@@ -0,0 +1,641 @@
1
+ // Walk content/<story>/, read the pages with their texts, images and catalogues, let the engine read its own files
2
+ // and fields, and write dist/: the story bundle, a static page per step and language, the main page and the crawl
3
+ // files. The engine is the package that draws the viewport; its hooks are documented in README.md.
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { execFileSync } from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import { createRequire } from 'module';
9
+ import yaml from 'js-yaml';
10
+ import { marked } from 'marked';
11
+ import { check, Site, storySchema, pageSchema, Group, ImageFolder, Themes } from './schema.mjs';
12
+ import { loadCatalogues, translator, flatten, unflatten } from './i18n.mjs';
13
+ import { esc, ordinals, PLACE, siteContext, storyFiles, siteFiles } from './pages.mjs';
14
+ import { libraryItems, storyFolders, isImage } from './folders.mjs';
15
+ import { writeVendor } from './vendor.mjs';
16
+ import { hash, listDirs, idOf } from './util.mjs';
17
+ import { sha256, imageKeys } from './image.mjs';
18
+ import { storyTree, checkStore } from './store.mjs';
19
+ import { loadEngine, checkEngine } from './engine.mjs';
20
+
21
+ // PKG is this package. The content project comes in as the root of each build.
22
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const require = createRequire(import.meta.url);
24
+ const REPO = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')).repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
25
+ const COMMON = fs.readFileSync(path.join(PKG, 'src', 'common.js'), 'utf8').replace('/*__REPO__*/', REPO);
26
+ // the story page shell, and the script every story page shares as dist/kitap.js
27
+ const APP = fs.readFileSync(path.join(PKG, 'src', 'app.html'), 'utf8');
28
+ const APP_JS = COMMON + '\n' + fs.readFileSync(path.join(PKG, 'src', 'app.js'), 'utf8');
29
+ const INDEX = fs.readFileSync(path.join(PKG, 'src', 'index.html'), 'utf8').replace('/*__COMMON__*/', COMMON);
30
+ // a script's version in its URL, so a browser holding the last release's copy fetches the new one
31
+ const version = text => hash(text).slice(0, 10);
32
+ // the shape of the main page card kept in the cache. A card of another shape counts as no cache.
33
+ const CARD_FORMAT = 6;
34
+ // Cache files are written whole and renamed into place, so a build running at the same time reads the old file or
35
+ // the new one. A file that is missing or does not parse counts as no cache.
36
+ const readCache = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
37
+ // Copies an original unless dest already holds it, through a temporary file, so a build running beside reads it whole.
38
+ const copyFresh = (file, dest) => {
39
+ const from = fs.statSync(file), to = fs.existsSync(dest) && fs.statSync(dest);
40
+ if (to && to.size === from.size && to.mtimeMs >= from.mtimeMs) return;
41
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
42
+ const tmp = `${dest}.${process.pid}`;
43
+ fs.copyFileSync(file, tmp); fs.renameSync(tmp, dest);
44
+ };
45
+ const writeCache = (file, value) => { fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(value)); fs.renameSync(tmp, file); };
46
+ const UI_DIR = path.join(PKG, 'src', 'i18n');
47
+ const readUi = dir => Object.fromEntries(fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).map(f => [f.replace(/\.yaml$/, ''), yaml.load(fs.readFileSync(path.join(dir, f), 'utf8')) ?? {}]));
48
+ const UI = readUi(UI_DIR);
49
+ // Icons come from Lucide: the shell's own, and the ones an engine asks for by name
50
+ const ICON_DIR = path.dirname(require.resolve('lucide-static/icons/flag.svg'));
51
+ const SHELL_ICONS = ['x', 'external-link', 'chevron-left', 'chevron-right', 'list', 'info', 'settings', 'languages'];
52
+ function iconSvg(name, where) {
53
+ const file = path.join(ICON_DIR, name + '.svg');
54
+ if (!exists(file)) fail(where, `unknown icon "${name}", pick a name from https://lucide.dev/icons`);
55
+ return fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '').replace(/\s*class="[^"]*"/, '').replace(/\s+/g, ' ').replace(/> </g, '><').trim();
56
+ }
57
+
58
+ const fail = (where, msg) => { throw new Error(`${where}: ${msg}`); };
59
+ const exists = p => fs.existsSync(p);
60
+ const listFiles = (p, ext) => exists(p) ? fs.readdirSync(p).filter(f => f.endsWith(ext)).sort() : [];
61
+ const writeFile = (file, text) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); };
62
+
63
+ const emptyText = langs => Object.fromEntries(langs.map(l => [l, '']));
64
+ // A when value with the missing parts rounded down for from and up for to.
65
+ const whenEdge = (s, isEnd) => { const [y, m, d] = s.split('-'); return `${y}-${m ?? (isEnd ? '12' : '01')}-${d ?? (isEnd ? '31' : '01')}`; };
66
+
67
+ // --- interface strings: what kitap ships, what the engine ships laid over it, and the project's i18n/ui/<lang>.yaml over both ---
68
+ const engineUiCache = new Map();
69
+ function engineUi(engine) {
70
+ if (!engine?.ui) return {};
71
+ const dir = engine.ui instanceof URL ? fileURLToPath(engine.ui) : engine.ui;
72
+ if (!engineUiCache.has(dir)) engineUiCache.set(dir, exists(dir) ? readUi(dir) : {});
73
+ return engineUiCache.get(dir);
74
+ }
75
+ const shipped = (engine, lang) => ({ ...flatten(UI[lang] ?? {}), ...flatten(engineUi(engine)[lang] ?? {}) });
76
+ const uiOwn = (root, l) => { const f = path.join(root, 'i18n', 'ui', l + '.yaml'); return exists(f) ? flatten(yaml.load(fs.readFileSync(f, 'utf8')) ?? {}) : {}; };
77
+ // Interface keys: every string shipped in English, plus a name for each theme the site offers.
78
+ export function uiCatalogue(root, lang, themes, engine = null) {
79
+ const en = shipped(engine, 'en');
80
+ const entries = Object.keys(en).filter(k => !k.startsWith('themes.')).map(key => ({ key, source: en[key], shared: false, inline: null }))
81
+ .concat(Object.entries(themes).map(([id, t]) => ({ key: `themes.${id}`, source: en[`themes.${id}`] ?? (typeof t.name === 'string' ? t.name : t.name?.en) ?? id, shared: false, inline: null })));
82
+ // a theme that still carries an inline name for this language counts as translated
83
+ const inlineNames = Object.fromEntries(Object.entries(themes).filter(([, t]) => typeof t.name === 'object' && t.name?.[lang]).map(([id, t]) => [`themes.${id}`, t.name[lang]]));
84
+ const own = shipped(engine, lang);
85
+ const have = { ...inlineNames, ...own, ...uiOwn(root, lang) };
86
+ return { entries, existing: have, missing: entries.filter(e => !have[e.key]).map(e => e.key), shipped: Object.keys(own).length > 0 };
87
+ }
88
+ // dir is the language's writing direction, from ICU; Node before 23 has textInfo, later ones getTextInfo()
89
+ const textDir = l => { const loc = new Intl.Locale(l); return (loc.getTextInfo?.() ?? loc.textInfo).direction; };
90
+ function uiFor(langs, root, engine) {
91
+ // a missing key falls back to English alone
92
+ return Object.fromEntries(langs.map(l => [l, { ...unflatten({ ...shipped(engine, 'en'), ...shipped(engine, l), ...uiOwn(root, l) }), dir: textDir(l) }]));
93
+ }
94
+ const themeList = (themes, ui, langs) => Object.entries(themes).map(([id, t]) => ({ id, name: Object.fromEntries(langs.map(l => [l, ui[l].themes?.[id] ?? (typeof t.name === 'string' ? t.name : t.name?.[l] ?? t.name?.en) ?? id])) }));
95
+
96
+ // A map field written at the top level of story.yaml or page.yaml is sent under the engine's key, so a file from
97
+ // before the engines were split reads as one written now.
98
+ function lift(data, engine, schema) {
99
+ const shape = schema?.shape ?? schema?.def?.shape ?? null;
100
+ if (!engine || !shape || !data || typeof data !== 'object') return data;
101
+ for (const k of Object.keys(shape)) if (k in data && !(k in (data[engine.id] ?? {}))) { (data[engine.id] ??= {})[k] = data[k]; delete data[k]; }
102
+ return data;
103
+ }
104
+
105
+ // The content project of one build: its folders, its logger, and readers that name files relative to it.
106
+ function project(root, log) {
107
+ const rel = p => path.relative(root, p);
108
+ const loadYaml = p => { try { return yaml.load(fs.readFileSync(p, 'utf8')) ?? {}; } catch (e) { fail(rel(p), e.message); } };
109
+ const readYaml = (p, schema) => check(schema, loadYaml(p), rel(p));
110
+ const readJson = (p, schema) => { let data; try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { fail(rel(p), e.message); } return schema ? check(schema, data, rel(p)) : data; };
111
+ // the links of an image's objects, from the site root or absolute. buildPages sets them for its assets mode.
112
+ const imageLinks = ({ keys }) => ({ original: `assets/${keys.original}`, large: `assets/${keys.large}`, small: `assets/${keys.small}` });
113
+ return { root, content: path.join(root, 'content'), dist: path.join(root, 'dist'), log, rel, loadYaml, readYaml, readJson, imageLinks, options: {} };
114
+ }
115
+ // a link from the site root as a story page uses it, below its story folder
116
+ const fromStory = u => /^https?:/.test(u) ? u : `../${u}`;
117
+ const IMAGE_FILE = /\.(jpe?g|png|webp|gif|svg)$/i;
118
+
119
+ const LAND_SLOTS = 8;
120
+ // CSS for every theme: tokens on :root[data-palette=id], light and dark; the default theme also on bare :root
121
+ function themeCss(themes, defaultId) {
122
+ const TOKEN = { paper: '--paper', paper2: '--paper-2', sea: '--sea', ink: '--ink', ink2: '--ink-2', rule: '--rule', accent: '--accent', panel: '--panel', route: '--route', border: '--border' };
123
+ const lum = hex => { const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
124
+ // eight land slots always, filled up from paper2, so a theme without tints paints one colour and never inherits another theme's
125
+ const land = set => [...Array(LAND_SLOTS)].map((_, i) => `--land-${i}:${set.land?.[i % set.land.length] ?? set.paper2};`).join('');
126
+ const decl = set => Object.entries(set).filter(([k]) => TOKEN[k]).map(([k, v]) => `${TOKEN[k]}:${v};`).join('') + land(set) + (lum(set.paper) < 0.5 ? 'color-scheme:dark;' : 'color-scheme:light;');
127
+ const block = (sel, t) => `${sel}{${decl(t.light)}}@media (prefers-color-scheme: dark){${sel}:not([data-theme="light"]){${decl(t.dark)}}}${sel}[data-theme="dark"]{${decl(t.dark)}}`;
128
+ return block(':root', themes[defaultId]) + Object.entries(themes).map(([id, t]) => block(`:root[data-palette="${id}"]`, t)).join('');
129
+ }
130
+
131
+ // whether a text names a place by its label, or by the label without a part in brackets, at the start of a word, so a
132
+ // Turkish suffix such as Gelibolu’ya counts
133
+ const mentions = (text, label) => [label, label.replace(/\s*\(.*\)$/, '')].some(l => l && new RegExp(`(?<![\\p{L}\\p{N}])${l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'u').test(text));
134
+
135
+ // The story's content as text: story.yaml, the page tree with each page's text, the images they show, and what the
136
+ // engine read from its own files and fields. onProblem(folder, message, page id) takes each problem, else the first
137
+ // throws.
138
+ function readContent(p, storyDir, engine, onProblem = null) {
139
+ const { rel, readYaml, readJson, log, root } = p;
140
+ const where = rel(storyDir), shared = path.join(storyDir, 'shared'), storyFile = path.join(storyDir, 'story.yaml');
141
+ const pageIdOf = new Map(); // a page folder's page id, so check can keep to the pages it was given
142
+ const attempt = (dir, fn) => { if (!onProblem) return fn(); try { return fn(); } catch (err) { onProblem(dir, err.message, pageIdOf.get(dir)); } };
143
+ const warnings = [];
144
+ const story = check(storySchema(engine), lift(p.loadYaml(storyFile), engine, engine.schemas.story), rel(storyFile));
145
+ const langs = story.languages;
146
+ const defaultLang = story.default_language ?? langs[0];
147
+ // strings: the default language inline, the others from i18n/<lang>.yaml catalogues
148
+ const { tr: T, entries, report } = translator({ langs, defaultLang, catalogues: loadCatalogues(path.join(storyDir, 'i18n'), langs, p.loadYaml), where: `${where}/i18n`, fail });
149
+ const images = {}, definedIn = {};
150
+ const define = (table, id, value, file, kind, dir) => { if (table[id]) fail(file, `${kind} "${id}" is defined twice`); table[id] = value; definedIn[`${kind} ${id}`] = dir; };
151
+ const icons = {}, icon = (name, at) => icons[name] ??= iconSvg(name, at);
152
+ for (const n of SHELL_ICONS) icon(n, where);
153
+ const texts = {}, firstImages = {};
154
+ // per text file, the names the page's engine items carry that the text writes without a link, for check --places
155
+ const unlinked = [];
156
+
157
+ // the items of a library at any depth, by their folder names as written, so a reference finds the same folder on
158
+ // every file system
159
+ const libraries = new Map();
160
+ const library = (dir, isItem, pattern, rule) => {
161
+ if (!libraries.has(dir)) {
162
+ let items;
163
+ try { items = libraryItems(dir, isItem, rel); } catch (err) { fail(rel(dir), err.message); }
164
+ for (const [name, full] of items) if (!pattern.test(name)) fail(rel(full), rule);
165
+ libraries.set(dir, items);
166
+ }
167
+ return libraries.get(dir);
168
+ };
169
+ const imageLib = dir => library(dir, isImage, /^[a-z0-9][a-z0-9-]*-[0-9a-f]{6}$/, 'an image folder is named <name>-<six hex digits>, such as hastings-knights-3f9a1c, and kitap image <file> <name> makes one');
170
+ const storyImages = path.join(shared, 'images'), commonImages = path.join(p.content, 'shared', 'images');
171
+
172
+ // --- image folders: <id>/image.yaml, and the original where this machine has it, read when a text, marker or card
173
+ // first names one. Its objects are in the asset store, or with --local-assets the original is copied to dist/assets/.
174
+ const folderImages = {};
175
+ const loadImageFolder = (dir, id) => {
176
+ if (folderImages[id]) return id;
177
+ const meta = path.join(dir, 'image.yaml'), files = fs.readdirSync(dir).filter(f => /^image\.\w+$/.test(f) && f !== 'image.yaml');
178
+ if (!exists(meta)) fail(rel(dir), 'image.yaml is missing');
179
+ if (files.length > 1) fail(rel(dir), `keep one image file of ${files.join(', ')}`);
180
+ const m = readYaml(meta, ImageFolder);
181
+ if (files.length && files[0] !== m.file) fail(rel(meta), `file names ${m.file} and the folder holds ${files[0]}, kitap rehash writes the file it holds`);
182
+ const t = translator({ langs, defaultLang: m.default_language, catalogues: loadCatalogues(path.join(dir, 'i18n'), langs, p.loadYaml), where: rel(meta), fail });
183
+ const keys = imageKeys(id, m), file = files.length ? path.join(dir, files[0]) : null;
184
+ const links = p.imageLinks({ keys, file, url: m.url ?? null, at: rel(dir) });
185
+ define(images, id, { id, src: fromStory(links.large), thumb: fromStory(links.small), caption: t.tr(m.caption, 'image.caption'), credit: m.credit ? t.tr(m.credit, 'image.credit', { shared: true }) : emptyText(langs) }, rel(meta), 'image', dir);
186
+ // social sites read no SVG, so og:image takes the 1280 px WebP of one
187
+ const og = path.extname(m.file) === '.svg' ? links.large : links.original;
188
+ folderImages[id] = { id, dir: rel(dir), meta: rel(meta), file, url: m.url ?? null, sha256: m.sha256, keys, links: { ...links, og }, defaultLang: m.default_language, translator: t };
189
+ return id;
190
+ };
191
+ // an image a text, an engine item or a card names: the image folder the scope sees by that id
192
+ const resolveImage = (s, ref, at) => {
193
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(ref)) fail(at, `unknown image "${ref}", an image id is lowercase letters, digits and dashes`);
194
+ const dirs = s.imageDirs.map(d => imageLib(d).get(ref)).filter(Boolean);
195
+ if (dirs.length > 1) fail(at, `image "${ref}" is in ${dirs.map(rel).join(' and ')}, keep one`);
196
+ if (dirs.length) return loadImageFolder(dirs[0], ref);
197
+ fail(at, `unknown image "${ref}"`);
198
+ };
199
+
200
+ // A scope is the story's files or an included item's, such as a battle's. It carries the translator of its
201
+ // catalogues, the image libraries it sees, how its ids and page ids read in the story, and where its texts go.
202
+ // textFallback lets a page without a text in a language show the scope's default language text.
203
+ const scope = (fields) => ({ kind: 'story', T, defaultLang, imageDirs: [storyImages, commonImages], ns: id => id, pageId: id => id, texts, firstImages, fallbacks: {}, textFallback: false, prepare: null, decorate: null, include: null, ...fields });
204
+ const storyScope = scope({});
205
+ // what the engine's hooks see
206
+ const ctx = { root, content: p.content, rel, where, storyFile: rel(storyFile), story, langs, defaultLang, T, fail, attempt, warn: m => warnings.push(m), log, readYaml, readJson, loadYaml: p.loadYaml,
207
+ library, icon, resolveImage, define, images, idOf, listFiles, exists, strict: !!p.strict, onProblem, scope, storyScope, emptyText: () => emptyText(langs), storyImages, commonImages, options: p.options, texts, firstImages,
208
+ // a translator over a folder's own catalogues, for an item of the engine's that carries its strings in its folder
209
+ translatorFor: ({ defaultLang: d, dir, where: w }) => translator({ langs, defaultLang: d, catalogues: loadCatalogues(path.join(dir, 'i18n'), langs, p.loadYaml), where: w, fail }) };
210
+ const E = engine.read.story(story, ctx);
211
+ ctx.state = E; storyScope.engine = E;
212
+
213
+ // --- pages/ is a tree: a folder with page.yaml is a page, one with include.yaml places another item's pages, any
214
+ // other folder is a header for what it holds ---
215
+ const walk = (dir, s) => listDirs(dir).map(name => {
216
+ const full = path.join(dir, name), id = idOf(name);
217
+ if (exists(path.join(full, 'page.yaml')) && exists(path.join(full, 'include.yaml'))) return attempt(full, () => fail(rel(full), 'holds page.yaml and include.yaml, keep one: a folder is a page or the place of an include'));
218
+ if (exists(path.join(full, 'page.yaml'))) { pageIdOf.set(full, s.pageId(id)); return { type: 'page', id: s.pageId(id), dir: full, scope: s }; }
219
+ if (exists(path.join(full, 'include.yaml'))) return attempt(full, () => engine.read.include ? engine.read.include(full, s, ctx) : fail(rel(path.join(full, 'include.yaml')), `the ${engine.id} engine includes nothing, a folder holds page.yaml or group.yaml`));
220
+ const g = exists(path.join(full, 'group.yaml')) ? attempt(full, () => readYaml(path.join(full, 'group.yaml'), Group)) ?? {} : {};
221
+ return { type: 'group', id, title: s.T(g.title ?? id, `groups.${id}.title`), children: walk(full, s) };
222
+ }).filter(Boolean);
223
+ const pagesIn = nodes => nodes.flatMap(n => n.type === 'page' ? [n] : pagesIn(n.children));
224
+ ctx.walk = walk; ctx.pagesIn = pagesIn;
225
+
226
+ // --- pages ---
227
+ const figure = (id, lang) => {
228
+ const im = images[id];
229
+ return `<figure><img src="${esc(im.src)}" alt="${esc(im.caption[lang])}" data-img="${esc(id)}"><figcaption>${esc(im.caption[lang])}<small>${esc(im.credit[lang])}</small></figcaption></figure>`;
230
+ };
231
+ // per page and language: the Markdown source, and the first image the text shows, for the static pages
232
+ function readPage(dir, s) {
233
+ const file = path.join(dir, 'page.yaml'), at = rel(file), own = idOf(path.basename(dir)), pid = s.pageId(own);
234
+ const pg = check(pageSchema(engine), lift(p.loadYaml(file), engine, engine.schemas.page), at);
235
+ const texts = s.texts[pid] = {}, firstImages = s.firstImages[pid] = {};
236
+ const need = (ok, msg) => attempt(dir, () => ok || fail(at, msg));
237
+ const w = pg.when == null ? null : typeof pg.when === 'string' ? { from: pg.when } : pg.when;
238
+ const when = w && { from: whenEdge(w.from, false), to: whenEdge(w.to ?? w.from, true) };
239
+ if (when) need(when.to >= when.from, 'when.to is before when.from');
240
+ // the engine's fields of the page, and how the text's links resolve
241
+ const ep = attempt(dir, () => engine.read.page(pg[engine.id] ?? {}, { dir, at, own, pid, scope: s, core: pg }, ctx)) ?? { data: {} };
242
+ const links = ep.links ?? null;
243
+ const textOf = lang => { const f = path.join(dir, 'text', lang + '.md'); return exists(f) ? [f, fs.readFileSync(f, 'utf8')] : null; };
244
+ const html = {};
245
+ for (const lang of langs) attempt(dir, () => {
246
+ // an included page without a text in this language shows its default language text
247
+ let found = textOf(lang), fallback = false;
248
+ if (!found && s.textFallback && lang !== s.defaultLang) {
249
+ found = textOf(s.defaultLang);
250
+ if (!found) fail(rel(dir), `text/${s.defaultLang}.md is missing`);
251
+ fallback = true; (s.fallbacks[lang] ??= []).push(pid);
252
+ }
253
+ if (!found) fail(rel(dir), `text/${lang}.md is missing`);
254
+ const [textFile, source] = found;
255
+ // the image ids become the bundle's, so the Markdown export and og:image find an included item's images
256
+ texts[lang] = source.replace(/^(@image\s+)(\S+)(\s*)$/gm, (_, head, imgId, tail) => {
257
+ const id = resolveImage(s, imgId, rel(textFile));
258
+ firstImages[lang] ??= id;
259
+ return head + id + tail;
260
+ });
261
+ const md = texts[lang].replace(/^@image\s+(\S+)\s*$/gm, (_, imgId) => `\n${figure(imgId, lang)}\n`)
262
+ .replace(PLACE, (_, name, id) => {
263
+ if (!links) attempt(dir, () => fail(rel(textFile), `[${name}](@${id}) links to "${id}", and this page has nothing to link`));
264
+ else if (!links.has(id)) attempt(dir, () => fail(rel(textFile), links.missing(name, id)));
265
+ return `<span class="place"${links?.attr(id) ?? ` data-ref="${esc(id)}"`} tabindex="0">${name}</span>`;
266
+ });
267
+ if (!fallback && links?.names) {
268
+ const linked = new Set([...source.matchAll(PLACE)].map(m => m[2])), prose = source.replace(PLACE, ' ');
269
+ const names = links.names(lang).filter(([id]) => !linked.has(id)).filter(([, label]) => label && mentions(prose, label));
270
+ if (names.length) unlinked.push({ pageId: pid, file: rel(textFile), names: names.map(([id, label]) => `${label} (@${id})`) });
271
+ }
272
+ const out = marked.parse(ordinals(md));
273
+ html[lang] = fallback ? `<div lang="${s.defaultLang}" dir="${textDir(s.defaultLang)}">${out}</div>` : out;
274
+ });
275
+ const sourceTexts = pg.sources.map((x, i) => s.T(x, `pages.${own}.sources.${i}`, { shared: true }));
276
+ const sources = Object.fromEntries(langs.map(l => [l, sourceTexts.map(x => x[l])]));
277
+ // credits the engine's page data carries beyond the story's, behind an "Other sources" button in the viewport
278
+ const creditTexts = (ep.credits ?? []).map((x, i) => s.T(x.text, `pages.${own}.${ep.creditsKey ?? 'credits'}.${i}`, { shared: true }));
279
+ const credits = Object.fromEntries(langs.map(l => [l, (ep.credits ?? []).map((x, i) => ({ text: creditTexts[i][l], url: x.url ?? null }))]));
280
+ return { id: pid, dir: rel(dir), include: s.include, when, date: pg.date != null ? s.T(pg.date, `pages.${own}.date`) : emptyText(langs), title: s.T(pg.title, `pages.${own}.title`),
281
+ html, sources, credits, card: pg.card ?? null, [engine.id]: ep.data ?? {} };
282
+ }
283
+ ctx.readPage = readPage;
284
+
285
+ // the story's pages and the pages of what it includes, in reading order
286
+ const tree = walk(path.join(storyDir, 'pages'), storyScope), nodes = pagesIn(tree);
287
+ if (!nodes.length) fail(where, 'no pages');
288
+ const storyDirs = [shared, ...nodes.filter(n => n.scope === storyScope).map(n => n.dir)];
289
+ for (const dir of storyDirs) attempt(dir, () => engine.read.folder(dir, storyScope, ctx));
290
+ if (engine.read.prepare) attempt(storyDir, () => engine.read.prepare(ctx)); // the engine's cross references, once every file is read
291
+ const pages = nodes.map(n => {
292
+ n.scope.prepare?.();
293
+ const pg = attempt(n.dir, () => readPage(n.dir, n.scope));
294
+ return pg && (n.scope.decorate?.(pg) ?? pg);
295
+ }).filter(Boolean);
296
+ const coverImage = story.cover ? attempt(storyDir, () => resolveImage(storyScope, story.cover, `${rel(storyFile)} cover`)) ?? null : null;
297
+ // the engine's checks over the whole story, the images its items show, and the folders it read catalogues from
298
+ const fin = attempt(storyDir, () => engine.read.finish({ story, pages, texts, images, coverImage }, ctx)) ?? {};
299
+ // an included item's page without a text in a language showed its default language text
300
+ for (const f of fin.folders ?? []) for (const [lang, ids] of Object.entries(f.fallbacks ?? {})) warnings.push(`${f.kind} "${f.id}" has no ${lang} text on ${ids.join(', ')}, they show the ${f.defaultLang} text`);
301
+ // the bundle and dist/ hold what the pages show: the images of their texts, and the ones the engine's items show
302
+ const shownImages = new Set([...pages.flatMap(pg => langs.flatMap(l => [...(texts[pg.id]?.[l] ?? '').matchAll(/^@image\s+(\S+)\s*$/gm)].map(m => m[1]))),
303
+ ...(fin.images ?? []), ...(coverImage ? [coverImage] : [])]);
304
+ for (const id of Object.keys(images)) if (!shownImages.has(id)) delete images[id];
305
+ // --strict holds each original on this machine to the sha256 its image.yaml records, which names its objects
306
+ const shipped = Object.values(folderImages).filter(f => shownImages.has(f.id));
307
+ if (p.strict) for (const f of shipped) if (f.file && f.sha256 !== sha256(f.file)) fail(f.meta, `${path.basename(f.file)} changed after its sha256 was written, kitap rehash writes the new one`);
308
+ for (let i = 1; i < pages.length; i++) attempt(path.join(root, pages[i].dir), () => {
309
+ if (pages.find((q, j) => j < i && q.id === pages[i].id)) fail(where, `two pages share the id "${pages[i].id}"`);
310
+ const prev = pages.slice(0, i).reverse().find(q => q.when);
311
+ if (!prev || !pages[i].when || pages[i].when.from >= prev.when.from) return;
312
+ // an included item moves by its include folder, since the numbers in its own folder count in every story
313
+ const moved = pages[i].include !== prev.include ? pages[i].include ?? prev.include : null;
314
+ fail(where, `${pages[i].dir} starts ${pages[i].when.from}, before ${prev.dir} which starts ${prev.when.from}; ${moved ? `renumber ${moved} to move it` : 'fix the folder numbers or the when fields'}`);
315
+ });
316
+ return { story, langs, defaultLang, T, entries, translations: report, tree, images, icons, pages, texts, firstImages, warnings: [...warnings, ...(fin.warnings ?? [])], unlinked,
317
+ imageFolders: Object.values(folderImages), shippedImages: shipped, coverImage, engine: E, folders: fin.folders ?? [], state: E };
318
+ }
319
+
320
+ // The objects a story links to: its images with their keys and where their originals are, the engine's objects in
321
+ // their groups, and the hash tree of both, under the story's folder name.
322
+ function storyObjects(name, content, engine, ctx) {
323
+ const images = content.shippedImages.map(f => ({ id: f.id, dir: f.dir.split(path.sep).join('/'), keys: f.keys, file: f.file, url: f.url, sha256: f.sha256 }));
324
+ const own = engine.objects(content, ctx) ?? {};
325
+ const groups = own.groups ?? {};
326
+ return { images, groups, own, tree: storyTree(name, images, groups) };
327
+ }
328
+
329
+ function buildStory(p, storyDir, site, engine) {
330
+ const { log, root } = p;
331
+ const where = p.rel(storyDir);
332
+ const content = readContent(p, storyDir, engine);
333
+ const { story, langs, defaultLang, T, entries, translations, tree, images, icons, pages, texts, firstImages, warnings, imageFolders, shippedImages, coverImage, folders } = content;
334
+ for (const w of warnings) log(` warning: ${w}`);
335
+ const ui = uiFor(langs, root, engine);
336
+ const { tr: siteT } = translator({ langs, defaultLang: site.default_language ?? defaultLang, catalogues: loadCatalogues(path.join(root, 'i18n'), langs, p.loadYaml), where: 'i18n', fail });
337
+ if (story.theme && !site.themes[story.theme]) fail(where, `unknown theme "${story.theme}"`);
338
+ // the story is written to a fresh folder and swapped in once it built, so a failed build keeps the last good one
339
+ // and a page that went leaves no file behind
340
+ const final = path.join(p.dist, story.id), out = path.join(p.dist, `.${story.id}.next`);
341
+ fs.rmSync(out, { recursive: true, force: true });
342
+ fs.mkdirSync(out, { recursive: true });
343
+ // with --local-assets each original here is copied to dist/assets/<key>, once for every story that shows it
344
+ const localKeys = [];
345
+ if (p.local) for (const f of shippedImages.filter(f => f.file)) {
346
+ copyFresh(f.file, path.join(p.dist, 'assets', f.keys.original));
347
+ localKeys.push(f.keys.original);
348
+ }
349
+ const ectx = { root, where, log, fail, cache: p.cache, strict: !!p.strict, local: !!p.local, hash, readCache, writeCache, rel: p.rel, langs, defaultLang, T, objectLink: p.objectLink, options: p.options, site };
350
+ const objects = storyObjects(path.basename(storyDir), content, engine, ectx);
351
+
352
+ // --- the engine's bundle: what the viewport receives, the families it colours by and the credits it shows ---
353
+ const eb = engine.bundle(content, { ...ectx, objects }) ?? {};
354
+ for (const w of eb.warnings ?? []) log(` warning: ${w}`);
355
+ const families = eb.families ?? {};
356
+ const pageIndex = Object.fromEntries(pages.map((p, i) => [p.id, i]));
357
+ const navTree = nodes => nodes.map(n => n.type === 'page' ? { type: 'page', index: pageIndex[n.id] } : { type: 'group', id: n.id, title: n.title, children: navTree(n.children) });
358
+
359
+ // --- the cover on the site index, an image the story can show ---
360
+ const cover = coverImage ? images[coverImage].src : null;
361
+ const coverLinks = coverImage ? shippedImages.find(f => f.id === coverImage).links : null;
362
+ // the paths harita 0.4 served each image at, from the site root, to its original: dist/redirects.json
363
+ const redirects = {};
364
+ for (const f of shippedImages) {
365
+ const ext = path.extname(f.keys.original), to = /^[a-z]+:/i.test(f.links.original) ? f.links.original : '/' + f.links.original;
366
+ redirects[`/images/${f.id}${ext}`] = to; redirects[`/${story.id}/images/${f.id}${ext}`] = to;
367
+ }
368
+
369
+ // span is story.span, else the years from the pages' when fields, else the first page's date
370
+ const dated = pages.filter(p => p.when);
371
+ const years = dated.length ? [...new Set([dated[0].when.from.slice(0, 4), dated[dated.length - 1].when.to.slice(0, 4)])].join(' – ') : null;
372
+ const span = story.span ? T(story.span, 'story.span') : Object.fromEntries(langs.map(l => [l, years ?? pages[0].date[l]]));
373
+ const summary = story.summary ? T(story.summary, 'story.summary') : {};
374
+
375
+ // --- bundle and pages ---
376
+ const bundle = {
377
+ id: story.id, title: T(story.title, 'story.title'), summary, span, cover, languages: langs, defaultLanguage: defaultLang, ui, families, engine: engine.id,
378
+ site: { title: siteT(site.title, 'site.title'), languages: site.langs, defaultLanguage: site.defaultLang,
379
+ source: site.repository ? `${site.repository.replace(/\/$/, '')}/tree/${site.branch}/${where.split(path.sep).join('/')}` : null },
380
+ themes: themeList(site.themes, ui, langs), defaultTheme: story.theme ?? site.theme,
381
+ credits: eb.credits ?? [], images, icons, pages: pages.map(({ dir, include, ...p }) => p), tree: navTree(tree),
382
+ [engine.id]: eb.data ?? {},
383
+ };
384
+ const familyCss = Object.entries(families).map(([f, c]) => `--z-${f}:${c.color};`).join('');
385
+ const familyCssDark = Object.entries(families).map(([f, c]) => `--z-${f}:${c.color_dark ?? c.color};`).join('');
386
+ const shell = site.shell.replace('/*__FAMILY_CSS__*/', () => familyCss).replaceAll('/*__FAMILY_CSS_DARK__*/', () => familyCssDark)
387
+ .replace('/*__THEMES_CSS__*/', () => themeCss(site.themes, bundle.defaultTheme));
388
+ const storyJs = `const BUNDLE = ${JSON.stringify(bundle)};\n`;
389
+ writeFile(path.join(out, 'story.js'), storyJs);
390
+ const links = Object.fromEntries(shippedImages.map(f => [f.id, f.links]));
391
+ const files = storyFiles({ shell, bundle, texts, firstImages, links, coverImage, site: site.context, engine: engine.id, versions: { story: version(storyJs), app: version(APP_JS), engine: site.engineVersion } });
392
+ for (const [f, text] of Object.entries(files)) writeFile(path.join(out, f), text);
393
+ log(` wrote dist/${story.id}/ (story.js ${(Buffer.byteLength(storyJs) / 1e6).toFixed(1)} MB, ${pages.length} pages in ${langs.join('/')})`);
394
+
395
+ // translation coverage per catalogue and language, and with --strict a gap stops the build
396
+ const covered = (label, lang, total, missing, at, extra = '') => {
397
+ log(` ${label}${lang}: ${total - missing.length} of ${total} strings translated${missing.length ? `, ${missing.length} missing (kitap i18n ${lang})` : ''}${extra}`);
398
+ if (p.strict && missing.length) fail(at, `${lang}: ${missing.length} strings missing, first: ${missing.slice(0, 8).join(', ')}`);
399
+ };
400
+ for (const [lang, r] of Object.entries(translations())) covered('', lang, r.total, r.missing, where);
401
+ // an included item's own pages count their texts: a page without a text in the language is a gap too
402
+ for (const f of folders.filter(f => f.pages)) for (const [lang, r] of Object.entries(f.translator.report())) {
403
+ const noText = f.fallbacks?.[lang] ?? [];
404
+ covered(`${f.kind} ${f.id} `, lang, r.total, r.missing, f.dir, noText.length ? `, ${noText.length} pages without text/${lang}.md` : '');
405
+ if (p.strict && noText.length) fail(f.dir, `${lang}: no text/${lang}.md on ${noText.join(', ')}`);
406
+ }
407
+ // the image folders the story ships and the engine's folders whose names it shows count together, one line per kind
408
+ const kinds = [['image folders ', shippedImages], ...Object.entries(Object.groupBy(folders.filter(f => !f.pages && f.used !== false), f => f.kind)).map(([k, fs]) => [`${k} folders `, fs])];
409
+ for (const [label, items] of kinds) for (const lang of langs) {
410
+ const reports = items.map(f => [f, f.translator.report()[lang]]).filter(([, r]) => r);
411
+ if (reports.length) covered(label, lang, reports.reduce((n, [, r]) => n + r.total, 0), reports.flatMap(([f, r]) => r.missing.map(k => `${f.id} ${k}`)), where);
412
+ }
413
+ for (const lang of langs.filter(l => l !== 'en')) {
414
+ const u = uiCatalogue(root, lang, site.themes, engine);
415
+ if (u.missing.length) log(` ${lang} interface: ${u.entries.length - u.missing.length} of ${u.entries.length} strings translated, ${u.missing.length} missing (kitap i18n ${lang})`);
416
+ if (p.strict && u.missing.length) fail(where, `${lang} interface: ${u.missing.length} strings missing, first: ${u.missing.slice(0, 8).join(', ')}`);
417
+ }
418
+ fs.rmSync(final, { recursive: true, force: true });
419
+ fs.renameSync(out, final);
420
+ // kitap i18n writes the catalogues of each image folder and each folder of the engine's in that folder
421
+ const folderI18n = Object.fromEntries([...folders.map(f => [f.dir, f]), ...imageFolders.map(f => [f.dir, { kind: 'image', ...f }])]
422
+ .map(([dir, f]) => [dir, { kind: f.kind, id: f.id, defaultLang: f.defaultLang, langs, entries: [...f.translator.entries.values()] }]));
423
+ return { format: CARD_FORMAT, id: story.id, dir: where, title: bundle.title, summary, span, languages: langs, defaultLanguage: defaultLang, pages: pages.length,
424
+ cover: coverLinks?.large ?? null, coverOg: coverLinks?.og ?? null, i18n: [...entries.values()], objects, folderI18n, localKeys, redirects, tree: objects.tree };
425
+ }
426
+
427
+ // The names of the story folders below content/, in groups too, with each one's folder in p.storyDirs. content/shared/
428
+ // holds the images and the engine's items stories share.
429
+ function storyNames(p) {
430
+ if (exists(path.join(p.content, 'shared', 'story.yaml'))) fail('content/shared/story.yaml', 'content/shared/ holds what the stories share, move this story to a folder of another name');
431
+ try { p.storyDirs = storyFolders(p.content, p.rel); } catch (err) { fail('content/', err.message); }
432
+ // a story's folder in dist/ sits beside the ones kitap writes
433
+ for (const id of ['assets', 'vendor']) if (p.storyDirs.has(id)) fail(p.rel(p.storyDirs.get(id)), `kitap writes dist/${id}/, give the story another folder name`);
434
+ return [...p.storyDirs.keys()];
435
+ }
436
+
437
+ // The site index. site.yaml is optional: the folder name, alphabetical order and the cool theme stand in.
438
+ function buildIndex(p, cards, site, engine) {
439
+ const langs = site.langs, defaultLang = site.defaultLang;
440
+ const order = site.stories ?? cards.map(c => c.id);
441
+ for (const id of order) if (!cards.find(c => c.id === id)) fail('site.yaml', `unknown story "${id}"`);
442
+ const { tr: T, entries, report } = translator({ langs, defaultLang, catalogues: loadCatalogues(path.join(p.root, 'i18n'), langs, p.loadYaml), where: 'i18n', fail });
443
+ const ui = uiFor(langs, p.root, engine);
444
+ const data = {
445
+ title: T(site.title, 'site.title'), intro: site.intro ? T(site.intro, 'site.intro') : {},
446
+ languages: langs, defaultLanguage: defaultLang, ui, repository: site.repository ?? null, icons: { settings: iconSvg('settings', 'site.yaml'), languages: iconSvg('languages', 'site.yaml') },
447
+ stories: order.map(id => { const { dir, i18n, objects, folderI18n, localKeys, redirects, tree, coverOg, ...card } = cards.find(c => c.id === id); return card; }),
448
+ themes: themeList(site.themes, ui, langs), defaultTheme: site.theme,
449
+ };
450
+ for (const [lang, r] of Object.entries(report())) {
451
+ if (r.missing.length) p.log(`site ${lang}: ${r.missing.length} of ${r.total} strings missing (kitap i18n ${lang})`);
452
+ if (p.strict && r.missing.length) fail('site.yaml', `${lang}: missing ${r.missing.join(', ')}`);
453
+ }
454
+ p.siteEntries = [...entries.values()];
455
+ const css = themeCss(site.themes, site.theme);
456
+ // the 404 page speaks every language a site or story page does
457
+ const notFoundUi = uiFor([...new Set([...langs, ...cards.flatMap(c => c.languages)])], p.root, engine);
458
+ const image = order.map(id => cards.find(c => c.id === id).coverOg).find(Boolean) ?? null;
459
+ const files = siteFiles({ shell: INDEX.replace('/*__THEMES_CSS__*/', () => css), data, image, site: site.context, common: COMMON, themeCss: css, ui: notFoundUi });
460
+ for (const [f, text] of Object.entries(files)) writeFile(path.join(p.dist, f), text);
461
+ p.log(`wrote dist/index.html (${data.stories.length} stories)`);
462
+ }
463
+
464
+ // The engine of a build: the one handed in, else the one site.yaml names, resolved from the project.
465
+ function engineOf(p, site, engine) {
466
+ if (engine) return checkEngine(engine, 'engine');
467
+ if (!site.engine) fail('site.yaml', 'add engine: <id>, the package that draws the pages, such as engine: harita');
468
+ return loadEngine(p.root, site.engine);
469
+ }
470
+
471
+ // A story's content as the build reads it, for an engine's own commands. Returns the content with the engine's state.
472
+ export function readStory({ root = process.cwd(), story, engine = null, log = () => {} } = {}) {
473
+ const p = project(root, log), stories = storyNames(p);
474
+ if (!stories.includes(story)) fail('content/', `"${story}" is not a story folder, the stories are ${stories.join(', ')}`);
475
+ const e = engineOf(p, readSite(p), engine);
476
+ return readContent(p, p.storyDirs.get(story), e);
477
+ }
478
+
479
+ // Every problem in one story's content: the pages, their texts and the files they use, the engine's included. pages
480
+ // limits the check to those page ids and to the files outside any page. Returns the problems, [] when there are none.
481
+ // places lists, per text file, the names the page's items carry that the text writes without a link
482
+ export function checkStory({ root = process.cwd(), story, pages = [], places = false, log = console.log, engine = null } = {}) {
483
+ const p = project(root, log);
484
+ const stories = storyNames(p);
485
+ if (!stories.includes(story)) fail('content/', `"${story}" is not a story folder, the stories are ${stories.join(', ')}`);
486
+ const storyDir = p.storyDirs.get(story);
487
+ // a problem outside any page, in shared/ or an included item's own files, is always in scope
488
+ const inScope = pageId => !pages.length || pageId == null || pages.includes(pageId);
489
+ const problems = [];
490
+ try {
491
+ const e = engineOf(p, readSite(p), engine);
492
+ const { tree, unlinked } = readContent(p, storyDir, e, (dir, msg, pageId) => { if (inScope(pageId)) problems.push(msg); });
493
+ if (places) for (const u of unlinked) if (inScope(u.pageId)) log(`${u.file}: ${u.names.join(', ')}`);
494
+ const ids = new Set(), collect = nodes => { for (const n of nodes) n.type === 'page' ? ids.add(n.id) : collect(n.children); };
495
+ collect(tree);
496
+ for (const id of pages) if (!ids.has(id)) problems.push(`${p.rel(storyDir)}/pages: no page "${id}"`);
497
+ } catch (err) { problems.push(err.message); } // story.yaml itself or an empty pages/ stops the reading
498
+ return problems;
499
+ }
500
+
501
+ // The image files git tracks under content/, which belong in the asset store. A folder outside git has none.
502
+ function trackedImages(root) {
503
+ try { return execFileSync('git', ['ls-files', '-z', '--', 'content'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0').filter(f => IMAGE_FILE.test(f)); }
504
+ catch { return []; }
505
+ }
506
+
507
+ // site.yaml with the built-in themes and the defaults that stand in for what it leaves out
508
+ function readSite(p) {
509
+ const siteFile = path.join(p.root, 'site.yaml');
510
+ const siteMeta = exists(siteFile) ? p.readYaml(siteFile, Site) : {};
511
+ const builtIn = check(Themes, p.loadYaml(path.join(PKG, 'src', 'themes.yaml')), 'src/themes.yaml');
512
+ return { ...siteMeta, title: siteMeta.title ?? path.basename(p.root), theme: siteMeta.theme ?? 'cool', themes: { ...builtIn, ...(siteMeta.themes ?? {}) } };
513
+ }
514
+
515
+ // The objects each story links to and its hash tree, read as the build reads the stories, with the store of
516
+ // site.yaml. story limits it to that story folder. kitap upload puts these objects in the store.
517
+ export function storyAssets({ root = process.cwd(), story = null, log = console.log, engine = null, cache = path.join(root, '.cache', 'kitap') } = {}) {
518
+ const p = project(root, log), stories = storyNames(p);
519
+ if (!stories.length) fail('content/', 'no story.yaml found, run this from a content project');
520
+ if (story && !stories.includes(story)) fail('content/', `"${story}" is not a story folder, the stories are ${stories.join(', ')}`);
521
+ const site = readSite(p), e = engineOf(p, site, engine);
522
+ if (!site.assets) fail('site.yaml', 'add an assets block with the url, bucket and endpoint of the store for the images and the objects the pages show');
523
+ p.cache = cache;
524
+ const ectx = { root, log, fail, cache, hash, readCache, writeCache, rel: p.rel, options: p.options };
525
+ return { assets: site.assets, engine: e, stories: (story ? [story] : stories).map(s => ({ story: s, ...storyObjects(s, readContent(p, p.storyDirs.get(s), e), e, { ...ectx, where: s }) })) };
526
+ }
527
+
528
+ // The client script of the engine, written beside kitap.js as dist/<engine>.js: its script file, else nothing
529
+ function engineScript(engine) {
530
+ const c = engine.client ?? {};
531
+ if (!c.script) return '';
532
+ const file = c.script instanceof URL ? fileURLToPath(c.script) : c.script;
533
+ return fs.readFileSync(file, 'utf8');
534
+ }
535
+
536
+ // Every story under root/content into root/dist, without the engine's objects. kitap i18n reads its strings.
537
+ // only names story folders to build; the others keep their last build, and their main page cards come from the cache.
538
+ // story builds that one story folder into a site of its own, its main page listing it alone. out replaces dist/.
539
+ // Pages link the images and objects in the store of site.yaml's assets, or with localAssets copies in dist/assets/.
540
+ export function buildPages({ root = process.cwd(), log = console.log, strict = false, cache = path.join(root, '.cache', 'kitap'), only = null, story = null, out = null, localAssets = false, engine = null, ...options } = {}) {
541
+ const p = project(root, log);
542
+ p.strict = strict; p.cache = cache; p.local = localAssets; p.options = options;
543
+ if (out) p.dist = path.resolve(root, out);
544
+ const stories = storyNames(p);
545
+ if (!stories.length) fail('content/', 'no story.yaml found, run this from a content project');
546
+ for (const s of [...(only ?? []), ...(story ? [story] : [])]) if (!stories.includes(s)) fail('content/', `"${s}" is not a story folder, the stories are ${stories.join(', ')}`);
547
+ if (strict) {
548
+ const tracked = trackedImages(root);
549
+ if (tracked.length) fail('content/', `git tracks ${tracked.length} image file${tracked.length === 1 ? '' : 's'}, which belong in the asset store: ${tracked.slice(0, 5).join(', ')}${tracked.length > 5 ? ', …' : ''}. Add the .gitignore lines for image files and git rm --cached them`);
550
+ }
551
+ const cardFile = s => path.join(cache, 'stories', s, 'card.json');
552
+ // a card from the last build of the same assets mode, while that build's page is still in dist/
553
+ const lastCard = s => { const c = readCache(cardFile(s)); return c?.format === CARD_FORMAT && c.local === localAssets && exists(path.join(p.dist, c.id, 'index.html')) ? c : null; };
554
+ const site = readSite(p);
555
+ if (!site.themes[site.theme]) fail('site.yaml', `unknown theme "${site.theme}"`);
556
+ const e = engineOf(p, site, engine);
557
+ const store = site.assets?.url ?? null;
558
+ if (!store && !localAssets) fail('site.yaml', 'add an assets block with the url, bucket and endpoint of the store for the images and the objects the pages show, or build with --local-assets to show the files on this machine');
559
+ // an image links its objects in the store, or with --local-assets its original: the copy in dist/assets/, else its
560
+ // url, else the one in the store
561
+ p.imageLinks = ({ keys, file, url, at }) => {
562
+ if (!localAssets) return { original: store + keys.original, large: store + keys.large, small: store + keys.small };
563
+ const one = file ? `assets/${keys.original}` : url ?? (store ? store + keys.original
564
+ : fail(at, 'the original is not on this machine and image.yaml has no url, add the original to the folder or give it a url'));
565
+ return { original: one, large: one, small: one };
566
+ };
567
+ // an engine's object, in the store or with --local-assets below dist/assets/, as a story page links it
568
+ p.objectLink = key => store && !localAssets ? store + key : `../assets/${key}`;
569
+ // story ids and language codes both name folders at the top of dist/
570
+ // building one story, another story's story.yaml that does not read yet is left out of the checks
571
+ const storyFile = s => path.join(p.storyDirs.get(s), 'story.yaml'), meta = s => [s, check(storySchema(e), lift(p.loadYaml(storyFile(s)), e, e.schemas.story), p.rel(storyFile(s)))];
572
+ const metas = stories.flatMap(s => { if (!story || s === story) return [meta(s)]; try { return [meta(s)]; } catch { return []; } });
573
+ site.langs = site.languages ?? [...new Set(metas.flatMap(([, m]) => m.languages))];
574
+ site.defaultLang = site.default_language ?? site.langs[0];
575
+ const codes = new Set([...site.langs, ...metas.flatMap(([, m]) => m.languages)]);
576
+ for (const [s, m] of metas) if (codes.has(m.id)) fail(p.rel(storyFile(s)), `the story id "${m.id}" is also a language code, and both would be the folder dist/${m.id}/, so give the story another id`);
577
+ // dist/assets/ holds the images and objects of a --local-assets build
578
+ for (const [s, m] of metas) if (m.id === 'assets') fail(p.rel(storyFile(s)), `the story id "${m.id}" names the folder dist/${m.id}/ that holds the images and objects of a --local-assets build, so give the story another id`);
579
+ site.context = siteContext({ url: site.url, languages: site.langs, defaultLanguage: site.defaultLang });
580
+ if (story) site.stories = [metas.find(([s]) => s === story)[1].id];
581
+ // the engine's checks across the stories, such as drawings that copy one another
582
+ e.site({ root, cache, strict, log, stories: story ? [story] : only, fail, rel: p.rel, storyDirs: p.storyDirs, options });
583
+ // the scripts: the engine's libraries from dist/vendor/, its own script and kitap's
584
+ const { paths: vendorPaths, imports } = writeVendor(p.dist, e);
585
+ const engineJs = engineScript(e);
586
+ site.engineVersion = version(engineJs);
587
+ // a module engine imports its libraries through an import map of the vendored copies, from the story folder
588
+ const importMap = Object.keys(imports).length ? `<script type="importmap">${JSON.stringify({ imports: Object.fromEntries(Object.entries(imports).map(([k, v]) => [k, `../${v}`])) })}</script>` : '';
589
+ const tags = [importMap, ...vendorPaths.map(f => `<script src="../${f}"></script>`), ...(e.client?.styles ? [`<link rel="stylesheet" href="../${e.id}.css">`] : [])].filter(Boolean);
590
+ const engineTag = engineJs ? `<script${e.client?.module ? ' type="module"' : ''} src="../${e.id}.js"></script>\n` : '';
591
+ site.shell = APP.replace('<!--__SCRIPTS__-->', tags.join('\n')).replace('<script src="../kitap.js"></script>', `${engineTag}<script src="../kitap.js"></script>`);
592
+ writeFile(path.join(p.dist, 'kitap.js'), APP_JS);
593
+ if (engineJs) writeFile(path.join(p.dist, `${e.id}.js`), engineJs);
594
+ if (e.client?.styles) writeFile(path.join(p.dist, `${e.id}.css`), fs.readFileSync(e.client.styles instanceof URL ? fileURLToPath(e.client.styles) : e.client.styles, 'utf8'));
595
+ const cards = [];
596
+ for (const s of story ? [story] : stories) {
597
+ const kept = only && !only.includes(s) && lastCard(s);
598
+ if (kept) { cards.push(kept); continue; }
599
+ log(`story ${s}`);
600
+ const card = buildStory(p, p.storyDirs.get(s), site, e);
601
+ const { i18n, objects, folderI18n, tree, ...saved } = card;
602
+ writeCache(cardFile(s), { ...saved, local: localAssets });
603
+ cards.push(card);
604
+ }
605
+ buildIndex(p, cards, site, e);
606
+ // the Worker and kitap dev redirect the image paths of harita 0.4 to the originals, see routes.mjs
607
+ const redirects = Object.fromEntries(cards.flatMap(c => Object.entries(c.redirects ?? {})).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
608
+ writeFile(path.join(p.dist, 'redirects.json'), JSON.stringify(redirects, null, 1) + '\n');
609
+ // dist/ holds no objects of the store, and dist/images/ and dist/terrain/ of harita 0.4 go unless a story is named so
610
+ const taken = new Set(metas.map(([, m]) => m.id));
611
+ for (const d of [...(localAssets ? [] : ['assets']), 'images', 'terrain']) if (!taken.has(d)) fs.rmSync(path.join(p.dist, d), { recursive: true, force: true });
612
+ // dist/assets/images/ keeps the originals a story in dist/ shows: the built ones, and the others from their cached cards
613
+ const others = story ? stories.filter(s => s !== story).map(lastCard).filter(Boolean) : [];
614
+ const imagesDir = path.join(p.dist, 'assets', 'images'), shown = new Set([...cards, ...others].flatMap(c => c.localKeys ?? []));
615
+ for (const f of localAssets && exists(imagesDir) ? fs.readdirSync(imagesDir, { recursive: true }).sort().reverse() : []) {
616
+ const full = path.join(imagesDir, f);
617
+ if (fs.statSync(full).isDirectory()) { if (!fs.readdirSync(full).length) fs.rmdirSync(full); }
618
+ else if (!shown.has(`images/${f.split(path.sep).join('/')}`)) fs.rmSync(full);
619
+ }
620
+ const built = cards.filter(c => c.objects);
621
+ // a folder several stories use has one catalogue: its keys from every story, and the languages of all of them
622
+ const merged = {};
623
+ for (const c of built) for (const [dir, b] of Object.entries(c.folderI18n)) {
624
+ const into = merged[dir] ??= { kind: b.kind, id: b.id, defaultLang: b.defaultLang, langs: new Set(), entries: new Map() };
625
+ for (const l of b.langs) into.langs.add(l);
626
+ for (const e of b.entries) if (!into.entries.has(e.key)) into.entries.set(e.key, e);
627
+ }
628
+ const folders = Object.fromEntries(Object.entries(merged).map(([dir, f]) => [dir, { ...f, langs: [...f.langs], entries: [...f.entries.values()] }]));
629
+ return { dist: p.dist, themes: site.themes, engine: e, stories: cards.map(({ i18n, objects, folderI18n, localKeys, redirects, tree, coverOg, local, ...card }) => card), i18n: { stories: Object.fromEntries(built.map(c => [c.id, { dir: c.dir, entries: c.i18n }])), site: p.siteEntries, folders },
630
+ objects: built.map(c => ({ story: c.id, dir: c.dir, ...c.objects })), trees: built.map(c => ({ ...c.tree, dir: c.dir })), assets: site.assets ?? null };
631
+ }
632
+
633
+ // Build every story under root/content into root/dist. Returns the dist folder and one card per story. Pages link
634
+ // the images and objects in the asset store, and with strict the store must hold each of them. With localAssets the
635
+ // originals here and the engine's objects are made into dist/assets/. The engine keeps its own caches under cache.
636
+ export async function build({ root = process.cwd(), log = console.log, strict = false, cache = path.join(root, '.cache', 'kitap'), only = null, story = null, out = null, localAssets = false, engine = null, ...options } = {}) {
637
+ const { objects, trees, assets, engine: e, ...r } = buildPages({ root, log, strict, cache, only, story, out, localAssets, engine, ...options });
638
+ if (localAssets && e.make) await e.make({ stories: objects, cache, out: path.join(r.dist, 'assets'), log, options });
639
+ else if (strict) await checkStore({ url: assets.url, trees, log, describe: e.describeObjects });
640
+ return r;
641
+ }