@openbooknetwork/kitap 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pages.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // The static HTML of every page in every language, with the head tags search engines and link previews read, and
2
+ // the files crawlers ask for: sitemaps, robots.txt, llms.txt and each story as Markdown.
3
+ // Paths are relative to the folder a page's <base> names. URLs are absolute and come from site.yaml's url.
4
+
5
+ export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
6
+ // JSON inside a <script> element, with < escaped so a "</script>" in the content stays text
7
+ const scriptJson = v => JSON.stringify(v).replace(/</g, '\\u003c');
8
+ const pick = (field, lang, fallback) => field?.[lang] ?? field?.[fallback] ?? '';
9
+ // A line that opens with a number and a full stop is text, as Turkish ordinals are ("21. yüzyılda"), not a list item.
10
+ export const ordinals = md => md.replace(/^(\d+)\. /gm, '$1\\. ');
11
+ // a place the text names, linked to the page's marker by its id: [Gelibolu’ya](@gallipoli)
12
+ export const PLACE = /\[([^\]]+)\]\(@([^)\s]+)\)/g;
13
+
14
+ // a marker the template must hold. The function form keeps a "$&" in the value literal.
15
+ function fill(html, marker, value) {
16
+ if (!html.includes(marker)) throw new Error(`template has no ${marker}`);
17
+ return html.replace(marker, () => value);
18
+ }
19
+
20
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
21
+ const plain = html => html.replace(/<[^>]+>/g, '')
22
+ .replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => e[0] !== '#' ? ENTITIES[e] ?? m : String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : Number(e.slice(1))))
23
+ .replace(/\s+/g, ' ').trim();
24
+ const firstParagraph = html => plain(html.match(/<p>([\s\S]*?)<\/p>/)?.[1] ?? '');
25
+
26
+ // A search snippet: the text up to its last sentence end before max characters, else cut at a space with an
27
+ // ellipsis. A sentence end in the first 60 characters is passed over, since it is often an abbreviation such as "c.".
28
+ export function describe(text, max = 160) {
29
+ if (text.length <= max) return text;
30
+ const ends = [...text.slice(0, max).matchAll(/[.!?…](?=\s)/g)].map(m => m.index + 1).filter(i => i >= 60);
31
+ if (ends.length) return text.slice(0, ends.at(-1));
32
+ const cut = text.lastIndexOf(' ', max - 2);
33
+ return text.slice(0, cut > 0 ? cut : max - 2).replace(/[\s,;:]+$/, '') + '…';
34
+ }
35
+
36
+ // og:locale wants language_TERRITORY, and ICU supplies the likely territory: tr gives tr_TR
37
+ const ogLocale = l => { const m = new Intl.Locale(l).maximize(); return m.region ? `${m.language}_${m.region}` : m.language; };
38
+
39
+ // the <head> tags after the viewport: <base>, title and description always, the rest when the site has a URL
40
+ function head({ base, title, description, lang, site, canonical, alternates = [], type = 'website', image = null, jsonld = null }) {
41
+ const tags = [`<base href="${base}">`, `<title>${esc(title)}</title>`];
42
+ if (description) tags.push(`<meta name="description" content="${esc(description)}">`);
43
+ if (!site.url) return tags.join('\n');
44
+ tags.push(`<link rel="canonical" href="${esc(canonical)}">`);
45
+ for (const a of alternates) tags.push(`<link rel="alternate" hreflang="${a.hreflang}" href="${esc(a.href)}">`);
46
+ const og = { 'og:type': type, 'og:title': title, 'og:description': description, 'og:url': canonical, 'og:site_name': pick(site.title, lang, site.defaultLang), 'og:locale': ogLocale(lang) };
47
+ for (const [k, v] of Object.entries(og)) if (v) tags.push(`<meta property="${k}" content="${esc(v)}">`);
48
+ for (const a of alternates) if (a.hreflang !== lang && a.hreflang !== 'x-default') tags.push(`<meta property="og:locale:alternate" content="${ogLocale(a.hreflang)}">`);
49
+ if (image) tags.push(`<meta property="og:image" content="${esc(image)}">`);
50
+ tags.push(`<meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">`);
51
+ if (jsonld) tags.push(`<script type="application/ld+json">${scriptJson(jsonld)}</script>`);
52
+ return tags.join('\n');
53
+ }
54
+
55
+ // one link per language version, plus x-default for readers whose language the page lacks
56
+ const alternatesFor = (site, langs, defaultLang, at) => site.url ? [...langs.map(l => ({ hreflang: l, href: site.abs(at(l)) })), { hreflang: 'x-default', href: site.abs(at(defaultLang)) }] : [];
57
+ const urlset = entries => `<?xml version="1.0" encoding="UTF-8"?>
58
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
59
+ ${entries.map(e => `<url><loc>${esc(e.loc)}</loc>${e.alternates.map(a => `<xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${esc(a.href)}"/>`).join('')}</url>`).join('\n')}
60
+ </urlset>
61
+ `;
62
+
63
+ // The site as the page builders use it: url ends in a slash, abs() turns a path from the site root into a URL.
64
+ export function siteContext({ url, title, languages, defaultLanguage }) {
65
+ const base = url ? url.replace(/\/?$/, '/') : null;
66
+ // abs keeps an absolute URL, such as an image's in the asset store, as it is
67
+ return { url: base, title, langs: languages, defaultLang: defaultLanguage, abs: p => new URL(p, base).href, root: base ? new URL(base).pathname : '/' };
68
+ }
69
+
70
+ // Every file of one story folder: the bare and per language overviews, a page per step and language, the story as
71
+ // Markdown per language, and with a site URL its sitemap. bundle is what story.js carries. texts and images are
72
+ // per page id and language: the Markdown source, and the first image the text shows. links holds each image's
73
+ // original and og:image links, absolute or from the site root, and coverImage is the id of the story's cover.
74
+ export function storyFiles({ shell, bundle: B, texts, firstImages, links, coverImage = null, site: siteIn, engine = null, versions }) {
75
+ const site = { ...siteIn, title: B.site.title }, files = {}, id = B.id, langs = B.languages, def = B.defaultLanguage, pages = B.pages;
76
+ const homeLang = l => site.langs.includes(l) ? l : site.defaultLang;
77
+ // the Markdown file sits in the story folder, so a link from the site root goes one folder up
78
+ const imageUrl = imgId => site.url ? site.abs(links[imgId].original) : /^https?:/.test(links[imgId].original) ? links[imgId].original : `../${links[imgId].original}`;
79
+ const ogImage = imgId => site.url ? site.abs(links[imgId].og) : null;
80
+ const cover = coverImage ? ogImage(coverImage) : null;
81
+ const overviewAt = l => `${id}/${l}/`, stepAt = (l, pid) => `${id}/${l}/${pid}/`;
82
+
83
+ const nav = (lang, cur) => {
84
+ const L = f => pick(f, lang, def);
85
+ const walk = (nodes, depth) => nodes.map(n => n.type === 'page'
86
+ ? `<a class="pg" href="${lang}/${pages[n.index].id}/" style="--depth:${depth}"${n.index === cur ? ' aria-current="page"' : ''}><span class="d">${esc(L(pages[n.index].date))}</span>${esc(L(pages[n.index].title))}</a>`
87
+ : `<details open><summary style="--depth:${depth}">${esc(L(n.title))}</summary>${walk(n.children, depth + 1)}</details>`).join('');
88
+ return walk(B.tree, 0);
89
+ };
90
+ const page = ({ lang, base, route, headHtml, text, cur }) => {
91
+ const L = f => pick(f, lang, def);
92
+ let html = fill(shell, '<html lang="en">', `<html lang="${lang}" dir="${B.ui[lang].dir}">`);
93
+ html = fill(html, '<!--__HEAD__-->', headHtml);
94
+ html = fill(html, '<a class="back" id="back" href="../"></a>', `<a class="back" id="back" href="../${homeLang(lang)}/">${esc(L(B.site.title))}</a>`);
95
+ html = fill(html, '<h1 id="title"></h1>', `<h1 id="title">${esc(L(B.title))}</h1>`);
96
+ html = fill(html, '<nav class="pages" id="nav"></nav>', `<nav class="pages" id="nav">${nav(lang, cur)}</nav>`);
97
+ html = fill(html, '<div class="text" id="story"></div>', `<div class="text" id="story">${text}</div>`);
98
+ html = fill(html, 'const ROUTE = null;', `const ROUTE = ${scriptJson(route)};`);
99
+ html = fill(html, 'src="story.js"', `src="story.js?v=${versions.story}"`);
100
+ if (engine && html.includes(`src="../${engine}.js"`)) html = fill(html, `src="../${engine}.js"`, `src="../${engine}.js?v=${versions.engine}"`);
101
+ return fill(html, 'src="../kitap.js"', `src="../kitap.js?v=${versions.app}"`);
102
+ };
103
+
104
+ const overview = (lang, base, bare) => {
105
+ const L = f => pick(f, lang, def), T = B.ui[lang];
106
+ const canonical = site.url ? site.abs(overviewAt(lang)) : null;
107
+ const text = `<div class="date">${esc(L(B.span))}</div><h2>${esc(L(B.title))}</h2>${B.summary ? `<p>${esc(L(B.summary))}</p>` : ''}`
108
+ + `<p><a class="start" href="${lang}/${pages[0].id}/" data-page="0">${esc(T.start)}</a></p>`;
109
+ const headHtml = head({ base, title: L(B.title), description: describe(L(B.summary)), lang, site, canonical,
110
+ alternates: bare ? [] : alternatesFor(site, langs, def, overviewAt), image: cover });
111
+ return page({ lang, base, route: bare ? null : { lang, page: null }, headHtml, text, cur: -1 });
112
+ };
113
+ files['index.html'] = overview(def, './', true);
114
+ for (const lang of langs) files[`${lang}/index.html`] = overview(lang, '../', false);
115
+
116
+ pages.forEach((p, i) => {
117
+ for (const lang of langs) {
118
+ const L = f => pick(f, lang, def), T = B.ui[lang];
119
+ const description = describe(firstParagraph(p.html[lang]));
120
+ const canonical = site.url ? site.abs(stepAt(lang, p.id)) : null;
121
+ const image = firstImages[p.id]?.[lang] ? ogImage(firstImages[p.id][lang]) : cover;
122
+ const sources = p.sources[lang].length ? `<h3>${esc(T.sources)}</h3><ol>${p.sources[lang].map(x => `<li>${esc(x)}</li>`).join('')}</ol>` : '';
123
+ const step = (j, label) => pages[j] ? `<a href="${lang}/${pages[j].id}/" data-page="${j}">${esc(label)}: ${esc(L(pages[j].title))}</a>` : '<span></span>';
124
+ const text = `<div class="date">${esc(L(p.date))}</div><h2>${esc(L(p.title))}</h2>${p.html[lang]}${sources}<p class="steps">${step(i - 1, T.prev)}${step(i + 1, T.next)}</p>`;
125
+ const title = `${L(p.title)} - ${L(B.title)}`;
126
+ const jsonld = site.url && [
127
+ { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [
128
+ [L(B.site.title), site.abs(`${homeLang(lang)}/`)], [L(B.title), site.abs(overviewAt(lang))], [L(p.title), canonical],
129
+ ].map(([name, item], k) => ({ '@type': 'ListItem', position: k + 1, name, item })) },
130
+ { '@context': 'https://schema.org', '@type': 'Article', headline: L(p.title), description, inLanguage: lang, url: canonical, mainEntityOfPage: canonical,
131
+ ...(image ? { image } : {}), isPartOf: { '@type': 'CreativeWork', name: L(B.title), url: site.abs(overviewAt(lang)) },
132
+ ...(p.sources[lang].length ? { citation: p.sources[lang] } : {}), publisher: { '@type': 'Organization', name: L(B.site.title), url: site.url } },
133
+ ];
134
+ const headHtml = head({ base: '../../', title, description, lang, site, canonical, alternates: alternatesFor(site, langs, def, l => stepAt(l, p.id)), type: 'article', image, jsonld });
135
+ files[`${lang}/${p.id}/index.html`] = page({ lang, base: '../../', route: { lang, page: p.id }, headHtml, text, cur: i });
136
+ }
137
+ });
138
+
139
+ for (const lang of langs) files[`${lang}.md`] = markdown(B, lang, texts, imageUrl);
140
+ if (site.url) files['sitemap.xml'] = urlset([
141
+ ...langs.map(l => ({ loc: site.abs(overviewAt(l)), alternates: alternatesFor(site, langs, def, overviewAt) })),
142
+ ...pages.flatMap(p => langs.map(l => ({ loc: site.abs(stepAt(l, p.id)), alternates: alternatesFor(site, langs, def, k => stepAt(k, p.id)) }))),
143
+ ]);
144
+ return files;
145
+ }
146
+
147
+ // The story as one Markdown file: title and summary, then the groups as headings and each step with its date,
148
+ // text and sources. An @image line becomes a Markdown image.
149
+ function markdown(B, lang, texts, imageUrl) {
150
+ const L = f => pick(f, lang, B.defaultLanguage), T = B.ui[lang], out = [`# ${L(B.title)}`, ''];
151
+ if (B.summary) out.push(L(B.summary), '');
152
+ const heading = depth => '#'.repeat(Math.min(depth + 2, 6));
153
+ const walk = (nodes, depth) => {
154
+ for (const n of nodes) {
155
+ if (n.type === 'group') { out.push(`${heading(depth)} ${L(n.title)}`, ''); walk(n.children, depth + 1); continue; }
156
+ const p = B.pages[n.index];
157
+ const text = ordinals(texts[p.id][lang]).replace(/^@image\s+(\S+)\s*$/gm, (_, imgId) => `![${L(B.images[imgId].caption).replace(/[[\]]/g, '\\$&')}](${imageUrl(imgId)})`)
158
+ .replace(PLACE, '$1');
159
+ out.push(`${heading(depth)} ${L(p.title)}`, '', `*${L(p.date)}*`, '', text.trim(), '');
160
+ if (p.sources[lang].length) out.push(`${T.sources}:`, '', ...p.sources[lang].map(s => `- ${s}`), '');
161
+ }
162
+ };
163
+ walk(B.tree, 0);
164
+ return out.join('\n');
165
+ }
166
+
167
+ // The main page per language and bare at the root, the 404 page, and with a site URL the crawl files. data is
168
+ // what the main page script reads as SITE, cards the stories it lists. image is the og:image link of the first cover,
169
+ // absolute or from the site root.
170
+ export function siteFiles({ shell, data: S, image: imageIn = null, site: siteIn, common, themeCss, ui }) {
171
+ const site = { ...siteIn, title: S.title }, files = {}, langs = S.languages, def = S.defaultLanguage;
172
+ const storyLang = (s, l) => s.languages.includes(l) ? l : s.defaultLanguage;
173
+ const homeAt = l => `${l}/`;
174
+ const image = imageIn && site.url ? site.abs(imageIn) : null;
175
+
176
+ const main = (lang, base, bare) => {
177
+ const L = f => pick(f, lang, def), T = S.ui[lang];
178
+ const rows = S.stories.map((s, i) => `
179
+ <li class="row">
180
+ <button type="button" aria-expanded="false" aria-controls="d-${i}" data-id="${esc(s.id)}">
181
+ <span class="span">${esc(L(s.span))}</span><h2>${esc(L(s.title))}</h2><span class="chev"></span>
182
+ <span class="brief">${esc(L(s.summary))}</span>
183
+ </button>
184
+ <div class="detail" id="d-${i}" hidden>
185
+ <div>
186
+ <p>${esc(L(s.summary))}</p>
187
+ <div class="meta"><span>${s.pages} ${esc(T.pages)}</span><span>${esc(s.languages.map(l => S.ui[l]?.name ?? l).join(', '))}</span></div>
188
+ <a class="open" href="${esc(s.id)}/${storyLang(s, lang)}/">${esc(T.open)}</a>
189
+ </div>
190
+ <div class="cover">${s.cover ? `<img src="${esc(s.cover)}" alt="">` : `<span>${esc(L(s.title))}</span>`}</div>
191
+ </div>
192
+ </li>`).join('');
193
+ let html = fill(shell, '<html lang="en">', `<html lang="${lang}" dir="${T.dir}">`);
194
+ html = fill(html, '<!--__HEAD__-->', head({ base, title: L(S.title), description: describe(L(S.intro)), lang, site,
195
+ canonical: site.url ? site.abs(homeAt(lang)) : null, alternates: bare ? [] : alternatesFor(site, langs, def, homeAt), image }));
196
+ html = fill(html, '<h1 id="title"></h1>', `<h1 id="title">${esc(L(S.title))}</h1>`);
197
+ html = fill(html, '<p class="intro" id="intro"></p>', `<p class="intro" id="intro">${esc(L(S.intro))}</p>`);
198
+ html = fill(html, '<ul class="rows" id="rows"></ul>', `<ul class="rows" id="rows">${rows}</ul>`);
199
+ return fill(html, 'const SITE = null;', `const ROUTE = ${scriptJson(bare ? null : { lang })};\nconst SITE = ${scriptJson(S)};`);
200
+ };
201
+ files['index.html'] = main(def, './', true);
202
+ for (const lang of langs) files[`${lang}/index.html`] = main(lang, '../', false);
203
+ files['404.html'] = notFound({ S, site, common, themeCss, ui });
204
+
205
+ if (site.url) {
206
+ files['sitemap-main.xml'] = urlset(langs.map(l => ({ loc: site.abs(homeAt(l)), alternates: alternatesFor(site, langs, def, homeAt) })));
207
+ files['sitemap.xml'] = `<?xml version="1.0" encoding="UTF-8"?>
208
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
209
+ ${['sitemap-main.xml', ...S.stories.map(s => `${s.id}/sitemap.xml`)].map(f => `<sitemap><loc>${esc(site.abs(f))}</loc></sitemap>`).join('\n')}
210
+ </sitemapindex>
211
+ `;
212
+ files['robots.txt'] = `User-agent: *\nAllow: /\n\nSitemap: ${site.abs('sitemap.xml')}\n`;
213
+ const L = f => pick(f, def, def);
214
+ files['llms.txt'] = [`# ${L(S.title)}`, '', ...(S.intro?.[def] ? [`> ${L(S.intro)}`, ''] : []),
215
+ 'Each story is one Markdown file per language: the title and summary, then every step with its date, text and sources.', '',
216
+ '## Stories', '',
217
+ ...S.stories.map(s => {
218
+ const sl = storyLang(s, def), others = s.languages.filter(l => l !== sl);
219
+ const also = others.length ? ` Also in ${others.map(l => `[${S.ui[l]?.name ?? l}](${site.abs(`${s.id}/${l}.md`)})`).join(', ')}.` : '';
220
+ return `- [${pick(s.title, sl, s.defaultLanguage)}](${site.abs(`${s.id}/${sl}.md`)}): ${pick(s.summary, sl, s.defaultLanguage)}${also}`;
221
+ }), ''].join('\n');
222
+ }
223
+ return files;
224
+ }
225
+
226
+ // GitHub Pages serves 404.html for any unknown path, at any depth, so its links start at the site root. The script
227
+ // reads the story and language from the path and shows the message in that language.
228
+ function notFound({ S, site, common, themeCss, ui }) {
229
+ const def = S.defaultLanguage;
230
+ const data = { root: site.root, languages: S.languages, defaultLanguage: def, title: S.title,
231
+ ui: Object.fromEntries(Object.entries(ui).map(([l, u]) => [l, { not_found: u.not_found, dir: u.dir }])),
232
+ stories: Object.fromEntries(S.stories.map(s => [s.id, { title: s.title, languages: s.languages, defaultLanguage: s.defaultLanguage }])) };
233
+ return `<!doctype html>
234
+ <html lang="${def}" dir="${ui[def].dir}">
235
+ <head>
236
+ <meta charset="utf-8">
237
+ <meta name="viewport" content="width=device-width, initial-scale=1">
238
+ <meta name="robots" content="noindex">
239
+ <title>${esc(ui[def].not_found)}</title>
240
+ <link rel="stylesheet" href="${site.root}vendor/fonts.css">
241
+ <style>
242
+ ${themeCss}
243
+ body{margin:0;background:var(--paper);color:var(--ink);font-family:"Public Sans","Helvetica Neue",Arial,sans-serif;line-height:1.5}
244
+ main{max-width:40rem;margin:0 auto;padding:4rem 1rem}
245
+ h1{font-family:"Fraunces",Georgia,serif;font-size:2rem;margin:0 0 1rem}
246
+ a{color:var(--accent)}
247
+ li{margin-bottom:.5rem}
248
+ </style>
249
+ </head>
250
+ <body>
251
+ <main>
252
+ <h1 id="msg">${esc(ui[def].not_found)}</h1>
253
+ <ul id="links"><li><a href="${esc(site.root)}${def}/">${esc(pick(S.title, def, def))}</a></li></ul>
254
+ </main>
255
+ <script>
256
+ ${common}
257
+ const D = ${scriptJson(data)};
258
+ const path = decodeURIComponent(location.pathname), parts = path.slice(path.startsWith(D.root) ? D.root.length : 1).split('/').filter(Boolean);
259
+ const story = D.stories[parts[0]], asked = story ? parts[1] : parts[0];
260
+ const lang = (story ? story.languages : D.languages).includes(asked) ? asked : preferredLang(D.languages, D.defaultLanguage);
261
+ const home = D.languages.includes(lang) ? lang : D.defaultLanguage;
262
+ const L = field => pick(field, lang, D.defaultLanguage);
263
+ document.documentElement.lang = lang; document.documentElement.dir = D.ui[lang].dir;
264
+ document.title = $('msg').textContent = D.ui[lang].not_found;
265
+ $('links').innerHTML = (story ? \`<li><a href="\${esc(D.root + parts[0])}/\${story.languages.includes(lang) ? lang : story.defaultLanguage}/">\${esc(L(story.title))}</a></li>\` : '')
266
+ + \`<li><a href="\${esc(D.root + home)}/">\${esc(L(D.title))}</a></li>\`;
267
+ </script>
268
+ </body>
269
+ </html>
270
+ `;
271
+ }
package/src/routes.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // How the site answers a path, shared by harita dev and the Worker, so a page behaves the same on a laptop and on
2
+ // the site. The module has no Node imports, so the Worker bundles it as it is.
3
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
4
+ '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
5
+ '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.pbf': 'application/x-protobuf' };
6
+ export const typeOf = file => TYPES[file.slice(file.lastIndexOf('.')).toLowerCase()] ?? 'application/octet-stream';
7
+
8
+ const NOT_FOUND = { key: '404.html', status: 404 };
9
+
10
+ // The answer to a decoded path: { key, status } to serve, or { location } to redirect to with 301. A path ending in /
11
+ // serves its index.html. A path without its slash whose folder has an index.html redirects to the path with the
12
+ // slash, with query kept. A path redirect names redirects to its target. Any other path the site lacks gets 404.html.
13
+ // exists tells whether the site holds a key and redirect gives a path's target or null, and either may be async.
14
+ export async function resolve(path, exists, { redirect = () => null, query = '' } = {}) {
15
+ const parts = path.split('/');
16
+ if (parts[0] !== '' || parts.includes('..') || parts.includes('.')) return NOT_FOUND;
17
+ if (path.endsWith('/')) { const key = path.slice(1) + 'index.html'; return await exists(key) ? { key, status: 200 } : NOT_FOUND; }
18
+ const key = path.slice(1);
19
+ if (await exists(key)) return { key, status: 200 };
20
+ if (await exists(key + '/index.html')) return { location: path + '/' + query };
21
+ const target = await redirect(path);
22
+ return target ? { location: target } : NOT_FOUND;
23
+ }
package/src/schema.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // Shapes of the book's content files. An engine adds its own fields to Story and Page under one key, its id, through
2
+ // storySchema and pageSchema. Cross references between files are checked in build.mjs.
3
+ import { z } from 'zod';
4
+ export { z };
5
+
6
+ // A plain string applies to every language; an object holds one string per language code.
7
+ export const Translatable = z.union([z.string(), z.record(z.string().min(2).max(8), z.string())]);
8
+ export const Id = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'lowercase letters, digits and dashes only');
9
+ export const Lang = z.string().regex(/^[a-z]{2}(-[A-Za-z]{2,4})?$/, 'a language code like en or pt-BR');
10
+ export const Hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'a hex colour like #a06b1f');
11
+ // a year, a year-month, or a full date; YAML hands over a Date for the full form
12
+ export const When = z.union([z.number().int(), z.string(), z.date()]).transform(v => v instanceof Date ? v.toISOString().slice(0, 10) : String(v))
13
+ .refine(s => /^\d{4}(-\d{2})?(-\d{2})?$/.test(s), 'YYYY, YYYY-MM or YYYY-MM-DD');
14
+
15
+ // land: up to eight tints an engine may use for areas, neighbours get different ones; without it every area is paper2
16
+ const ThemeTokens = z.object({ paper: Hex, paper2: Hex, sea: Hex, ink: Hex, ink2: Hex, rule: Hex, accent: Hex, panel: Hex, route: Hex, border: Hex, land: z.array(Hex).min(2).max(8).optional() }).strict();
17
+ export const Theme = z.object({ name: Translatable.optional(), light: ThemeTokens, dark: ThemeTokens }).strict();
18
+ export const Themes = z.record(Id, Theme);
19
+
20
+ export const Site = z.object({
21
+ title: Translatable.optional(),
22
+ intro: Translatable.optional(),
23
+ languages: z.array(Lang).min(1).optional(),
24
+ default_language: Lang.optional(),
25
+ stories: z.array(Id).optional(),
26
+ engine: Id.optional(), // the engine that draws the stories' pictures, resolved from the project's packages or plugins/engines/
27
+ theme: Id.optional(),
28
+ themes: Themes.optional(),
29
+ url: z.string().url().optional(), // where the site is published, for canonical links, sitemaps and llms.txt
30
+ repository: z.string().url().optional(), // the content repository: a Contribute link on the main page, a source link per story
31
+ branch: z.string().default('main'),
32
+ // the object store that holds the images and the engine's objects: url serves them, bucket and endpoint take uploads
33
+ assets: z.object({ url: z.string().url().refine(u => u.endsWith('/'), 'ends in /'), bucket: z.string().min(1), endpoint: z.string().url() }).strict().optional(),
34
+ pages: z.object({ bucket: z.string().min(1) }).strict().optional(), // the bucket kitap deploy writes dist/ to, in the account of assets.endpoint
35
+ }).strict();
36
+
37
+ // The book's fields of story.yaml. storySchema(engine) adds the engine's under its id.
38
+ export const StoryCore = {
39
+ id: Id,
40
+ title: Translatable,
41
+ summary: Translatable.optional(),
42
+ span: Translatable.optional(),
43
+ cover: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'the id of an image folder the story can show, kitap image <file> <name> --story <story> makes one').optional(), // the card on the main page
44
+ languages: z.array(Lang).min(1),
45
+ default_language: Lang.optional(),
46
+ theme: Id.optional(),
47
+ };
48
+ export const storySchema = engine => z.object({ ...StoryCore, ...(engine ? { [engine.id]: (engine.schemas?.story ?? z.object({}).passthrough()).default({}) } : {}) }).strict();
49
+
50
+ export const Group = z.object({ title: Translatable.optional() }).strict();
51
+
52
+ // image.yaml in an image folder, content/shared/images/<id>/ or content/<story>/shared/images/<id>/. source: the page
53
+ // the original came from, such as its Commons file page. url: where the original downloads from. file and sha256: the
54
+ // original's name in the folder and its hash, which name its objects in the asset store.
55
+ export const ImageFolder = z.object({ caption: Translatable, credit: Translatable.optional(), default_language: Lang.default('en'),
56
+ source: z.string().url().optional(), url: z.string().url().optional(),
57
+ file: z.string().regex(/^image\.(jpe?g|png|webp|svg|gif)$/, 'image.<ext>, the original in this folder, kitap rehash writes it'),
58
+ sha256: z.string().regex(/^[0-9a-f]{64}$/, '64 hex digits, kitap rehash writes it') }).strict();
59
+
60
+ // The book's fields of page.yaml. pageSchema(engine) adds the engine's under its id.
61
+ export const PageCore = {
62
+ title: Translatable,
63
+ date: Translatable.optional(), // shown above the title and in the contents, when the subject has dates
64
+ when: z.union([When, z.object({ from: When, to: When.optional() }).strict()]).optional(),
65
+ sources: z.array(Translatable).default([]),
66
+ // a card beside the text drawn by plugins/cards/<kind>.mjs, with its parameters
67
+ card: z.object({ kind: Id }).passthrough().optional(),
68
+ images: z.unknown().optional().refine(v => v === undefined, 'images live in image folders, content/<story>/shared/images/<name>-<six hex digits>/, and kitap image <file> <name> makes one'),
69
+ };
70
+ export const pageSchema = engine => z.object({ ...PageCore, ...(engine ? { [engine.id]: (engine.schemas?.page ?? z.object({}).passthrough()).default({}) } : {}) }).strict();
71
+
72
+ // Parse or throw one error that names the file and every bad field.
73
+ export function check(schema, data, file) {
74
+ const r = schema.safeParse(data);
75
+ if (r.success) return r.data;
76
+ const lines = r.error.issues.map(i => ` ${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`);
77
+ throw new Error(`${file}:\n${lines.join('\n')}`);
78
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,153 @@
1
+ // The asset store: an S3 bucket that holds the images and the engine's objects under keys the build computes, and per
2
+ // story a hash tree of the keys it needs. Reads go through the store's public URL, uploads through its S3 endpoint.
3
+ import crypto from 'crypto';
4
+ import { AwsClient } from 'aws4fetch';
5
+
6
+ export const TREE_FORMAT = 1;
7
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
8
+ const hashLines = lines => crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
9
+
10
+ // A story's hash tree: a directory per image folder with its keys, and a directory per group of the engine's objects
11
+ // with its keys. A directory's hash is the sha256 of its sorted keys, and the root's the sha256 of its sorted
12
+ // "<hash> <directory>" lines. images are { dir, keys } with dir the folder's path in the project, groups is
13
+ // { directory: [keys] }.
14
+ export function storyTree(story, images, groups = {}) {
15
+ const keysOf = {};
16
+ for (const { dir, keys } of images) keysOf[dir] = Object.values(keys);
17
+ for (const [d, keys] of Object.entries(groups)) (keysOf[d] ??= []).push(...keys);
18
+ const dirs = {};
19
+ for (const d of Object.keys(keysOf).sort()) { const keys = [...new Set(keysOf[d])].sort(); dirs[d] = { hash: hashLines(keys), keys }; }
20
+ return { format: TREE_FORMAT, story, root: hashLines(Object.entries(dirs).map(([d, x]) => `${x.hash} ${d}`).sort()), dirs };
21
+ }
22
+
23
+ // Runs fn over the items, n at a time, and returns its results in order.
24
+ export async function pool(items, n, fn) {
25
+ const out = new Array(items.length);
26
+ let next = 0;
27
+ const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); } };
28
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
29
+ return out;
30
+ }
31
+
32
+ // A request tried again five times on a network error or a 5xx or 429 answer, waiting twice as long each time, half
33
+ // a minute in all. Returns the last answer, or throws with the status or the error code, such as ETIMEDOUT.
34
+ export async function retry(what, request, { wait = 1000 } = {}) {
35
+ for (let attempt = 0; ; attempt++) {
36
+ let res = null, error = null;
37
+ try { res = await request(); } catch (err) { error = err; }
38
+ if (res && res.status < 500 && res.status !== 429) return res;
39
+ if (attempt === 5) throw new Error(`${what}: ${res ? `HTTP ${res.status}` : [error.cause?.code, error.cause?.message || error.message].filter(Boolean).join(' ')}`);
40
+ await new Promise(r => setTimeout(r, wait * 2 ** attempt));
41
+ }
42
+ }
43
+
44
+ // Reads objects through the store's public URL, which needs no credentials. count tells the requests made.
45
+ export function storeReader(url, opts = {}) {
46
+ const r = {
47
+ count: 0,
48
+ // whether the store holds the key: a GET of its first byte, since the store closes the connection after a HEAD
49
+ async has(key) {
50
+ r.count++;
51
+ const res = await retry(url + key, () => fetch(url + key, { headers: { Range: 'bytes=0-0' } }), opts);
52
+ await res.arrayBuffer();
53
+ if (res.status === 404 || res.status === 403) return false;
54
+ if (!res.ok) throw new Error(`${url + key}: HTTP ${res.status}`);
55
+ return true;
56
+ },
57
+ async get(key) {
58
+ r.count++;
59
+ const res = await retry(url + key, () => fetch(url + key, { cache: 'no-store' }), opts);
60
+ if (res.status === 404 || res.status === 403) return null;
61
+ if (!res.ok) throw new Error(`${url + key}: HTTP ${res.status}`);
62
+ return Buffer.from(await res.arrayBuffer());
63
+ },
64
+ async json(key) { const b = await r.get(key); return b && JSON.parse(b.toString('utf8')); },
65
+ };
66
+ return r;
67
+ }
68
+
69
+ const unescapeXml = s => s.replace(/&(amp|lt|gt|quot|apos|#(\d+));/g, (_, e, n) => n ? String.fromCodePoint(n) : { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[e]);
70
+
71
+ // Writes, lists and deletes objects through the S3 endpoint with the credentials of AWS_ACCESS_KEY_ID and
72
+ // AWS_SECRET_ACCESS_KEY. command names the command in the error for missing credentials.
73
+ export function storeWriter({ endpoint, bucket }, { command = 'kitap upload', ...opts } = {}) {
74
+ const { AWS_ACCESS_KEY_ID: accessKeyId, AWS_SECRET_ACCESS_KEY: secretAccessKey } = process.env;
75
+ if (!accessKeyId || !secretAccessKey) throw new Error(`${command} writes with the S3 credentials of an API token for the bucket: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY`);
76
+ const aws = new AwsClient({ accessKeyId, secretAccessKey, service: 's3', region: 'auto', retries: 0 });
77
+ const base = `${endpoint.replace(/\/$/, '')}/${bucket}/`;
78
+ return {
79
+ // the store checks the bytes against Content-MD5 and refuses a copy damaged on the way
80
+ async put(key, body, type, cacheControl = IMMUTABLE) {
81
+ const headers = { 'Content-Type': type, 'Cache-Control': cacheControl, 'Content-MD5': crypto.createHash('md5').update(body).digest('base64') };
82
+ const res = await retry(`PUT ${key}`, async () => fetch(await aws.sign(base + key, { method: 'PUT', body, headers })), opts);
83
+ if (!res.ok) throw new Error(`PUT ${key}: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
84
+ },
85
+ // every key in the bucket with its ETag, the MD5 of an object put in one part, a thousand keys a request
86
+ async list() {
87
+ const out = [];
88
+ let token = null;
89
+ do {
90
+ const query = `?list-type=2${token ? `&continuation-token=${encodeURIComponent(token)}` : ''}`;
91
+ const res = await retry(`list ${bucket}`, async () => fetch(await aws.sign(base + query, { method: 'GET' })), opts);
92
+ if (!res.ok) throw new Error(`list ${bucket}: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
93
+ const xml = await res.text();
94
+ for (const [, entry] of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g))
95
+ out.push({ key: unescapeXml(entry.match(/<Key>([\s\S]*?)<\/Key>/)[1]), etag: unescapeXml(entry.match(/<ETag>([\s\S]*?)<\/ETag>/)[1]).replace(/"/g, '') });
96
+ token = xml.match(/<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/)?.[1];
97
+ token = token ? unescapeXml(token) : null;
98
+ } while (token);
99
+ return out;
100
+ },
101
+ async delete(key) {
102
+ const res = await retry(`DELETE ${key}`, async () => fetch(await aws.sign(base + key, { method: 'DELETE' })), opts);
103
+ if (!res.ok && res.status !== 404) throw new Error(`DELETE ${key}: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
104
+ },
105
+ };
106
+ }
107
+
108
+ // The keys of a tree the store lacks. The meta file of the tree's root means none. Else the tree latest.json names
109
+ // tells the directories that match and the keys uploaded, and the store is asked for each other key. A story without
110
+ // latest.json returns null, or with headAll a request for every key.
111
+ export async function missingKeys(reader, tree, { headAll = false } = {}) {
112
+ const meta = `meta/${tree.story}/`;
113
+ if (await reader.get(`${meta}${tree.root}.json`)) return { missing: [], complete: true };
114
+ const latest = await reader.json(`${meta}latest.json`), stored = latest && await reader.json(`${meta}${latest.root}.json`);
115
+ if (!stored && !headAll) return { missing: null, complete: false };
116
+ const known = new Set(), same = new Set();
117
+ for (const [d, x] of Object.entries(stored?.dirs ?? {})) {
118
+ for (const k of x.keys) known.add(k);
119
+ if (tree.dirs[d]?.hash === x.hash) same.add(d);
120
+ }
121
+ const ask = [...new Set(Object.entries(tree.dirs).filter(([d]) => !same.has(d)).flatMap(([, x]) => x.keys).filter(k => !known.has(k)))];
122
+ const found = await pool(ask, 8, k => reader.has(k));
123
+ return { missing: ask.filter((_, i) => !found[i]), complete: false };
124
+ }
125
+
126
+ // The keys of a tree that a listing of the bucket lacks, held being the set of keys in the store.
127
+ export function missingIn(held, tree) {
128
+ const keys = [...new Set(Object.values(tree.dirs).flatMap(x => x.keys))];
129
+ return { missing: keys.filter(k => !held.has(k)), complete: held.has(`meta/${tree.story}/${tree.root}.json`) };
130
+ }
131
+
132
+ // The missing objects of a tree in words: the image folders by path, and the engine's objects as the engine describes
133
+ // them, else by count.
134
+ export function describeMissing(tree, missing, describe = null) {
135
+ const set = new Set(missing);
136
+ const folders = Object.entries(tree.dirs).filter(([d, x]) => !d.startsWith('objects/') && d.includes('/images/') && x.keys.some(k => set.has(k))).map(([d]) => d);
137
+ const others = missing.filter(k => !k.startsWith('images/'));
138
+ const described = others.length ? describe?.(others) ?? `${others.length} object${others.length === 1 ? '' : 's'}` : '';
139
+ return [folders.length && `the images of ${folders.join(', ')}`, described].filter(Boolean).join(' and ');
140
+ }
141
+
142
+ // kitap build --strict: every object the trees name is in the store. Throws with the stories that lack some, named by
143
+ // each tree's dir, the story folder.
144
+ export async function checkStore({ url, trees, log = console.log, describe = null, ...opts }) {
145
+ const reader = storeReader(url, opts), problems = [];
146
+ for (const tree of trees) {
147
+ const { missing } = await missingKeys(reader, tree);
148
+ if (missing === null) problems.push(`${tree.dir}: the store holds no upload of this story, kitap upload --story ${tree.story} puts its objects there`);
149
+ else if (missing.length) problems.push(`${tree.dir}: the store lacks ${describeMissing(tree, missing, describe)}, kitap upload --story ${tree.story} puts them there`);
150
+ }
151
+ if (problems.length) throw new Error(problems.join('\n'));
152
+ log(`the store holds every object of ${trees.length} stor${trees.length === 1 ? 'y' : 'ies'} (${reader.count} request${reader.count === 1 ? '' : 's'})`);
153
+ }
@@ -0,0 +1,27 @@
1
+ # Named colour themes. Each has a light and a dark set of the same tokens.
2
+ # A project adds or overrides themes under `themes:` in site.yaml with the same shape.
3
+ # Names are interface strings: themes.<id> in src/i18n/<lang>.yaml, or i18n/ui/<lang>.yaml in a project.
4
+ # land lists tints for the countries, atlas style; a theme without it paints every country paper2.
5
+ cool:
6
+ light: { paper: "#f7f7f5", paper2: "#eef0ea", sea: "#d8e0e6", ink: "#1f2328", ink2: "#5b6470", rule: "#c9cfd6", accent: "#c0392b", panel: "#ffffff", route: "#5b6470", border: "#7a848f",
7
+ land: ["#e6e8d6", "#e2e6ea", "#ece2d6", "#dfe8dc", "#eadfe0", "#e8e4d2"] }
8
+ dark: { paper: "#15181c", paper2: "#262b31", sea: "#0f1317", ink: "#e6e8eb", ink2: "#9aa3ad", rule: "#3a414a", accent: "#e8655a", panel: "#1c2026", route: "#9aa3ad", border: "#8a949e",
9
+ land: ["#2b3128", "#262d36", "#332c27", "#263229", "#312a2e", "#302f26"] }
10
+ mono:
11
+ light: { paper: "#ffffff", paper2: "#f0f0f0", sea: "#e2e2e2", ink: "#111111", ink2: "#666666", rule: "#d0d0d0", accent: "#c8102e", panel: "#fafafa", route: "#666666", border: "#777777" }
12
+ dark: { paper: "#0e0e0e", paper2: "#232323", sea: "#050505", ink: "#f2f2f2", ink2: "#a0a0a0", rule: "#333333", accent: "#ff4d5e", panel: "#161616", route: "#a0a0a0", border: "#8c8c8c" }
13
+ navy:
14
+ light: { paper: "#0f1a2b", paper2: "#c9bda0", sea: "#0b1422", ink: "#f1e9d6", ink2: "#a9b2c2", rule: "#2c3b55", accent: "#f0a04b", panel: "#162339", route: "#a9b2c2", border: "#5c6a80",
15
+ land: ["#c9bda0", "#bfc3a0", "#c7b396", "#b8bfa8", "#cbb8a6", "#c2bb9a"] }
16
+ dark: { paper: "#0f1a2b", paper2: "#c9bda0", sea: "#0b1422", ink: "#f1e9d6", ink2: "#a9b2c2", rule: "#2c3b55", accent: "#f0a04b", panel: "#162339", route: "#a9b2c2", border: "#5c6a80",
17
+ land: ["#c9bda0", "#bfc3a0", "#c7b396", "#b8bfa8", "#cbb8a6", "#c2bb9a"] }
18
+ parchment:
19
+ light: { paper: "#e9ebe6", paper2: "#dfe2da", sea: "#cfd7d3", ink: "#262b28", ink2: "#5c645f", rule: "#b8beb8", accent: "#9c2f2f", panel: "#f1f2ee", route: "#5c645f", border: "#6f7873",
20
+ land: ["#dde2cf", "#d8e0dc", "#e3dccd", "#d5e0d2", "#e0d8d6", "#dcdccb"] }
21
+ dark: { paper: "#22262a", paper2: "#30363b", sea: "#171b1f", ink: "#e3e6e2", ink2: "#a3aaa6", rule: "#454c51", accent: "#d9605e", panel: "#1c2024", route: "#a3aaa6", border: "#8f9893",
22
+ land: ["#333a2f", "#2e363e", "#3a342d", "#2e3a33", "#383037", "#36362c"] }
23
+ sepia:
24
+ light: { paper: "#e6e1d2", paper2: "#ddd7c6", sea: "#cfd6d2", ink: "#2a2823", ink2: "#5d5950", rule: "#b9b2a0", accent: "#a3282b", panel: "#efebe0", route: "#5d5950", border: "#6d665a",
25
+ land: ["#dcd4b8", "#d3d6c2", "#e0d0bd", "#cfd6c4", "#ddd0c8", "#d8d6b9"] }
26
+ dark: { paper: "#2a2823", paper2: "#34312a", sea: "#1d2024", ink: "#e7e1d1", ink2: "#aaa392", rule: "#4e4a40", accent: "#d9565a", panel: "#23211c", route: "#aaa392", border: "#9a9283",
27
+ land: ["#38352a", "#30352e", "#3b322a", "#2e3630", "#38302e", "#353426"] }
@@ -0,0 +1,41 @@
1
+ // kitap i18n <lang> [--story id]: builds quietly to collect every string, then writes the <lang>.yaml catalogues of
2
+ // each story, of each image folder and folder of the engine's a story uses, and of the site, keeping the translations there.
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import yaml from 'js-yaml';
7
+ import { buildPages, uiCatalogue } from './build.mjs';
8
+ import { flatten, catalogueText } from './i18n.mjs';
9
+
10
+ export function i18n({ root = process.cwd(), lang, story, log = console.log, engine = null } = {}) {
11
+ if (!lang) throw new Error('usage: kitap i18n <lang> [--story id]');
12
+ // one story builds alone into a folder thrown away after, so a half edited story elsewhere does not stop it
13
+ const out = story && fs.mkdtempSync(path.join(os.tmpdir(), 'kitap-i18n-'));
14
+ let r;
15
+ try { r = buildPages({ root, log: () => {}, story, out, localAssets: true, engine }); } finally { if (out) fs.rmSync(out, { recursive: true, force: true }); }
16
+ const written = [];
17
+ const write = (file, entries, heading) => {
18
+ const existing = fs.existsSync(file) ? flatten(yaml.load(fs.readFileSync(file, 'utf8')) ?? {}) : {};
19
+ const { text, filled, total } = catalogueText(lang, entries, existing, heading);
20
+ fs.mkdirSync(path.dirname(file), { recursive: true });
21
+ fs.writeFileSync(file, text);
22
+ log(`wrote ${path.relative(root, file)}: ${filled} of ${total} strings translated`);
23
+ written.push(file);
24
+ };
25
+ for (const [id, { dir, entries }] of Object.entries(r.i18n.stories)) write(path.join(root, dir, 'i18n', lang + '.yaml'), entries, `${lang} strings for the story "${id}".`);
26
+ // an image folder's or an engine folder's catalogues sit in its folder, for each language a story using it has
27
+ for (const [dir, f] of Object.entries(r.i18n.folders)) {
28
+ if (lang === f.defaultLang || !f.langs.includes(lang)) continue;
29
+ write(path.join(root, dir, 'i18n', lang + '.yaml'), f.entries, `${lang} strings for the ${f.kind} "${f.id}".`);
30
+ }
31
+ if (!story) write(path.join(root, 'i18n', lang + '.yaml'), r.i18n.site, `${lang} strings for the site.`);
32
+ if (!story && lang !== 'en') {
33
+ const u = uiCatalogue(root, lang, r.themes, r.engine), file = path.join(root, 'i18n', 'ui', lang + '.yaml');
34
+ if (u.missing.length || fs.existsSync(file)) {
35
+ const { text, filled, total } = catalogueText(lang, u.entries, u.existing, `${lang} interface strings: buttons, labels, messages, theme names. Overrides what kitap and the engine ship.`);
36
+ fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text);
37
+ log(`wrote ${path.relative(root, file)}: ${filled} of ${total} strings translated`); written.push(file);
38
+ } else log(`interface: kitap and the engine ship all ${u.entries.length} strings for ${lang}`);
39
+ }
40
+ return written;
41
+ }