@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/image.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Image folders: content/shared/images/<id>/ for every story, content/<story>/shared/images/<id>/ for one. Each holds
|
|
2
|
+
// image.yaml with the caption, the credit, where the original came from, its file name and sha256. The original,
|
|
3
|
+
// image.<ext>, stays on the machine that added it, and the asset store holds it with its WebP sizes.
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { libraryItems, storyFolders, isImage } from './folders.mjs';
|
|
9
|
+
import { check, ImageFolder } from './schema.mjs';
|
|
10
|
+
|
|
11
|
+
export const SIZES_VERSION = 1; // part of the keys of the WebP sizes, bumped when the sizes or their encoding change
|
|
12
|
+
export const MAX_BYTES = 20e6;
|
|
13
|
+
const TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
|
|
14
|
+
export const contentType = ext => TYPES[ext] ?? 'application/octet-stream';
|
|
15
|
+
const FORMAT = { '.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.gif': 'gif', '.webp': 'webp', '.svg': 'svg' };
|
|
16
|
+
|
|
17
|
+
export const sha256Of = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
export const sha256 = file => sha256Of(fs.readFileSync(file));
|
|
19
|
+
// The keys of an image's objects in the asset store, from its folder id and the file and sha256 of its image.yaml.
|
|
20
|
+
export const imageKeys = (id, { file, sha256: hash }) => ({
|
|
21
|
+
original: `images/${hash}/${id}${path.extname(file)}`,
|
|
22
|
+
large: `images/${hash}/s${SIZES_VERSION}/${id}-1280.webp`,
|
|
23
|
+
small: `images/${hash}/s${SIZES_VERSION}/${id}-320.webp`,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The format the bytes hold, by their first bytes, or null.
|
|
27
|
+
function formatOf(b) {
|
|
28
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
|
|
29
|
+
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
|
|
30
|
+
if (b.toString('latin1', 0, 4) === 'GIF8') return 'gif';
|
|
31
|
+
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'webp';
|
|
32
|
+
// an XML declaration, comments and a doctype may come before the svg element
|
|
33
|
+
const head = b.toString('utf8', 0, 65536);
|
|
34
|
+
if (/^?\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*|<!DOCTYPE[^[>]*(\[[\s\S]*?\])?\s*>\s*)*<svg[\s>]/i.test(head)) return 'svg';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// true when an EXIF block holds a position: IFD0 points at a GPS IFD with a latitude or a longitude in it
|
|
39
|
+
function exifHasPosition(exif) {
|
|
40
|
+
const t = exif.toString('latin1', 0, 6) === 'Exif\0\0' ? exif.subarray(6) : exif;
|
|
41
|
+
if (t.length < 8) return false;
|
|
42
|
+
const le = t.toString('latin1', 0, 2) === 'II';
|
|
43
|
+
const u16 = o => le ? t.readUInt16LE(o) : t.readUInt16BE(o), u32 = o => le ? t.readUInt32LE(o) : t.readUInt32BE(o);
|
|
44
|
+
const tags = ifd => {
|
|
45
|
+
const found = new Map();
|
|
46
|
+
if (ifd + 2 > t.length) return found;
|
|
47
|
+
for (let i = 0, n = u16(ifd); i < n && ifd + 14 + i * 12 <= t.length; i++) found.set(u16(ifd + 2 + i * 12), u32(ifd + 10 + i * 12));
|
|
48
|
+
return found;
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
const gps = tags(u32(4)).get(0x8825);
|
|
52
|
+
if (gps == null) return false;
|
|
53
|
+
const g = tags(gps);
|
|
54
|
+
return g.has(2) || g.has(4);
|
|
55
|
+
} catch { return false; } // a block too short for its own offsets holds no position
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// What an SVG must not hold: anything that runs code or loads a file when the SVG is opened.
|
|
59
|
+
const SVG_RULES = [
|
|
60
|
+
[/<script[\s>/]/i, () => 'a script'],
|
|
61
|
+
[/\s(on[a-z]+)\s*=/i, m => `the event attribute ${m[1]}`],
|
|
62
|
+
[/\s((?:xlink:)?href|src)\s*=\s*["']\s*(?!#|data:)([^"']*)/i, m => `a reference to ${m[2] || 'another file'}`],
|
|
63
|
+
[/url\(\s*["']?\s*(?!#|data:)([^)"']*)/i, m => `a reference to ${m[1]}`],
|
|
64
|
+
[/@import\b/i, () => 'a stylesheet import'],
|
|
65
|
+
[/<!ENTITY[^>]*\b(SYSTEM|PUBLIC)\b/i, () => 'an outside entity'],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Refuses an original over 20 MB, one whose bytes are not the format its extension names, one with a position in its
|
|
69
|
+
// EXIF or XMP metadata, and an SVG that holds a script, an event attribute or a reference outside the file.
|
|
70
|
+
export async function checkOriginal(bytes, ext, where) {
|
|
71
|
+
if (bytes.length > MAX_BYTES) throw new Error(`${where}: ${(bytes.length / 1e6).toFixed(1)} MB, an original is at most ${MAX_BYTES / 1e6} MB, add a smaller copy`);
|
|
72
|
+
const want = FORMAT[ext.toLowerCase()], got = formatOf(bytes);
|
|
73
|
+
if (got !== want) throw new Error(`${where}: the file ends in ${ext} and holds ${got ? `${got.toUpperCase()} bytes` : 'bytes of no image format'}, add it with the extension of its format`);
|
|
74
|
+
if (got === 'svg') {
|
|
75
|
+
const text = bytes.toString('utf8');
|
|
76
|
+
for (const [re, what] of SVG_RULES) {
|
|
77
|
+
const m = text.match(re);
|
|
78
|
+
if (m) throw new Error(`${where}: the SVG holds ${what(m)}, remove it and add the file again`);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const sharp = (await import('sharp')).default;
|
|
83
|
+
let meta;
|
|
84
|
+
try { meta = await sharp(bytes).metadata(); } catch (err) { throw new Error(`${where}: the ${got.toUpperCase()} does not read (${err.message})`); }
|
|
85
|
+
const xmp = meta.xmp ? meta.xmp.toString('utf8') : '';
|
|
86
|
+
if ((meta.exif && exifHasPosition(meta.exif)) || /GPS(Latitude|Longitude)/.test(xmp))
|
|
87
|
+
throw new Error(`${where}: the file holds the place it was taken in its metadata, remove the location and add the file again`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// a source as a page address, so File%3AA%20b.jpg over http and File:A_b.jpg over https are one source, and
|
|
91
|
+
// object.php?id=101 and ?id=202 are two
|
|
92
|
+
const sameSource = url => { try { const u = new URL(url); return u.host + decodeURIComponent(u.pathname).replace(/ /g, '_') + u.search; } catch { return url; } };
|
|
93
|
+
const readMeta = dir => { try { return yaml.load(fs.readFileSync(path.join(dir, 'image.yaml'), 'utf8')) ?? {}; } catch { return {}; } };
|
|
94
|
+
// a field of image.yaml set in place, so the other lines and their comments stay. A new field goes before the field
|
|
95
|
+
// named by before, else at the end.
|
|
96
|
+
function setField(dir, key, value, before = null) {
|
|
97
|
+
const file = path.join(dir, 'image.yaml'), text = fs.readFileSync(file, 'utf8'), line = `${key}: ${value}`, at = new RegExp(`^${key}:.*$`, 'm');
|
|
98
|
+
const next = before && text.match(new RegExp(`^${before}:`, 'm'));
|
|
99
|
+
fs.writeFileSync(file, at.test(text) ? text.replace(at, line) : next ? text.slice(0, next.index) + line + '\n' + text.slice(next.index) : text.replace(/\n?$/, '\n') + line + '\n');
|
|
100
|
+
}
|
|
101
|
+
const removeField = (dir, key) => { const file = path.join(dir, 'image.yaml'); fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(new RegExp(`^${key}:.*\n?`, 'm'), '')); };
|
|
102
|
+
|
|
103
|
+
// Every image folder the content holds, with its stored fields. file and size describe the original on this machine,
|
|
104
|
+
// and are null where it is elsewhere.
|
|
105
|
+
export function imageIndex({ root = process.cwd() } = {}) {
|
|
106
|
+
const content = path.join(root, 'content'), index = [];
|
|
107
|
+
// the image folders of a library, in its groups too
|
|
108
|
+
const folders = (dir, story) => {
|
|
109
|
+
for (const [id, d] of libraryItems(dir, isImage, p => path.relative(root, p))) {
|
|
110
|
+
const m = readMeta(d), files = fs.readdirSync(d).filter(f => /^image\.\w+$/.test(f) && f !== 'image.yaml');
|
|
111
|
+
const f = files.includes(m.file) ? m.file : files[0], file = f ? path.join(d, f) : null;
|
|
112
|
+
index.push({ id, story, dir: d, meta: fs.existsSync(path.join(d, 'image.yaml')), file, size: file ? fs.statSync(file).size : null,
|
|
113
|
+
source: m.source ?? null, url: m.url ?? null, name: m.file ?? null, sha256: m.sha256 ?? null });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
folders(path.join(content, 'shared', 'images'), null);
|
|
117
|
+
for (const [s, d] of storyFolders(content, p => path.relative(root, p))) folders(path.join(d, 'shared', 'images'), s);
|
|
118
|
+
return index;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The image the content already holds for a source or an original: the same source first, then the same bytes, by
|
|
122
|
+
// the sha256 of file or the sha256 given. Returns the index entry with by: 'source' or 'sha256', or null.
|
|
123
|
+
export function findImage(index, { source = null, file = null, sha256: hash = file ? sha256(file) : null } = {}) {
|
|
124
|
+
if (source) {
|
|
125
|
+
const want = sameSource(source), hit = index.find(i => i.source && sameSource(i.source) === want);
|
|
126
|
+
if (hit) return { ...hit, by: 'source' };
|
|
127
|
+
}
|
|
128
|
+
if (hash) {
|
|
129
|
+
// a folder without a sha256 hashes its original, which only happens before kitap rehash
|
|
130
|
+
const hit = index.find(i => (i.sha256 ?? (i.file ? sha256(i.file) : null)) === hash);
|
|
131
|
+
if (hit) return { ...hit, by: 'sha256' };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The original behind a URL and its extension: the one the path ends in, else the one of the type the server sends.
|
|
137
|
+
export async function downloadOriginal(url) {
|
|
138
|
+
let res;
|
|
139
|
+
try { res = await fetch(url); } catch (err) { throw new Error(`${url}: ${err.cause?.message ?? err.message}`); }
|
|
140
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
141
|
+
if (Number(res.headers.get('content-length')) > MAX_BYTES) throw new Error(`${url}: an original is at most ${MAX_BYTES / 1e6} MB, add a smaller copy`);
|
|
142
|
+
const bytes = Buffer.from(await res.arrayBuffer()), named = path.extname(decodeURIComponent(new URL(url).pathname)).toLowerCase();
|
|
143
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
144
|
+
const ext = FORMAT[named] ? named : Object.keys(TYPES).find(e => e !== '.jpeg' && TYPES[e] === type);
|
|
145
|
+
if (!ext) throw new Error(`${url}: an image is a .jpg, .jpeg, .png, .webp, .svg or .gif file, and this one is ${type || 'of no known type'}`);
|
|
146
|
+
return { bytes, ext };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// kitap image: the folder that holds the same source or bytes, moved to content/shared/images/ when another story owns
|
|
150
|
+
// it, else a new one named <name>-<six random hex digits>, so branches adding images at once never clash. file is a
|
|
151
|
+
// path or a URL to download the original from. Returns the id.
|
|
152
|
+
export async function image({ root = process.cwd(), file, name, caption, credit = null, source = null, story = null, log = console.log } = {}) {
|
|
153
|
+
if (!file || !name || !caption) throw new Error('usage: kitap image <file or url> <name> --caption <text> [--credit <text>] [--source <url>] [--story <id>]');
|
|
154
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`"${name}": a name is lowercase letters, digits and dashes, such as hastings-knights`);
|
|
155
|
+
const url = /^https?:\/\//i.test(file) ? file : null;
|
|
156
|
+
if (!url && !fs.existsSync(file)) throw new Error(`${file}: no such file`);
|
|
157
|
+
if (!url && !FORMAT[path.extname(file).toLowerCase()]) throw new Error(`${file}: an image is a .jpg, .jpeg, .png, .webp, .svg or .gif file`);
|
|
158
|
+
const stories = storyFolders(path.join(root, 'content'), p => path.relative(root, p));
|
|
159
|
+
if (!stories.size) throw new Error('content/: no story.yaml found, run kitap image from a content project');
|
|
160
|
+
if (story && !stories.has(story)) throw new Error(`"${story}" is not a story folder under content/`);
|
|
161
|
+
// the fields as image.yaml will hold them, checked before anything is fetched or written
|
|
162
|
+
const fields = { caption, ...(credit ? { credit } : {}), ...(source ? { source } : {}), ...(url ? { url } : {}) };
|
|
163
|
+
check(ImageFolder.partial({ file: true, sha256: true }), fields, '--caption, --credit and --source');
|
|
164
|
+
const { bytes, ext } = url ? await downloadOriginal(url) : { bytes: fs.readFileSync(file), ext: path.extname(file).toLowerCase() };
|
|
165
|
+
await checkOriginal(bytes, ext, file);
|
|
166
|
+
const hash = sha256Of(bytes), rel = p => path.relative(root, p), common = path.join(root, 'content', 'shared', 'images');
|
|
167
|
+
const index = imageIndex({ root }), hit = findImage(index, { source, sha256: hash });
|
|
168
|
+
if (hit) {
|
|
169
|
+
let dir = hit.dir;
|
|
170
|
+
// a folder of another story moves where both stories see it, and every reference to it keeps working
|
|
171
|
+
if (hit.story && hit.story !== story) {
|
|
172
|
+
dir = path.join(common, hit.id);
|
|
173
|
+
if (fs.existsSync(dir) || index.some(i => i.id === hit.id && i.dir !== hit.dir)) throw new Error(`${rel(hit.dir)} holds this image and another folder is named ${hit.id} too, keep one`);
|
|
174
|
+
fs.mkdirSync(common, { recursive: true });
|
|
175
|
+
fs.renameSync(hit.dir, dir);
|
|
176
|
+
log(`moved ${rel(hit.dir)}/ to ${rel(dir)}/, it holds the same ${hit.by === 'source' ? 'source' : 'file'}`);
|
|
177
|
+
} else log(`${rel(dir)}/ holds the same ${hit.by === 'source' ? 'source' : 'file'}`);
|
|
178
|
+
if (source && !hit.source) setField(dir, 'source', source);
|
|
179
|
+
log(`name it as ${hit.id}`);
|
|
180
|
+
return hit.id;
|
|
181
|
+
}
|
|
182
|
+
const base = story ? path.join(stories.get(story), 'shared', 'images') : common;
|
|
183
|
+
let id;
|
|
184
|
+
// a fresh suffix, unused in every library and group
|
|
185
|
+
do id = `${name}-${crypto.randomBytes(3).toString('hex')}`; while (index.some(i => i.id === id));
|
|
186
|
+
const dir = path.join(base, id), original = 'image' + ext;
|
|
187
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
+
fs.writeFileSync(path.join(dir, original), bytes);
|
|
189
|
+
// the caption is in the story's default language, or for a shared image the site's
|
|
190
|
+
const settingsFile = story ? path.join(stories.get(story), 'story.yaml') : path.join(root, 'site.yaml');
|
|
191
|
+
const settings = fs.existsSync(settingsFile) ? yaml.load(fs.readFileSync(settingsFile, 'utf8')) ?? {} : {};
|
|
192
|
+
const lang = settings.default_language ?? settings.languages?.[0] ?? 'en';
|
|
193
|
+
fs.writeFileSync(path.join(dir, 'image.yaml'), yaml.dump({ ...fields, ...(lang !== 'en' ? { default_language: lang } : {}), file: original, sha256: hash }, { lineWidth: -1 }));
|
|
194
|
+
log(`wrote ${rel(dir)}/, name it as ${id}`);
|
|
195
|
+
return id;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// kitap rehash: writes the file and sha256 of every image folder whose original is on this machine into its
|
|
199
|
+
// image.yaml, where they are missing or no longer match. A new sha256 drops url, which downloads the old bytes.
|
|
200
|
+
// Returns the folders it wrote.
|
|
201
|
+
export function rehash({ root = process.cwd(), log = console.log } = {}) {
|
|
202
|
+
const written = [];
|
|
203
|
+
for (const i of imageIndex({ root })) {
|
|
204
|
+
if (!i.meta) { log(`${path.relative(root, i.dir)}: image.yaml is missing`); continue; }
|
|
205
|
+
if (!i.file) continue;
|
|
206
|
+
const hash = sha256(i.file), name = path.basename(i.file);
|
|
207
|
+
if (i.sha256 === hash && i.name === name) continue;
|
|
208
|
+
if (i.name !== name) setField(i.dir, 'file', name, 'sha256');
|
|
209
|
+
if (i.sha256 !== hash) { setField(i.dir, 'sha256', hash); if (i.url) removeField(i.dir, 'url'); }
|
|
210
|
+
written.push(path.relative(root, i.dir));
|
|
211
|
+
}
|
|
212
|
+
log(`wrote the file and sha256 of ${written.length} image folder${written.length === 1 ? '' : 's'}`);
|
|
213
|
+
return written;
|
|
214
|
+
}
|
package/src/index.html
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<!--__HEAD__-->
|
|
7
|
+
<link rel="stylesheet" href="vendor/fonts.css">
|
|
8
|
+
<style>
|
|
9
|
+
:root{
|
|
10
|
+
--display:"Fraunces",Georgia,"Times New Roman",serif;
|
|
11
|
+
--body:"Public Sans","Helvetica Neue",Arial,sans-serif;
|
|
12
|
+
}
|
|
13
|
+
/*__THEMES_CSS__*/
|
|
14
|
+
html{font-size:clamp(14px, 0.4545vw + 7.2px, 19px)}
|
|
15
|
+
*{box-sizing:border-box}
|
|
16
|
+
[hidden]{display:none!important}
|
|
17
|
+
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--body);font-size:1.07rem;line-height:1.5;padding-inline:1.143rem;padding-block:.857rem}
|
|
18
|
+
.wrap{max-width:68rem;margin:0 auto;display:flex;flex-direction:column;gap:2rem}
|
|
19
|
+
h1,h2{font-family:var(--display);font-weight:500;margin:0;text-wrap:balance}
|
|
20
|
+
header{display:flex;align-items:center;gap:1.143rem;flex-wrap:wrap;border-bottom:1px solid var(--rule);padding-bottom:.714rem}
|
|
21
|
+
header h1{font-size:1.857rem}
|
|
22
|
+
header .controls{margin-inline-start:auto;display:flex;gap:.571rem}
|
|
23
|
+
header a.contribute{color:var(--ink-2);font-size:.929rem;text-decoration:none;border:1px solid var(--rule);border-radius:4px;padding:.357rem .714rem;display:inline-flex;align-items:center}
|
|
24
|
+
header a.contribute:hover{color:var(--ink)}
|
|
25
|
+
header a.contribute:focus-visible{outline:2px solid var(--accent)}
|
|
26
|
+
header .prefs{position:relative}
|
|
27
|
+
#settings-btn{background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:4px;padding:.357rem .714rem;font:inherit;font-size:.929rem;cursor:pointer;display:inline-flex;align-items:center;gap:.357rem}
|
|
28
|
+
#settings-btn svg{width:1rem;height:1rem}
|
|
29
|
+
#settings-btn:focus-visible{outline:2px solid var(--accent)}
|
|
30
|
+
/* the language beside the settings, as on a story page */
|
|
31
|
+
.langpick{display:inline-flex;align-items:center;gap:.143rem;background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:4px;padding-inline-start:.714rem;font-size:.929rem}
|
|
32
|
+
.langpick svg{width:1rem;height:1rem;flex:none}
|
|
33
|
+
.langpick select{background:var(--panel);color:inherit;border:0;border-radius:4px;font:inherit;padding:.357rem .571rem;cursor:pointer}
|
|
34
|
+
.langpick:focus-within{outline:2px solid var(--accent)}
|
|
35
|
+
.langpick select:focus{outline:none}
|
|
36
|
+
/* settings: the same panel as on a story page, the layout and the theme, under its button */
|
|
37
|
+
.settings{position:absolute;top:calc(100% + .429rem);inset-inline-end:0;z-index:8;width:min(24rem,calc(100vw - 2.286rem));display:grid;gap:.857rem;
|
|
38
|
+
background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.857rem;box-shadow:0 4px 16px rgba(0,0,0,.25)}
|
|
39
|
+
.settings .group{display:grid;gap:.357rem}
|
|
40
|
+
.settings .group > span{font-size:.786rem;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2)}
|
|
41
|
+
.layouts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.429rem}
|
|
42
|
+
.layouts button{display:grid;justify-items:center;align-content:start;gap:.286rem;padding:.429rem;font:inherit;font-size:.786rem;line-height:1.25;text-align:center;
|
|
43
|
+
background:var(--paper);color:var(--ink);border:1px solid var(--rule);border-radius:6px;cursor:pointer}
|
|
44
|
+
.layouts button svg{width:100%;height:auto;max-width:4.6rem}
|
|
45
|
+
.layouts button[aria-checked="true"]{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
|
|
46
|
+
.layouts button:focus-visible{outline:2px solid var(--accent)}
|
|
47
|
+
@media (max-width:900px){ .settings .layout-group{display:none} } /* a phone has one layout */
|
|
48
|
+
select#theme{width:100%;min-height:max(40px,2.857rem);background:var(--paper);color:var(--ink);border:1px solid var(--rule);border-radius:4px;padding:0 .571rem;font:inherit;cursor:pointer}
|
|
49
|
+
select#theme:focus-visible{outline:2px solid var(--accent)}
|
|
50
|
+
.intro{max-width:62ch;color:var(--ink-2);margin:0}
|
|
51
|
+
.listbar{display:flex;justify-content:space-between;align-items:center;gap:.857rem;flex-wrap:wrap}
|
|
52
|
+
.listbar input{flex:1;max-width:24rem;min-height:max(40px,2.857rem);background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:6px;padding:0 .857rem;font:inherit}
|
|
53
|
+
.listbar input::placeholder{color:var(--ink-2)}
|
|
54
|
+
.listbar input:focus-visible{outline:2px solid var(--accent);outline-offset:-1px}
|
|
55
|
+
.rows .empty{padding:1.143rem .571rem;color:var(--ink-2)}
|
|
56
|
+
.listbar button,.toggle-all{background:none;border:1px solid var(--rule);border-radius:4px;color:var(--ink-2);font:inherit;font-size:.857rem;padding:.286rem .714rem;cursor:pointer}
|
|
57
|
+
.listbar button:hover,.toggle-all:hover{color:var(--ink)}
|
|
58
|
+
.listbar button:focus-visible,.toggle-all:focus-visible{outline:2px solid var(--accent)}
|
|
59
|
+
.rows{list-style:none;margin:0;padding:0;border-top:1px solid var(--rule)}
|
|
60
|
+
.row{border-bottom:1px solid var(--rule)}
|
|
61
|
+
.row > button{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.286rem 1.143rem;align-items:baseline;width:100%;text-align:start;background:none;border:0;
|
|
62
|
+
color:var(--ink);font:inherit;padding:1rem .571rem;cursor:pointer}
|
|
63
|
+
.row > button:hover{background:var(--panel)}
|
|
64
|
+
.row > button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
65
|
+
.row .span{grid-column:2;grid-row:1;font-size:.857rem;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);font-variant-numeric:tabular-nums}
|
|
66
|
+
.row h2{grid-column:3;grid-row:1;font-size:1.429rem;line-height:1.2}
|
|
67
|
+
.row .brief{grid-column:2/4;grid-row:2;color:var(--ink-2);font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
68
|
+
.row .chev{grid-column:1;grid-row:1/3;align-self:center;width:0;height:0;border:5px solid transparent;border-inline-start:6px solid var(--ink-2);transition:transform .15s}
|
|
69
|
+
.row > button[aria-expanded="true"] .chev{transform:rotate(90deg)}
|
|
70
|
+
:root[dir="rtl"] .row > button[aria-expanded="true"] .chev{transform:rotate(-90deg)}
|
|
71
|
+
.row > button[aria-expanded="true"] .brief{visibility:hidden}
|
|
72
|
+
.row .detail{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:1.429rem;padding:0 .571rem 1.286rem}
|
|
73
|
+
.row .detail p{margin:0;max-width:62ch}
|
|
74
|
+
.row .detail .meta{display:flex;gap:.857rem;font-size:.857rem;color:var(--ink-2);margin-top:.857rem}
|
|
75
|
+
.row .detail .open{display:inline-block;margin-top:1rem;background:var(--ink);color:var(--paper);text-decoration:none;padding:.5rem 1rem;border-radius:4px;font-size:1rem}
|
|
76
|
+
.row .detail .open:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
77
|
+
.row .cover{aspect-ratio:16/10;background:var(--paper-2);border:1px solid var(--rule);display:grid;place-items:center;overflow:hidden}
|
|
78
|
+
.row .cover img{width:100%;height:100%;object-fit:cover;display:block}
|
|
79
|
+
.credit{font-size:.857rem;color:var(--ink-2);border-top:1px solid var(--rule);padding-top:.714rem}
|
|
80
|
+
.credit a{color:inherit}
|
|
81
|
+
.row .cover span{font-family:var(--display);font-size:1.571rem;color:var(--ink-2);padding:.857rem;text-align:center}
|
|
82
|
+
@media (max-width:640px){
|
|
83
|
+
.row > button{grid-template-columns:auto minmax(0,1fr)}
|
|
84
|
+
.row .span{grid-column:2;grid-row:1}
|
|
85
|
+
.row h2{grid-column:2;grid-row:2}
|
|
86
|
+
.row .brief{grid-column:2;grid-row:3}
|
|
87
|
+
.row .chev{grid-column:1;grid-row:1/4}
|
|
88
|
+
.row .detail{grid-template-columns:1fr}
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div class="wrap">
|
|
94
|
+
<header>
|
|
95
|
+
<h1 id="title"></h1>
|
|
96
|
+
<div class="controls"><a class="contribute" id="contribute" target="_blank" rel="noopener" hidden></a>
|
|
97
|
+
<label class="langpick" id="langpick"><span id="lang-icon" aria-hidden="true"></span><select id="lang"></select></label>
|
|
98
|
+
<div class="prefs">
|
|
99
|
+
<button type="button" id="settings-btn" aria-expanded="false" aria-controls="settings"></button>
|
|
100
|
+
<div class="settings" id="settings" hidden>
|
|
101
|
+
<div class="group layout-group"><span id="layout-label"></span><div class="layouts" id="layouts" role="radiogroup"></div></div>
|
|
102
|
+
<label class="group"><span id="theme-label"></span><select id="theme"></select></label>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</header>
|
|
107
|
+
<p class="intro" id="intro"></p>
|
|
108
|
+
<div class="listbar"><input type="search" id="search" autocomplete="off" spellcheck="false"><button type="button" id="toggle-all"></button></div>
|
|
109
|
+
<ul class="rows" id="rows"></ul>
|
|
110
|
+
<footer class="credit" id="credit"></footer>
|
|
111
|
+
</div>
|
|
112
|
+
<script>
|
|
113
|
+
/*__COMMON__*/
|
|
114
|
+
const SITE = null;
|
|
115
|
+
const S = SITE;
|
|
116
|
+
// the language the URL names, else the reader's
|
|
117
|
+
let lang = ROUTE?.lang ?? preferredLang(S.languages, S.defaultLanguage);
|
|
118
|
+
// a story opens in the main page's language when it has it, else in its own default
|
|
119
|
+
const storyLang = s => s.languages.includes(lang) ? lang : s.defaultLanguage;
|
|
120
|
+
let palette = pref('palette', S.themes.map(t => t.id), S.defaultTheme);
|
|
121
|
+
document.documentElement.dataset.palette = palette;
|
|
122
|
+
const L = field => pick(field, lang, S.defaultLanguage);
|
|
123
|
+
const open = new Set();
|
|
124
|
+
// search: a case insensitive substring of the title in the current language, folded by that language's rules
|
|
125
|
+
let query = '';
|
|
126
|
+
const visible = () => { const q = query.trim().toLocaleLowerCase(lang); return q ? S.stories.filter(s => L(s.title).toLocaleLowerCase(lang).includes(q)) : S.stories; };
|
|
127
|
+
const allOpen = () => visible().every(s => open.has(s.id));
|
|
128
|
+
function render(){
|
|
129
|
+
document.documentElement.lang = lang; document.documentElement.dir = S.ui[lang].dir;
|
|
130
|
+
document.title = L(S.title);
|
|
131
|
+
$('title').textContent = L(S.title);
|
|
132
|
+
$('intro').textContent = L(S.intro);
|
|
133
|
+
if (S.repository) { $('contribute').href = S.repository; $('contribute').textContent = S.ui[lang].contribute; $('contribute').hidden = false; }
|
|
134
|
+
fillSelect($('lang'), S.languages.map(l => ({ value: l, text: S.ui[l].name ?? l })), lang, S.ui[lang].language);
|
|
135
|
+
$('lang-icon').innerHTML = S.icons.languages; $('langpick').title = S.ui[lang].language; $('langpick').hidden = S.languages.length < 2;
|
|
136
|
+
fillSelect($('theme'), S.themes.map(t => ({ value: t.id, text: pick(t.name, lang, S.defaultLanguage, t.id) })), palette, S.ui[lang].theme);
|
|
137
|
+
$('settings-btn').innerHTML = `${S.icons.settings} ${esc(S.ui[lang].settings)}`;
|
|
138
|
+
$('settings').setAttribute('aria-label', S.ui[lang].settings);
|
|
139
|
+
$('theme-label').textContent = S.ui[lang].theme;
|
|
140
|
+
$('layout-label').textContent = S.ui[lang].layout; $('layouts').setAttribute('aria-label', S.ui[lang].layout);
|
|
141
|
+
$('layouts').innerHTML = layoutButtons(S.ui[lang], pref('layout', LAYOUTS, 'center'));
|
|
142
|
+
$('search').placeholder = S.ui[lang].search_stories; $('search').setAttribute('aria-label', S.ui[lang].search_stories);
|
|
143
|
+
$('toggle-all').textContent = toggleLabel(S.ui[lang], allOpen());
|
|
144
|
+
$('credit').innerHTML = credit(S.ui[lang]);
|
|
145
|
+
const shown = visible();
|
|
146
|
+
$('toggle-all').hidden = !shown.length;
|
|
147
|
+
if (!shown.length) { $('rows').innerHTML = `<li class="empty">${esc(S.ui[lang].no_match.replace('{q}', query.trim()))}</li>`; return; }
|
|
148
|
+
$('rows').innerHTML = shown.map((s, i) => `
|
|
149
|
+
<li class="row">
|
|
150
|
+
<button type="button" aria-expanded="${open.has(s.id)}" aria-controls="d-${i}" data-id="${esc(s.id)}">
|
|
151
|
+
<span class="span">${esc(L(s.span))}</span><h2>${esc(L(s.title))}</h2><span class="chev"></span>
|
|
152
|
+
<span class="brief">${esc(L(s.summary))}</span>
|
|
153
|
+
</button>
|
|
154
|
+
<div class="detail" id="d-${i}" ${open.has(s.id) ? '' : 'hidden'}>
|
|
155
|
+
<div>
|
|
156
|
+
<p>${esc(L(s.summary))}</p>
|
|
157
|
+
<div class="meta"><span>${s.pages} ${esc(S.ui[lang].pages)}</span><span>${esc(s.languages.map(l => S.ui[l]?.name ?? l).join(', '))}</span></div>
|
|
158
|
+
<a class="open" href="${storyLink(s.id, storyLang(s))}">${esc(S.ui[lang].open)}</a>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="cover">${s.cover ? `<img src="${esc(s.cover)}" alt="">` : `<span>${esc(L(s.title))}</span>`}</div>
|
|
161
|
+
</div>
|
|
162
|
+
</li>`).join('');
|
|
163
|
+
}
|
|
164
|
+
$('rows').addEventListener('click', e => {
|
|
165
|
+
const b = e.target.closest('button[data-id]'); if (!b) return;
|
|
166
|
+
const id = b.dataset.id; open.has(id) ? open.delete(id) : open.add(id);
|
|
167
|
+
b.setAttribute('aria-expanded', open.has(id)); $(b.getAttribute('aria-controls')).hidden = !open.has(id);
|
|
168
|
+
$('toggle-all').textContent = toggleLabel(S.ui[lang], allOpen());
|
|
169
|
+
});
|
|
170
|
+
$('toggle-all').onclick = () => { const all = allOpen(); visible().forEach(s => all ? open.delete(s.id) : open.add(s.id)); render(); };
|
|
171
|
+
$('search').oninput = e => { query = e.target.value; render(); };
|
|
172
|
+
$('search').onkeydown = e => { if (e.key === 'Escape' && query) { e.target.value = query = ''; render(); } };
|
|
173
|
+
$('theme').onchange = e => { palette = e.target.value; applyPalette(palette); };
|
|
174
|
+
const showSettings = open => { $('settings').hidden = !open; $('settings-btn').setAttribute('aria-expanded', open); };
|
|
175
|
+
$('settings-btn').onclick = () => showSettings($('settings').hidden);
|
|
176
|
+
$('layouts').onclick = e => { const b = e.target.closest('button[data-layout]'); if (b) saveLayout(b.dataset.layout); };
|
|
177
|
+
document.addEventListener('click', e => { if (!$('settings').hidden && !e.target.closest('.prefs')) showSettings(false); });
|
|
178
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') showSettings(false); });
|
|
179
|
+
$('lang').onchange = e => { lang = e.target.value; savePref('lang', lang); setRoute([lang], false); render(); };
|
|
180
|
+
render(); setRoute([lang], false);
|
|
181
|
+
</script>
|
|
182
|
+
</body>
|
|
183
|
+
</html>
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The package entry. The same commands the CLI offers, as functions, plus the content schemas and what an engine's
|
|
2
|
+
// own commands read.
|
|
3
|
+
export { build, buildPages, checkStory as check, readStory, storyAssets, uiCatalogue } from './build.mjs';
|
|
4
|
+
export { dev } from './dev.mjs';
|
|
5
|
+
export { i18n } from './translate.mjs';
|
|
6
|
+
export { image, imageIndex, findImage, rehash } from './image.mjs';
|
|
7
|
+
export { upload } from './upload.mjs';
|
|
8
|
+
export { deploy } from './deploy.mjs';
|
|
9
|
+
export { resolve } from './routes.mjs';
|
|
10
|
+
export { loadEngine } from './engine.mjs';
|
|
11
|
+
export { translator, loadCatalogues, flatten, unflatten, catalogueText } from './i18n.mjs';
|
|
12
|
+
export { libraryItems, storyFolders } from './folders.mjs';
|
|
13
|
+
export { esc, PLACE, describe } from './pages.mjs';
|
|
14
|
+
export { hash, idOf, listDirs } from './util.mjs';
|
|
15
|
+
export * as schema from './schema.mjs';
|