@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/upload.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // kitap upload: puts the images and the engine's objects each story links to into the asset store, then the story's
2
+ // hash tree, so kitap build --strict finds them with one request.
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { storyAssets } from './build.mjs';
7
+ import { checkOriginal, contentType, downloadOriginal, sha256Of } from './image.mjs';
8
+ import { typeOf } from './routes.mjs';
9
+ import { storeReader, storeWriter, missingKeys, missingIn, pool, describeMissing } from './store.mjs';
10
+ import { mb, plural, progressLine } from './util.mjs';
11
+
12
+ const SIZES = { large: 1280, small: 320 };
13
+
14
+ // The WebP sizes of an original, fitted inside 1280 and 320 px squares. A raster original is never enlarged, an SVG is
15
+ // drawn at each size, and an animated GIF or WebP keeps its frames.
16
+ export async function webpSizes(bytes, ext) {
17
+ const sharp = (await import('sharp')).default;
18
+ const svg = ext === '.svg', animated = ext === '.gif' || ext === '.webp';
19
+ // lines and lettering of maps and diagrams stay sharp at the higher quality
20
+ const quality = svg || ext === '.png' ? 90 : 80;
21
+ const meta = svg ? await sharp(bytes).metadata() : null;
22
+ const out = {};
23
+ for (const [name, n] of Object.entries(SIZES)) {
24
+ // an SVG is drawn at the density that makes its longer side n pixels, so the size is drawn, not scaled up
25
+ const density = svg ? Math.min(100000, Math.max(1, 72 * n / Math.max(meta.width, meta.height))) : undefined;
26
+ let img = sharp(bytes, { animated, ...(density ? { density } : {}) });
27
+ if (!animated) img = img.rotate(); // upright by the EXIF orientation, which the WebP does not carry
28
+ out[name] = await img.resize(n, n, { fit: 'inside', withoutEnlargement: !svg }).webp({ quality }).toBuffer();
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // The original of an image: the file on this machine, else the copy in the store, else the download from its url.
34
+ async function originalOf(im, reader, inStore) {
35
+ const ext = path.extname(im.keys.original);
36
+ let bytes = null, from;
37
+ if (im.file) { bytes = fs.readFileSync(im.file); from = path.join(im.dir, path.basename(im.file)); }
38
+ else if (inStore && (bytes = await reader.get(im.keys.original))) from = `the store's ${im.keys.original}`;
39
+ else if (im.url) { bytes = (await downloadOriginal(im.url)).bytes; from = im.url; }
40
+ else throw new Error(`${im.dir}: the original is not on this machine, in the store or at a url in image.yaml, add it to the folder`);
41
+ if (sha256Of(bytes) !== im.sha256) throw new Error(`${im.dir}: ${from} does not match the sha256 of image.yaml${im.file ? ', kitap rehash writes the new one' : ''}`);
42
+ await checkOriginal(bytes, ext, from);
43
+ return bytes;
44
+ }
45
+
46
+ // Puts the objects of story, else of every story, into the store of site.yaml's assets: the originals and their WebP
47
+ // sizes, the engine's objects, and last each story's hash tree with latest.json naming it. dryRun lists what would go
48
+ // and writes nothing. Returns the keys uploaded, or with dryRun the keys it would upload.
49
+ export async function upload({ root = process.cwd(), story = null, dryRun = false, log = console.log, progress = progressLine, cache = path.join(root, '.cache', 'kitap'), engine = null, wait, ...options } = {}) {
50
+ const { assets, stories, engine: e } = storyAssets({ root, story, log, engine, cache });
51
+ const reader = storeReader(assets.url, { wait }), writer = dryRun ? null : storeWriter(assets, { wait });
52
+ // an upload lists the bucket once, a thousand keys a request, and a dry run asks through the public URL alone
53
+ const held = writer ? new Set((await writer.list()).map(o => o.key)) : null;
54
+ if (held) log(`the store holds ${held.size.toLocaleString('en')} objects`);
55
+ let cur = null;
56
+ const seen = new Set(), done = new Set(), planned = new Set(), present = new Set(), sum = { images: 0, imageBytes: 0, tiles: 0, tileBytes: 0 };
57
+ const put = async (key, body, type, cacheControl) => {
58
+ if (done.has(key)) return 0;
59
+ await writer.put(key, body, type, cacheControl);
60
+ done.add(key);
61
+ cur.n++; cur.bytes += body.length;
62
+ progress({ ...cur, done: false });
63
+ return body.length;
64
+ };
65
+ for (const s of stories) {
66
+ const { missing: list, complete } = held ? missingIn(held, s.tree) : await missingKeys(reader, s.tree, { headAll: true });
67
+ const missing = new Set(list.filter(k => !done.has(k)));
68
+ for (const k of Object.values(s.tree.dirs).flatMap(x => x.keys)) if (!missing.has(k) && !done.has(k)) present.add(k);
69
+ if (complete) { log(`${s.story}: the store holds every object`); continue; }
70
+ const images = s.images.filter(im => Object.values(im.keys).some(k => missing.has(k)) && !seen.has(im.keys.original));
71
+ for (const im of images) seen.add(im.keys.original);
72
+ const objects = Object.values(s.groups).flat().filter(k => missing.has(k));
73
+ if (dryRun) {
74
+ if (missing.size) log(`${s.story}: would upload ${describeMissing(s.tree, [...missing], e.describeObjects)}`);
75
+ for (const im of images) if (!im.file && !im.url && missing.has(im.keys.original)) throw new Error(`${im.dir}: the original is not on this machine, in the store or at a url in image.yaml, add it to the folder`);
76
+ sum.images += images.length; sum.tiles += objects.length;
77
+ for (const k of missing) planned.add(k);
78
+ continue;
79
+ }
80
+ cur = { what: s.story, n: 0, total: missing.size, bytes: 0 };
81
+ // each image fetches its original once, makes the sizes it lacks and puts its objects, eight images at a time
82
+ await pool(images, 8, async im => {
83
+ const original = await originalOf(im, reader, !missing.has(im.keys.original));
84
+ const ext = path.extname(im.keys.original);
85
+ let bytes = missing.has(im.keys.original) ? await put(im.keys.original, original, contentType(ext)) : 0;
86
+ if (missing.has(im.keys.large) || missing.has(im.keys.small)) {
87
+ const sizes = await webpSizes(original, ext);
88
+ for (const name of Object.keys(SIZES)) if (missing.has(im.keys[name])) bytes += await put(im.keys[name], sizes[name], 'image/webp');
89
+ }
90
+ sum.images++; sum.imageBytes += bytes;
91
+ });
92
+ // the engine makes its missing objects into a folder of their own, and each goes up under its key
93
+ if (objects.length) {
94
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kitap-objects-'));
95
+ try {
96
+ await e.make({ stories: [{ ...s, groups: Object.fromEntries(Object.entries(s.groups).map(([g, keys]) => [g, keys.filter(k => missing.has(k))])) }], cache, out: dir, log, options });
97
+ await pool(objects, 8, async k => { sum.tileBytes += await put(k, fs.readFileSync(path.join(dir, k)), typeOf(k)); sum.tiles++; });
98
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
99
+ }
100
+ progress({ ...cur, done: true });
101
+ // the tree goes last, once every object it names is in the store, and latest.json names it
102
+ const tree = Buffer.from(JSON.stringify(s.tree));
103
+ await writer.put(`meta/${s.story}/${s.tree.root}.json`, tree, 'application/json');
104
+ await writer.put(`meta/${s.story}/latest.json`, Buffer.from(JSON.stringify({ format: s.tree.format, story: s.story, root: s.tree.root })), 'application/json', 'no-cache');
105
+ log(`${s.story}: uploaded, its tree is meta/${s.story}/${s.tree.root}.json`);
106
+ }
107
+ const inStore = [...present].filter(k => !done.has(k)).length;
108
+ // the engine's objects in its own words, such as "198 elevation tiles", else by count
109
+ const others = [...(dryRun ? planned : done)].filter(k => !k.startsWith('images/') && !k.startsWith('meta/'));
110
+ const described = e.describeObjects ? e.describeObjects(others) : plural(others.length, 'other object');
111
+ log(dryRun ? `would upload ${plural(sum.images, 'image')} and ${described}, ${inStore.toLocaleString('en')} objects are in the store`
112
+ : `uploaded ${plural(sum.images, 'image')} (${mb(sum.imageBytes)}) and ${described} (${mb(sum.tileBytes)}), ${inStore.toLocaleString('en')} were in the store`);
113
+ return [...(dryRun ? planned : done)];
114
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // Small helpers the build, harita zones and harita image share.
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+
6
+ export const hash = value => crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
7
+ export const listDirs = p => fs.existsSync(p) ? fs.readdirSync(p).filter(d => fs.statSync(path.join(p, d)).isDirectory()).sort() : [];
8
+ // a folder's id: its name without the number that orders it
9
+ export const idOf = name => name.replace(/^\d+-/, '');
10
+ export const boxesMeet = (a, b) => a[0] <= b[2] && b[0] <= a[2] && a[1] <= b[3] && b[1] <= a[3];
11
+ export const mb = bytes => `${(bytes / 1e6).toFixed(1)} MB`;
12
+ export const plural = (n, word) => `${n.toLocaleString('en')} ${word}${n === 1 ? '' : 's'}`;
13
+ // Progress of an upload: on a terminal one line rewritten in place, elsewhere a line every thousand objects. what names
14
+ // the story or the folder going up, and done clears the line once its summary is about to print.
15
+ export const progressLine = ({ what, n, total, bytes, done }) => {
16
+ const line = `${what}: ${n.toLocaleString('en')} of ${total.toLocaleString('en')} objects, ${mb(bytes)}`;
17
+ if (process.stdout.isTTY) process.stdout.write(`\r\x1b[K${done ? '' : line}`);
18
+ else if (!done && n % 1000 === 0) console.log(line);
19
+ };
package/src/vendor.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // The scripts and fonts the pages load, copied from the packages of kitap and of the engine into dist/vendor/, so a
2
+ // page loads nothing from another host. A package's folder names its version, so a browser can keep its files for good.
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { createRequire } from 'module';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+
11
+ // a package's folder and version, found up from its main file, since some packages export no package.json. resolve is
12
+ // the require of the package that depends on it: kitap's for the fonts, the engine's for its libraries.
13
+ function pkg(name, resolve = require.resolve) {
14
+ for (let dir = path.dirname(resolve(name)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
15
+ const file = path.join(dir, 'package.json');
16
+ if (fs.existsSync(file)) { const meta = JSON.parse(fs.readFileSync(file, 'utf8')); if (meta.name === name && meta.version) return { dir, version: meta.version }; }
17
+ }
18
+ throw new Error(`the package ${name} is missing, reinstall the engine`);
19
+ }
20
+ const folder = (name, resolve) => `${name.split('/').pop()}@${pkg(name, resolve).version}`;
21
+
22
+ // the subsets the pages need: Latin, and Latin Extended for Turkish and the other languages written in Latin letters
23
+ const FONTS = [
24
+ { family: 'Fraunces', name: '@fontsource-variable/fraunces', css: 'opsz.css', files: /^fraunces-latin(-ext)?-opsz-normal$/ },
25
+ { family: 'Public Sans', name: '@fontsource-variable/public-sans', css: 'index.css', files: /^public-sans-latin(-ext)?-wght-normal$/ },
26
+ ];
27
+ const licence = dir => ['LICENSE', 'LICENSE.txt', 'LICENSE.md'].map(f => path.join(dir, f)).find(f => fs.existsSync(f));
28
+
29
+ // Writes dist/vendor/: the engine's libraries, each [package, file] of engine.client.libraries resolved from the
30
+ // engine's own package, the fonts, and whatever the engine's vendor hook copies. Returns the libraries' paths under
31
+ // dist/ in order, for the story page's <script> tags.
32
+ export function writeVendor(dist, engine = null) {
33
+ const out = path.join(dist, 'vendor');
34
+ fs.rmSync(out, { recursive: true, force: true });
35
+ const copy = (from, to) => { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); };
36
+ const paths = [];
37
+ const resolve = engine?.dir ? createRequire(path.join(engine.dir, 'package.json')).resolve : require.resolve;
38
+ // a library is [package, file] for a script the page loads, or [package, folder] for a folder a module engine imports from
39
+ const imports = {};
40
+ const vendored = (name, file) => {
41
+ const { dir } = pkg(name, resolve), to = path.join(out, folder(name, resolve)), from = path.join(dir, file), base = path.basename(file.replace(/\/$/, ''));
42
+ const isDir = fs.statSync(from).isDirectory();
43
+ if (isDir) fs.cpSync(from, path.join(to, base), { recursive: true }); else copy(from, path.join(to, base));
44
+ const lic = licence(dir) ?? licence(path.join(dir, path.dirname(file)));
45
+ if (lic) copy(lic, path.join(to, path.basename(lic)));
46
+ return `vendor/${folder(name, resolve)}/${base}${isDir ? '/' : ''}`;
47
+ };
48
+ for (const [name, file] of engine?.client?.libraries ?? []) paths.push(vendored(name, file));
49
+ // an import is [package, file], or [package, folder, file] when the file imports its siblings
50
+ for (const [spec, [name, file, entry]] of Object.entries(engine?.client?.imports ?? {})) imports[spec] = entry ? `${vendored(name, file)}${entry}` : vendored(name, file);
51
+ // the packages' @font-face rules for the subsets above, under the family names the pages use
52
+ const rules = [];
53
+ for (const f of FONTS) {
54
+ const { dir } = pkg(f.name), to = folder(f.name);
55
+ for (const block of fs.readFileSync(path.join(dir, f.css), 'utf8').split('/* ').slice(1)) {
56
+ const id = block.slice(0, block.indexOf(' */'));
57
+ if (!f.files.test(id)) continue;
58
+ copy(path.join(dir, 'files', `${id}.woff2`), path.join(out, to, `${id}.woff2`));
59
+ rules.push(block.slice(block.indexOf('@font-face')).trim()
60
+ .replace(/font-family: '[^']*'/, `font-family: '${f.family}'`).replace(/url\(\.\/files\//, `url(${to}/`));
61
+ }
62
+ copy(licence(dir), path.join(out, to, 'LICENSE'));
63
+ }
64
+ fs.writeFileSync(path.join(out, 'fonts.css'), rules.join('\n') + '\n');
65
+ engine?.vendor?.(out);
66
+ return { paths: paths.filter(p => !p.endsWith('/')), imports };
67
+ }
package/src/worker.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // The Worker that serves the site from the pages bucket. A content project deploys `export default site()` with the
2
+ // bucket bound as SITE. It answers paths as harita dev does, see routes.mjs, and keeps each answer in the data
3
+ // centre's cache for the ten minutes a reader's browser keeps it.
4
+ import { resolve } from './routes.mjs';
5
+
6
+ const CACHE = 'public, max-age=600';
7
+ // a folder of dist/vendor/ names its package's version, so its files never change
8
+ const cacheFor = key => /^vendor\/[^/]+@[^/]+\//.test(key) ? 'public, max-age=31536000, immutable' : CACHE;
9
+
10
+ // The answer to a request's URL from the bucket, with the object's Content-Type and ETag.
11
+ async function answer(bucket, url) {
12
+ let path;
13
+ try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
14
+ // the object a path names is read once: the check and the answer share it
15
+ const objects = new Map();
16
+ const exists = async key => { if (!objects.has(key)) objects.set(key, await bucket.get(key)); return Boolean(objects.get(key)); };
17
+ const redirect = async p => (await (await bucket.get('redirects.json'))?.json())?.[p] ?? null;
18
+ const r = await resolve(path, exists, { redirect, query: url.search });
19
+ if (r.location) return new Response(null, { status: 301, headers: { location: r.location, 'cache-control': CACHE } });
20
+ const obj = objects.get(r.key) ?? await bucket.get(r.key);
21
+ if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': CACHE } });
22
+ return new Response(obj.body, { status: r.status, headers: { 'content-type': obj.httpMetadata?.contentType ?? 'application/octet-stream', etag: obj.httpEtag, 'cache-control': cacheFor(r.key) } });
23
+ }
24
+
25
+ // A 304 for a page the browser holds, else the answer, without its body for HEAD.
26
+ function reply(request, res) {
27
+ const etag = res.headers.get('etag'), given = request.headers.get('if-none-match');
28
+ if (res.status === 200 && etag && given && given.split(',').map(s => s.trim()).some(t => t === etag || t === '*'))
29
+ return new Response(null, { status: 304, headers: { etag, 'cache-control': res.headers.get('cache-control') } });
30
+ return request.method === 'HEAD' ? new Response(null, res) : res;
31
+ }
32
+
33
+ // The Worker: fetch(request, env, ctx) with env.SITE the bucket. The data centre's cache holds each URL's answer.
34
+ export function site() {
35
+ return {
36
+ async fetch(request, env, ctx) {
37
+ if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
38
+ const url = new URL(request.url), cache = globalThis.caches?.default ?? null, cacheKey = new Request(url.href);
39
+ let res = cache ? await cache.match(cacheKey) : null;
40
+ if (!res) {
41
+ res = await answer(env.SITE, url);
42
+ if (cache) { const keep = cache.put(cacheKey, res.clone()); ctx?.waitUntil ? ctx.waitUntil(keep) : await keep; }
43
+ }
44
+ return reply(request, res);
45
+ },
46
+ };
47
+ }