@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/LICENSE +21 -0
- package/README.md +104 -2
- package/bin/kitap.mjs +5 -0
- package/package.json +55 -4
- package/src/app.html +372 -0
- package/src/app.js +268 -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 +47 -0
- package/src/i18n/tr.yaml +47 -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 +79 -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/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|
package/src/folders.mjs
ADDED
|
@@ -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'));
|
package/src/i18n/en.yaml
ADDED
|
@@ -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
|
package/src/i18n/tr.yaml
ADDED
|
@@ -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
|
+
}
|