@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/LICENSE +21 -0
- package/README.md +103 -2
- package/bin/kitap.mjs +5 -0
- package/package.json +55 -4
- package/src/app.html +365 -0
- package/src/app.js +254 -0
- package/src/build.mjs +641 -0
- package/src/cli.mjs +41 -0
- package/src/common.js +62 -0
- package/src/deploy.mjs +52 -0
- package/src/dev.mjs +88 -0
- package/src/engine.mjs +33 -0
- package/src/folders.mjs +30 -0
- package/src/i18n/en.yaml +43 -0
- package/src/i18n/tr.yaml +43 -0
- package/src/i18n.mjs +67 -0
- package/src/image.mjs +214 -0
- package/src/index.html +183 -0
- package/src/index.mjs +15 -0
- package/src/pages.mjs +271 -0
- package/src/routes.mjs +23 -0
- package/src/schema.mjs +78 -0
- package/src/store.mjs +153 -0
- package/src/themes.yaml +27 -0
- package/src/translate.mjs +41 -0
- package/src/upload.mjs +114 -0
- package/src/util.mjs +19 -0
- package/src/vendor.mjs +67 -0
- package/src/worker.mjs +47 -0
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
|
+
}
|