@openbooknetwork/kitap 0.0.0-stage → 0.3.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/cli.mjs ADDED
@@ -0,0 +1,41 @@
1
+ // The command line of kitap and of every engine: the book's commands with the options they take. An engine's binary
2
+ // calls run with its engine and its own commands, which are tried first.
3
+ import { parseArgs } from 'util';
4
+ import { build, check, dev, i18n, image, rehash, upload, deploy } from './index.mjs';
5
+
6
+ export const USAGE = (name = 'kitap', extra = '') => `usage: ${name} build [--strict] [--local-assets] [--only <story> ...] [--story <id>] [--out <dir>] | ${name} check <story> [page id ...] [--places] | ${name} dev [--story <id>] [--out <dir>] [--port <n>] | ${name} i18n <lang> [--story id] | ${name} image <file or url> <name> --caption <text> [--credit <text>] [--source <url>] [--story <id>] | ${name} rehash | ${name} upload [--story <id>] [--dry-run] | ${name} deploy [--dry-run]${extra}`;
7
+
8
+ // argv are the arguments after the command name. engine is the engine module to build with, else site.yaml names it.
9
+ // own(cmd, positionals, options) runs an engine's own command and returns true when it took it. options adds flags.
10
+ export async function run(argv, { name = 'kitap', engine = null, enginePath = null, own = null, options = {}, usage = '' } = {}) {
11
+ let args;
12
+ try {
13
+ args = parseArgs({ args: argv, allowPositionals: true, options: { strict: { type: 'boolean' }, 'local-assets': { type: 'boolean' }, 'dry-run': { type: 'boolean' }, places: { type: 'boolean' }, story: { type: 'string' }, caption: { type: 'string' }, credit: { type: 'string' }, source: { type: 'string' }, only: { type: 'string', multiple: true }, out: { type: 'string' }, port: { type: 'string' }, engine: { type: 'string' }, ...options } });
14
+ } catch (err) { // an unknown or incomplete flag
15
+ console.error(`${err.message}\n${USAGE(name, usage)}`);
16
+ process.exit(2);
17
+ }
18
+ const { positionals: [cmd, lang, ...rest], values: opt } = args;
19
+ // --engine names the engine module's path, as kitap dev hands it to each build; an engine's binary hands its own
20
+ const e = engine ?? (opt.engine ? (await import('./engine.mjs')).loadEngine(process.cwd(), opt.engine) : null);
21
+ try {
22
+ if (own && await own(cmd, [lang, ...rest].filter(x => x !== undefined), opt)) return;
23
+ if (cmd === 'build') await build({ strict: opt.strict, localAssets: opt['local-assets'], only: opt.only, story: opt.story, out: opt.out, engine: e });
24
+ else if (cmd === 'check' && lang) {
25
+ const problems = check({ story: lang, pages: rest, places: opt.places, engine: e });
26
+ for (const m of problems) console.log(m);
27
+ console.log(problems.length ? `${problems.length} problem${problems.length === 1 ? '' : 's'}` : 'ok');
28
+ process.exitCode = problems.length ? 1 : 0;
29
+ }
30
+ else if (cmd === 'i18n' && lang) i18n({ lang, story: opt.story, engine: e });
31
+ else if (cmd === 'image' && lang) await image({ file: lang, name: rest[0], caption: opt.caption, credit: opt.credit, source: opt.source, story: opt.story });
32
+ else if (cmd === 'rehash') rehash();
33
+ else if (cmd === 'upload') await upload({ story: opt.story, dryRun: opt['dry-run'], engine: e });
34
+ else if (cmd === 'deploy') await deploy({ dryRun: opt['dry-run'] });
35
+ else if (cmd === 'dev') dev({ story: opt.story, out: opt.out, engine: enginePath ?? opt.engine ?? null, ...(opt.port ? { port: Number(opt.port) } : {}) });
36
+ else { console.error(USAGE(name, usage)); process.exit(2); }
37
+ } catch (err) { // content errors are expected, print them without a stack trace
38
+ console.error(`error: ${err.message}`);
39
+ process.exit(1);
40
+ }
41
+ }
package/src/common.js ADDED
@@ -0,0 +1,62 @@
1
+ // Helpers shared by the story page and the site index. The build inlines this file into both.
2
+ const $ = id => document.getElementById(id);
3
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
4
+
5
+ // Per browser preferences. Storage can be missing or blocked, so every access is guarded.
6
+ const pref = (key, allowed, fallback) => { try { const v = localStorage.getItem(key); return allowed.includes(v) ? v : fallback; } catch { return fallback; } };
7
+ const savePref = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
8
+ // the saved language, else the first browser language on offer, matched whole or by its base such as pt for pt-BR
9
+ const preferredLang = (langs, fallback) => {
10
+ const saved = pref('lang', langs, null); if (saved) return saved;
11
+ for (const l of navigator.languages ?? []) { const hit = langs.find(x => x.toLowerCase() === l.toLowerCase()) ?? langs.find(x => x === l.split('-')[0]); if (hit) return hit; }
12
+ return fallback;
13
+ };
14
+
15
+ // A translatable field for the current language, falling back to the default language.
16
+ const pick = (field, lang, defaultLang, fallback = '') => field?.[lang] ?? field?.[defaultLang] ?? fallback;
17
+
18
+ function fillSelect(sel, items, current, label) {
19
+ sel.setAttribute('aria-label', label); sel.innerHTML = '';
20
+ for (const { value, text } of items) { const o = document.createElement('option'); o.value = value; o.textContent = text; o.selected = value === current; sel.appendChild(o); }
21
+ }
22
+
23
+ // a web server resolves a folder to its index.html, a file:// URL shows a listing instead
24
+ const isFile = location.protocol === 'file:';
25
+ const folder = (...parts) => parts.join('/') + '/' + (isFile ? 'index.html' : '');
26
+ // links from the site root, where a main page's <base> points, and from a story folder, where a story page's points
27
+ const storyLink = (id, lang) => folder(id, lang);
28
+ const siteLink = lang => folder('..', lang);
29
+ // The route is the path under the folder the page's <base> names, such as en/cannae on a story page. pushState
30
+ // refuses another path on file://, so there the address stays on the page first opened.
31
+ const pathParts = () => {
32
+ const base = new URL(document.baseURI).pathname, p = decodeURIComponent(location.pathname);
33
+ return p.startsWith(base) ? p.slice(base.length).split('/').filter(s => s && s !== 'index.html') : [];
34
+ };
35
+ function setRoute(parts, push) {
36
+ if (isFile) return;
37
+ const url = new URL(parts.map(encodeURIComponent).join('/') + '/', document.baseURI).href;
38
+ if (url !== location.href) (push ? history.pushState : history.replaceState).call(history, null, '', url);
39
+ }
40
+
41
+ // The footer note on every page; the build fills in the repository URL from package.json
42
+ const REPO = '/*__REPO__*/';
43
+ const credit = ui => ui.generated_by.replace('{kitap}', `<a href="${REPO}" target="_blank" rel="noopener">kitap</a>`);
44
+
45
+ const applyPalette = id => { document.documentElement.dataset.palette = id; savePref('palette', id); };
46
+
47
+ // The story page layouts on wide screens: the picture in the middle, or the contents and the text together at one side.
48
+ // Every page offers the choice in its settings, and story pages apply it.
49
+ const LAYOUTS = ['center', 'left', 'right'];
50
+ // a small drawing of a layout: the map filled, the chronology and the text as outlines, in their order on the screen
51
+ function layoutIcon(l) {
52
+ const cols = { center: ['nav', 'map', 'text'], left: ['nav', 'text', 'map'], right: ['map', 'text', 'nav'] }[l], w = { nav: 12, text: 16, map: 28 };
53
+ let x = 1;
54
+ const parts = cols.map(c => { const r = c === 'map' ? `<rect x="${x}" y="1" width="${w[c]}" height="30" rx="1.5" fill="currentColor" opacity=".55"/>` : `<rect x="${x + .5}" y="1.5" width="${w[c] - 1}" height="29" rx="1.5" fill="none" stroke="currentColor"/>`; x += w[c] + 2; return r; });
55
+ return `<svg viewBox="0 0 61 32" aria-hidden="true">${parts.join('')}</svg>`;
56
+ }
57
+ const layoutButtons = (ui, current) => LAYOUTS.map(l => `<button type="button" role="radio" data-layout="${l}" aria-checked="${l === current}">${layoutIcon(l)}<span>${esc(ui['layout_' + l])}</span></button>`).join('');
58
+ function saveLayout(l) {
59
+ savePref('layout', l); document.documentElement.dataset.layout = l;
60
+ $('layouts').querySelectorAll('button').forEach(b => b.setAttribute('aria-checked', b.dataset.layout === l));
61
+ }
62
+ const toggleLabel = (ui, allOpen) => allOpen ? ui.collapse_all : ui.expand_all;
package/src/deploy.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // kitap deploy: puts dist/ in the pages bucket the Worker serves, see worker.mjs. The files the bucket lacks or holds
2
+ // in another version go up, scripts and stylesheets first, and the keys dist/ no longer holds go.
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
6
+ import yaml from 'js-yaml';
7
+ import { check, Site } from './schema.mjs';
8
+ import { storeWriter, pool } from './store.mjs';
9
+ import { typeOf } from './routes.mjs';
10
+ import { mb, plural, progressLine } from './util.mjs';
11
+
12
+ const CACHE = 'public, max-age=600';
13
+
14
+ // Puts root/dist in site.yaml's pages.bucket, in the account of assets.endpoint, and deletes the keys
15
+ // dist/ lacks. dryRun lists what would go and change and writes nothing. Returns the keys uploaded and deleted, and
16
+ // how many were in the bucket as they are.
17
+ export async function deploy({ root = process.cwd(), dryRun = false, log = console.log, progress = progressLine, wait } = {}) {
18
+ const siteFile = path.join(root, 'site.yaml');
19
+ const site = fs.existsSync(siteFile) ? check(Site, yaml.load(fs.readFileSync(siteFile, 'utf8')), 'site.yaml') : {};
20
+ if (!site.pages) throw new Error('site.yaml: add a pages block with the bucket that holds the site, kitap deploy writes dist/ there');
21
+ if (!site.assets) throw new Error('site.yaml: pages needs the assets block, whose endpoint is the account that holds the pages bucket');
22
+ const dist = path.join(root, 'dist');
23
+ if (!fs.existsSync(path.join(dist, 'index.html'))) throw new Error('dist/index.html is missing, kitap build writes the site to deploy');
24
+ const writer = storeWriter({ endpoint: site.assets.endpoint, bucket: site.pages.bucket }, { command: 'kitap deploy', wait });
25
+ // a dot starts the folder a build writes a story to before it swaps it in, and the files editors and Finder leave
26
+ const files = fs.readdirSync(dist, { recursive: true }).map(f => f.split(path.sep).join('/'))
27
+ .filter(f => !f.split('/').some(s => s.startsWith('.')) && fs.statSync(path.join(dist, f)).isFile()).sort();
28
+ const held = new Map((await writer.list()).map(o => [o.key, o.etag]));
29
+ const md5 = f => crypto.createHash('md5').update(fs.readFileSync(path.join(dist, f))).digest('hex');
30
+ const changed = files.filter(f => held.get(f) !== md5(f)), gone = [...held.keys()].filter(k => !files.includes(k)).sort();
31
+ const kept = files.length - changed.length, bytes = changed.reduce((n, f) => n + fs.statSync(path.join(dist, f)).size, 0);
32
+ if (dryRun) {
33
+ for (const f of changed) log(`upload ${f}`);
34
+ for (const k of gone) log(`delete ${k}`);
35
+ log(`would upload ${plural(changed.length, 'file')} (${mb(bytes)}), delete ${gone.length.toLocaleString('en')}, ${kept.toLocaleString('en')} are in the bucket`);
36
+ return { uploaded: changed, deleted: gone, kept };
37
+ }
38
+ // a page links its scripts, fonts and glyphs by version, so they go first and a new page finds them in the bucket
39
+ const script = f => /\.(js|css)$/.test(f) || f.startsWith('vendor/');
40
+ const cur = { what: 'dist', n: 0, total: changed.length, bytes: 0 };
41
+ const put = async f => {
42
+ const body = fs.readFileSync(path.join(dist, f));
43
+ await writer.put(f, body, typeOf(f), CACHE);
44
+ cur.n++; cur.bytes += body.length;
45
+ progress({ ...cur, done: false });
46
+ };
47
+ for (const batch of [changed.filter(script), changed.filter(f => !script(f))]) await pool(batch, 8, put);
48
+ progress({ ...cur, done: true });
49
+ await pool(gone, 8, k => writer.delete(k));
50
+ log(`uploaded ${plural(changed.length, 'file')} (${mb(bytes)}), deleted ${gone.length.toLocaleString('en')}, ${kept.toLocaleString('en')} were in the bucket`);
51
+ return { uploaded: changed, deleted: gone, kept };
52
+ }
package/src/dev.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // Watch content and src; rebuild on change; serve dist/ with a reload hook for open tabs.
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import http from 'http';
5
+ import { spawn } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import { resolve, typeOf } from './routes.mjs';
8
+
9
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const RELOAD = `<script>(()=>{let v=null;setInterval(async()=>{try{const r=await fetch('/__version');const t=await r.text();if(v===null)v=t;else if(t!==v)location.reload();}catch{}},1000)})()</script>`;
11
+
12
+ // Serve root/dist on the port and rebuild on changes: a change in one story's folder rebuilds that story and the main
13
+ // page, any other change every story. Returns the http server. Builds run in a child process, so package edits apply,
14
+ // and with --local-assets, so the images on this machine show before they are uploaded.
15
+ // story serves that one story as a site of its own. Every build writes into out, by default dist/, which the server serves.
16
+ export function dev({ root = process.cwd(), port = Number(process.env.PORT) || 8080, log = console.log, story = null, out = null, engine = null } = {}) {
17
+ const DIST = out ? path.resolve(root, out) : path.join(root, 'dist'), CONTENT = path.join(root, 'content');
18
+ // engine is the engine module's path, handed to each build and watched beside kitap's own source
19
+ const WATCH = [CONTENT, path.join(root, 'i18n'), path.join(root, 'plugins'), path.join(PKG, 'src'), ...(engine ? [path.join(path.dirname(engine), '..', 'src')] : [])].filter(fs.existsSync);
20
+
21
+ // what the next build covers: every story, or the story folders changed since the last build
22
+ let version = 0, building = false, queued = false, all = true, changed = new Set();
23
+ function build() {
24
+ if (building) { queued = true; return; }
25
+ building = true;
26
+ const t0 = Date.now(), wasAll = all, stories = [...changed];
27
+ all = false; changed = new Set();
28
+ const scope = [...(story ? ['--story', story] : wasAll ? [] : stories.flatMap(s => ['--only', s])), ...(out ? ['--out', out] : [])];
29
+ const p = spawn(process.execPath, [path.join(PKG, 'bin', 'kitap.mjs'), 'build', '--local-assets', ...(engine ? ['--engine', engine] : []), ...scope], { stdio: 'inherit', cwd: root });
30
+ p.on('exit', code => {
31
+ building = false;
32
+ if (code === 0) { version = Date.now(); log(`build ok in ${Date.now() - t0} ms, tabs reload`); }
33
+ else {
34
+ // the next build retries what this one covered, so a story saved alongside the broken one is not left behind
35
+ all ||= wasAll; for (const s of stories) changed.add(s);
36
+ log('build failed, fix the error above and save again');
37
+ }
38
+ if (queued) { queued = false; build(); }
39
+ });
40
+ }
41
+
42
+ // the story folder a file in content/ sits in, below any groups, named by the folder as the build names it
43
+ const storyOf = file => {
44
+ const parts = file.split(path.sep);
45
+ for (let i = 1; i < parts.length; i++) if (fs.existsSync(path.join(CONTENT, ...parts.slice(0, i), 'story.yaml'))) return parts[i - 1];
46
+ return null;
47
+ };
48
+ let timer = null;
49
+ const onChange = (dir, file) => {
50
+ if (file && /(^|\/)\./.test(file)) return; // editor swap files and the like
51
+ const story = dir === CONTENT && file ? storyOf(file) : null;
52
+ if (story) changed.add(story); else all = true;
53
+ clearTimeout(timer); timer = setTimeout(build, 150);
54
+ };
55
+ const watchers = WATCH.map(dir => fs.watch(dir, { recursive: true }, (_, file) => onChange(dir, file)));
56
+ // site.yaml sits beside dist/ and node_modules/, so the project folder is watched on its own level, for that file
57
+ watchers.push(fs.watch(root, (_, file) => { if (file === 'site.yaml') onChange(root, file); }));
58
+
59
+ const server = http.createServer(async (req, res) => {
60
+ const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
61
+ let url = req.url.slice(0, req.url.length - query.length);
62
+ try { url = decodeURIComponent(url); } catch {}
63
+ if (url === '/__version') { res.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }); return res.end(String(version)); }
64
+ if (url === '/' && !fs.existsSync(path.join(DIST, 'index.html'))) {
65
+ const stories = fs.existsSync(DIST) ? fs.readdirSync(DIST).filter(d => fs.existsSync(path.join(DIST, d, 'index.html'))) : [];
66
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
67
+ return res.end(`<!doctype html><meta charset="utf-8"><title>Stories</title><h1>Stories</h1><ul>${stories.map(s => `<li><a href="/${s}/">${s}</a></li>`).join('')}</ul>${RELOAD}`);
68
+ }
69
+ // the same answers as the Worker on the site: index pages, the slash redirect, redirects.json and 404.html
70
+ const fileOf = key => path.join(DIST, key);
71
+ const exists = key => { const f = fileOf(key); return f.startsWith(DIST + path.sep) && fs.existsSync(f) && fs.statSync(f).isFile(); };
72
+ const redirect = p => { const f = fileOf('redirects.json'); return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8'))[p] ?? null : null; };
73
+ const r = await resolve(url, exists, { redirect, query });
74
+ if (r.location) { res.writeHead(301, { location: r.location }); return res.end(); }
75
+ const file = fileOf(r.key), status = r.status;
76
+ if (!fs.existsSync(file)) { res.writeHead(404); return res.end('not found'); }
77
+ const ext = path.extname(file);
78
+ res.writeHead(status, { 'content-type': typeOf(file), 'cache-control': 'no-store' });
79
+ if (ext === '.html') return res.end(fs.readFileSync(file, 'utf8').replace('</body>', RELOAD + '</body>'));
80
+ fs.createReadStream(file).pipe(res);
81
+ });
82
+ server.listen(port, () => {
83
+ log(`serving ${path.relative(root, DIST)}/ at http://localhost:${server.address().port}/ and watching ${[...WATCH.map(d => path.basename(d)), 'site.yaml'].join(', ')}`);
84
+ build();
85
+ });
86
+ server.on('close', () => { for (const w of watchers) w.close(); clearTimeout(timer); });
87
+ return server;
88
+ }
package/src/engine.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // The engine that fills the viewport: a package or a plugin that exports the engine module as its default. site.yaml
2
+ // names it, a build may hand one in, and a command of the engine's own hands in its own. Node 22.12+ loads an ES
3
+ // module through require synchronously, which keeps the build synchronous.
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { createRequire } from 'module';
7
+
8
+ // Resolves an engine to its module: a path to a module file, else an id looked up in the project's packages, under
9
+ // the organisation's scope or bare, then in plugins/engines/<id>.mjs of the project. Fails naming both places.
10
+ export function loadEngine(root, id) {
11
+ const require = createRequire(path.join(root, 'package.json'));
12
+ if (id.endsWith('.mjs') || id.endsWith('.js')) return checkEngine(require(path.resolve(root, id)).default, id);
13
+ for (const name of [`@openbooknetwork/${id}`, `@openhistoryatlas/${id}`, id]) {
14
+ let mod;
15
+ try { mod = require(name); } catch (err) { if (err.code === 'MODULE_NOT_FOUND' && String(err.message).includes(name)) continue; throw err; }
16
+ return checkEngine(mod.default ?? mod.engine, name);
17
+ }
18
+ const plugin = path.join(root, 'plugins', 'engines', `${id}.mjs`);
19
+ if (fs.existsSync(plugin)) return checkEngine(require(plugin).default, plugin);
20
+ throw new Error(`site.yaml: engine "${id}" is not installed: npm install @openbooknetwork/${id}, or add plugins/engines/${id}.mjs`);
21
+ }
22
+
23
+ // An engine module has an id and the hooks the build calls; what it leaves out is filled with no-ops.
24
+ export function checkEngine(e, from) {
25
+ if (!e || typeof e !== 'object' || !e.id) throw new Error(`${from}: an engine module exports an object with an id and its hooks`);
26
+ if (e.checked) return e;
27
+ const read = e.read ?? {};
28
+ return {
29
+ checked: true, id: e.id, schemas: e.schemas ?? {}, ui: e.ui ?? null, client: e.client ?? {}, vendor: e.vendor ?? null, dir: e.dir ?? null,
30
+ read: { story: read.story ?? (() => ({})), folder: read.folder ?? (() => {}), include: read.include ?? null, prepare: read.prepare ?? null, page: read.page ?? (() => ({ data: {} })), finish: read.finish ?? (() => ({})) },
31
+ bundle: e.bundle ?? (() => ({ data: {} })), objects: e.objects ?? (() => ({ groups: {} })), site: e.site ?? (() => {}), make: e.make ?? null, describeObjects: e.describeObjects ?? null,
32
+ };
33
+ }
@@ -0,0 +1,30 @@
1
+ // Libraries of folders: stories in content/, and battles, images and zones in content/shared/ for every story or in a
2
+ // story's shared/. A folder that holds its own file is an item, named by the folder. Any other folder is a group that
3
+ // only orders the library.
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ export const isStory = d => fs.existsSync(path.join(d, 'story.yaml'));
8
+ export const isImage = d => fs.readdirSync(d).some(f => /^image\.\w+$/.test(f)); // image.yaml or the image itself
9
+ export const isBattle = d => fs.existsSync(path.join(d, 'battle.yaml')) || fs.existsSync(path.join(d, 'pages'));
10
+ export const isZone = d => fs.existsSync(path.join(d, 'zone.geojson'));
11
+
12
+ // The items below dir at any depth, by folder name. rel names the folders in the error for one name in two groups.
13
+ // skip leaves out a folder and everything below it.
14
+ export function libraryItems(dir, isItem, rel = p => p, skip = () => false) {
15
+ const items = new Map();
16
+ const walk = d => {
17
+ for (const name of fs.readdirSync(d).sort()) {
18
+ const full = path.join(d, name);
19
+ if (name.startsWith('.') || !fs.statSync(full).isDirectory() || skip(full)) continue;
20
+ if (!isItem(full)) { walk(full); continue; }
21
+ if (items.has(name)) throw new Error(`${rel(items.get(name))} and ${rel(full)} have one name, rename one`);
22
+ items.set(name, full);
23
+ }
24
+ };
25
+ if (fs.existsSync(dir)) walk(dir);
26
+ return items;
27
+ }
28
+
29
+ // The story folders below content/ at any depth, by folder name. content/shared/ holds what the stories share.
30
+ export const storyFolders = (content, rel) => libraryItems(content, isStory, rel, d => d === path.join(content, 'shared'));
@@ -0,0 +1,47 @@
1
+ # Interface strings for en, the book's. An engine ships its own over them. Content strings live in the content project.
2
+ name: English
3
+ prev: Prev
4
+ next: Next
5
+ close: Close
6
+ sources: Sources
7
+ chronology: Contents
8
+ language: Language
9
+ hint: Step through with the arrows.
10
+ pages: pages
11
+ open: Open
12
+ theme: Theme
13
+ expand_all: Expand all
14
+ source: Source
15
+ legend: Legend
16
+ collapse_all: Collapse all
17
+ read_more: Read more
18
+ hide: Hide
19
+ tab_map: View
20
+ tab_text: Text
21
+ contents: Contents
22
+ contribute: Contribute
23
+ story_source: Source on GitHub
24
+ generated_by: Generated by {kitap}
25
+ previous_image: Previous image
26
+ next_image: Next image
27
+ search_stories: Search stories
28
+ no_match: "No story matches “{q}”."
29
+ map_data: Data
30
+ other_sources: Other sources
31
+ ask: Ask AI
32
+ ask_title: Ask about this page
33
+ ask_prompt: "I am reading “{title}” at {url}. Answer my questions about it from this text and the page's sources, and say when you are unsure."
34
+ ask_note: Opens in a new tab with the page's text as the question.
35
+ start: Start
36
+ not_found: There is no page at this address.
37
+ settings: Settings
38
+ layout: Layout
39
+ layout_center: Picture in the middle
40
+ layout_left: Text on the left
41
+ layout_right: Text on the right
42
+ themes:
43
+ cool: Cool
44
+ mono: Mono
45
+ navy: Navy
46
+ parchment: Parchment
47
+ sepia: Sepia
@@ -0,0 +1,47 @@
1
+ # Interface strings for tr, the book's. An engine ships its own over them.
2
+ name: Türkçe
3
+ prev: Önceki
4
+ next: Sonraki
5
+ close: Kapat
6
+ sources: Kaynaklar
7
+ chronology: İçindekiler
8
+ language: Dil
9
+ hint: Oklarla adım adım ilerleyin.
10
+ pages: sayfa
11
+ open: Aç
12
+ theme: Tema
13
+ expand_all: Tümünü aç
14
+ source: Kaynak
15
+ collapse_all: Tümünü kapat
16
+ read_more: Devamını oku
17
+ hide: Gizle
18
+ legend: Lejant
19
+ tab_map: Görünüm
20
+ tab_text: Metin
21
+ contents: İçindekiler
22
+ contribute: Katkıda bulun
23
+ story_source: GitHub’da kaynağı
24
+ generated_by: '{kitap} ile oluşturuldu'
25
+ previous_image: Önceki görsel
26
+ next_image: Sonraki görsel
27
+ search_stories: Hikâye ara
28
+ no_match: "“{q}” ile eşleşen hikâye yok."
29
+ map_data: Veri
30
+ other_sources: Diğer kaynaklar
31
+ ask: Yapay zekâya sor
32
+ ask_title: Bu sayfayı sor
33
+ ask_prompt: "“{title}” sayfasını okuyorum: {url}. Sorularımı bu metne ve sayfanın kaynaklarına dayanarak yanıtla, emin olmadığında söyle."
34
+ ask_note: Yeni sekmede, sayfanın metni soru olarak açılır.
35
+ start: Başla
36
+ not_found: Bu adreste bir sayfa yok.
37
+ settings: Ayarlar
38
+ layout: Yerleşim
39
+ layout_center: Resim ortada
40
+ layout_left: Metin solda
41
+ layout_right: Metin sağda
42
+ themes:
43
+ cool: Soğuk
44
+ mono: Tek renk
45
+ navy: Lacivert
46
+ parchment: Parşömen
47
+ sepia: Sepya
package/src/i18n.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // Translatable strings. The default language is written inline as a plain string; other languages come from
2
+ // catalogues, content/<story>/i18n/<lang>.yaml for a story and i18n/<lang>.yaml for the site, keyed by the
3
+ // stable ids every piece of content already has, for example pages.occupation.title. An inline object with
4
+ // one entry per language keeps working and takes precedence for the languages it names.
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+
8
+ // { a: { b: 'x' } } -> { 'a.b': 'x' }, so a catalogue may nest or stay flat
9
+ export function flatten(obj, prefix = '', out = {}) {
10
+ for (const [k, v] of Object.entries(obj ?? {})) {
11
+ const key = prefix ? `${prefix}.${k}` : k;
12
+ if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out); else if (v != null && v !== '') out[key] = String(v);
13
+ }
14
+ return out;
15
+ }
16
+
17
+ // { 'a.b': 'x' } -> { a: { b: 'x' } }
18
+ export function unflatten(flat) {
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(flat)) { const parts = k.split('.'); let o = out; parts.slice(0, -1).forEach(p => { o = o[p] = typeof o[p] === 'object' ? o[p] : {}; }); o[parts.at(-1)] = v; }
21
+ return out;
22
+ }
23
+
24
+ // Reads <dir>/<lang>.yaml for every language that has one.
25
+ export function loadCatalogues(dir, langs, loadYaml) {
26
+ const cats = {};
27
+ for (const lang of langs) { const f = path.join(dir, lang + '.yaml'); if (fs.existsSync(f)) cats[lang] = flatten(loadYaml(f)); }
28
+ return cats;
29
+ }
30
+
31
+ // One translator per story or site. tr(value, key) expands a field to one string per language and records
32
+ // the key with its source text, so catalogues can be written and gaps reported. A shared field, a name, citation
33
+ // or photo credit, may be translated but is not counted as missing when it is not.
34
+ export function translator({ langs, defaultLang, catalogues, where, fail }) {
35
+ const entries = new Map();
36
+ const missing = Object.fromEntries(langs.map(l => [l, []]));
37
+ function tr(value, key, { shared = false } = {}) {
38
+ const inline = typeof value === 'object' && value !== null ? value : null;
39
+ const source = inline ? inline[defaultLang] : value;
40
+ if (source == null) fail(where, `${key}: no "${defaultLang}" text, write it in ${defaultLang} or set default_language to the language it is in`);
41
+ const out = {};
42
+ for (const lang of langs) {
43
+ const text = inline?.[lang] ?? catalogues[lang]?.[key] ?? (lang === defaultLang ? source : null);
44
+ if (text == null) { if (!shared) missing[lang].push(key); out[lang] = source; } else out[lang] = String(text);
45
+ }
46
+ if (!entries.has(key)) entries.set(key, { key, source: String(source), values: out, shared, inline: inline ? Object.fromEntries(Object.entries(inline).filter(([l]) => l !== defaultLang)) : null });
47
+ return out;
48
+ }
49
+ const report = () => Object.fromEntries(langs.filter(l => l !== defaultLang).map(l => [l, { total: [...entries.values()].filter(e => !e.shared).length, missing: [...new Set(missing[l])] }]));
50
+ return { tr, entries, report };
51
+ }
52
+
53
+ // Text of a catalogue: every key with its English source as the comment above, existing or inline
54
+ // translations kept, keys that no longer exist moved to a commented block at the end.
55
+ export function catalogueText(lang, entries, existing, heading) {
56
+ const q = s => JSON.stringify(s ?? '');
57
+ const lines = [`# ${heading}`, `# Keys are stable ids, the comment above each one is the source text. Empty values fall back to the`, `# default language, and kitap build --strict reports them. Regenerate with: kitap i18n ${lang}`, ''];
58
+ let filled = 0, total = 0;
59
+ const emit = e => { const value = existing[e.key] ?? e.inline?.[lang] ?? ''; if (value && !e.shared) filled++; if (!e.shared) total++; lines.push(`# ${e.source.replace(/\n/g, ' ')}`, `${e.key}: ${q(value)}`, ''); };
60
+ entries.filter(e => !e.shared).forEach(emit);
61
+ // a name, citation or credit is listed only with its own form in the language, the rest show as in the source
62
+ const shared = entries.filter(e => e.shared && (existing[e.key] || e.inline?.[lang]));
63
+ if (shared.length) { lines.push('# Names, citations and credits with their own form in this language. Any other shows as written in the source.', ''); shared.forEach(emit); }
64
+ const stale = Object.keys(existing).filter(k => !entries.some(e => e.key === k));
65
+ if (stale.length) { lines.push('# No longer used by the content:'); for (const k of stale) lines.push(`# ${k}: ${q(existing[k])}`); lines.push(''); }
66
+ return { text: lines.join('\n'), filled, total };
67
+ }