@openbooknetwork/kitap 0.1.0 → 0.3.1
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/README.md +25 -0
- package/package.json +3 -2
- package/scripts/cloudflare-book.mjs +115 -0
- package/scripts/cloudflare-redirect.mjs +62 -0
- package/src/app.html +11 -4
- package/src/app.js +16 -2
- package/src/build.mjs +2 -2
- package/src/candidates.mjs +112 -0
- package/src/cli.mjs +4 -3
- package/src/dev.mjs +2 -2
- package/src/i18n/en.yaml +4 -0
- package/src/i18n/tr.yaml +4 -0
- package/src/index.mjs +1 -0
- package/src/schema.mjs +1 -0
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ title: Open Body Book
|
|
|
26
26
|
engine: beden
|
|
27
27
|
languages: [en, tr]
|
|
28
28
|
assets: { url: https://assets.example.org/, bucket: body-assets, endpoint: https://... }
|
|
29
|
+
ask: [chatgpt, claude] # optional: an Ask button on every page opens the assistant with the page's text
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
```yaml
|
|
@@ -49,6 +50,7 @@ kitap check <story> [page id ...] [--places]
|
|
|
49
50
|
kitap dev [--story <id>] [--out <dir>] [--port <n>]
|
|
50
51
|
kitap i18n <lang> [--story <id>]
|
|
51
52
|
kitap image <file or url> <name> --caption <text> [--credit <text>] [--source <url>] [--story <id>]
|
|
53
|
+
kitap image-candidates <story> --queries <file> [--limit <n>] [--pages <id,id>] [--out <name>]
|
|
52
54
|
kitap rehash | kitap upload [--story <id>] [--dry-run] | kitap deploy [--dry-run]
|
|
53
55
|
```
|
|
54
56
|
|
|
@@ -56,6 +58,11 @@ An engine's own binary runs the same commands with its engine, `harita build` fo
|
|
|
56
58
|
`--strict` fails on a missing translation or an object the store lacks; `--local-assets` shows the files on this
|
|
57
59
|
machine before they are uploaded.
|
|
58
60
|
|
|
61
|
+
`image-candidates` searches Wikimedia Commons for each page of a story and downloads what it finds to
|
|
62
|
+
`<story>/.candidates/<page>/`, listed with author, license and source in `.candidates/index.yaml`. The queries file
|
|
63
|
+
is keyed by story, then page: `{ queries: [search terms], articles: [Wikipedia titles or URLs], files: [Commons
|
|
64
|
+
files] }`. A file the book holds already is listed with `held: <id>`. `kitap image <url>` adds a chosen one.
|
|
65
|
+
|
|
59
66
|
## An engine
|
|
60
67
|
|
|
61
68
|
An engine is a package whose default export is a module with an id and hooks. Kitap resolves `engine: <id>` of
|
|
@@ -96,6 +103,24 @@ and, as a module, dispatches `kitap-engine` once set. `host` carries the bundle
|
|
|
96
103
|
|
|
97
104
|
The test engine in `test/engines/plain.mjs` shows the least an engine needs.
|
|
98
105
|
|
|
106
|
+
## Deploy
|
|
107
|
+
|
|
108
|
+
A book serves from Cloudflare: the images and the engine's objects from an R2 bucket behind `assets.<zone>`, the
|
|
109
|
+
pages from a second bucket through a Worker on the zone. `site.yaml` names them in `url`, `assets` and `pages`, and
|
|
110
|
+
one script sets them up from a content project:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
CF_TOKEN=<token> node node_modules/@openbooknetwork/kitap/scripts/cloudflare-book.mjs [--lock]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
It adds the zone and prints its nameservers for the registrar, creates both buckets, attaches the assets domain,
|
|
117
|
+
sets CORS, the cache rule, the SVG header rule and tiered cache, and with `--lock` the bucket lock on `images/`.
|
|
118
|
+
Run it again once the zone is active. Then three tokens by hand: an R2 token on the assets bucket for `kitap
|
|
119
|
+
upload`, one on the site bucket as the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets of the deploy
|
|
120
|
+
workflow, and a wrangler token with Workers Scripts Edit and Workers Routes Edit as `CLOUDFLARE_API_TOKEN` with
|
|
121
|
+
the account id as `CLOUDFLARE_ACCOUNT_ID`. `scripts/cloudflare-redirect.mjs <old zone> <new zone>` sends a
|
|
122
|
+
retired domain to the new one.
|
|
123
|
+
|
|
99
124
|
## Work on it
|
|
100
125
|
|
|
101
126
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openbooknetwork/kitap",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The book under the Open Book Network sites: stories of pages with a text beside a picture an engine draws, built to a static site in every language",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"static-site",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"bin",
|
|
34
|
-
"src"
|
|
34
|
+
"src",
|
|
35
|
+
"scripts"
|
|
35
36
|
],
|
|
36
37
|
"publishConfig": {
|
|
37
38
|
"access": "public"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sets a book up on Cloudflare from its site.yaml: the zone of `url`, the assets bucket with its custom domain and
|
|
3
|
+
// CORS, the site bucket, the cache rule and the SVG header rule on the assets host, and tiered cache. Every step
|
|
4
|
+
// is skipped when it is already done, so the script can run again after the zone goes active.
|
|
5
|
+
//
|
|
6
|
+
// CF_TOKEN=<token> node node_modules/@openbooknetwork/kitap/scripts/cloudflare-book.mjs [--lock] [--dry-run]
|
|
7
|
+
//
|
|
8
|
+
// The token needs, on the account: Zone Edit, Workers R2 Storage Edit, DNS Edit; on the zone: Zone Read, Zone
|
|
9
|
+
// Settings Edit, Cache Rules Edit, Transform Rules Edit. --lock adds the bucket lock on images/, for after the
|
|
10
|
+
// first upload is checked. What stays manual: the nameservers at the registrar, and the R2 and wrangler tokens.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
|
|
15
|
+
const API = 'https://api.cloudflare.com/client/v4';
|
|
16
|
+
const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
|
|
17
|
+
|
|
18
|
+
const { values } = parseArgs({ options: { lock: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, account: { type: 'string' } } });
|
|
19
|
+
const site = yaml.load(fs.readFileSync('site.yaml', 'utf8'));
|
|
20
|
+
for (const k of ['url', 'assets', 'pages']) if (!site[k]) fail(`site.yaml has no ${k}, see kitap's README under Deploy`);
|
|
21
|
+
const zoneName = new URL(site.url).hostname.replace(/^www\./, '');
|
|
22
|
+
const assetsHost = new URL(site.assets.url).hostname;
|
|
23
|
+
const assetsBucket = site.assets.bucket, siteBucket = site.pages.bucket;
|
|
24
|
+
const token = process.env.CF_TOKEN, dry = values['dry-run'];
|
|
25
|
+
if (!token && !dry) fail('set CF_TOKEN to a Cloudflare API token, see the head of this script for its permissions');
|
|
26
|
+
|
|
27
|
+
console.log(`${zoneName}: assets ${assetsBucket} at ${assetsHost}, site ${siteBucket}`);
|
|
28
|
+
if (dry) { console.log('dry run: nothing is sent'); process.exit(0); }
|
|
29
|
+
|
|
30
|
+
const account = values.account ?? await accountId();
|
|
31
|
+
const zone = await ensureZone();
|
|
32
|
+
await ensureBucket(assetsBucket);
|
|
33
|
+
await ensureBucket(siteBucket);
|
|
34
|
+
await cors(assetsBucket);
|
|
35
|
+
await managedDomainOff(assetsBucket);
|
|
36
|
+
await customDomain(assetsBucket);
|
|
37
|
+
await rules();
|
|
38
|
+
if (values.lock) await lock(assetsBucket);
|
|
39
|
+
console.log(zone.status === 'active' ? `${zoneName} is set up` : `${zoneName} is ${zone.status}: point the registrar at ${zone.name_servers.join(' and ')}, then run this again`);
|
|
40
|
+
|
|
41
|
+
async function accountId() {
|
|
42
|
+
const list = await call('GET', '/accounts');
|
|
43
|
+
if (list.length !== 1) fail(`the token sees ${list.length} accounts, pass --account <id>`);
|
|
44
|
+
return list[0].id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function ensureZone() {
|
|
48
|
+
const found = await call('GET', `/zones?name=${zoneName}`);
|
|
49
|
+
if (found.length) { console.log(`zone ${zoneName}: ${found[0].status}`); return found[0]; }
|
|
50
|
+
const z = await call('POST', '/zones', { name: zoneName, account: { id: account }, type: 'full' });
|
|
51
|
+
console.log(`zone ${zoneName}: added, nameservers ${z.name_servers.join(' and ')}`);
|
|
52
|
+
return z;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function ensureBucket(name) {
|
|
56
|
+
const res = await fetch(`${API}/accounts/${account}/r2/buckets/${name}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
57
|
+
if (res.ok) { console.log(`bucket ${name}: exists`); return; }
|
|
58
|
+
await call('POST', `/accounts/${account}/r2/buckets`, { name });
|
|
59
|
+
console.log(`bucket ${name}: created`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function cors(bucket) {
|
|
63
|
+
await call('PUT', `/accounts/${account}/r2/buckets/${bucket}/cors`, { rules: [{ allowed: { origins: ['*'], methods: ['GET', 'HEAD'] } }] });
|
|
64
|
+
console.log(`bucket ${bucket}: CORS GET and HEAD from anywhere`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// the r2.dev URL skips the cache, so the custom domain is the only way in
|
|
68
|
+
async function managedDomainOff(bucket) {
|
|
69
|
+
await call('PUT', `/accounts/${account}/r2/buckets/${bucket}/domains/managed`, { enabled: false });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function customDomain(bucket) {
|
|
73
|
+
const { domains } = await call('GET', `/accounts/${account}/r2/buckets/${bucket}/domains/custom`);
|
|
74
|
+
if (domains.some(d => d.domain === assetsHost)) { console.log(`bucket ${bucket}: ${assetsHost} attached`); return; }
|
|
75
|
+
if (zone.status !== 'active') { console.log(`bucket ${bucket}: ${assetsHost} waits for the zone to go active`); return; }
|
|
76
|
+
await call('POST', `/accounts/${account}/r2/buckets/${bucket}/domains/custom`, { domain: assetsHost, zoneId: zone.id, enabled: true, minTLS: '1.2' });
|
|
77
|
+
console.log(`bucket ${bucket}: ${assetsHost} attached`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function rules() {
|
|
81
|
+
await call('PUT', `/zones/${zone.id}/rulesets/phases/http_request_cache_settings/entrypoint`, { rules: [{
|
|
82
|
+
description: 'assets',
|
|
83
|
+
expression: `(http.host eq "${assetsHost}")`,
|
|
84
|
+
action: 'set_cache_settings',
|
|
85
|
+
action_parameters: {
|
|
86
|
+
cache: true,
|
|
87
|
+
cache_key: { custom_key: { query_string: { exclude: { all: true } } } },
|
|
88
|
+
// a 404 stays uncached, so a strict build right after an upload sees the new keys
|
|
89
|
+
edge_ttl: { mode: 'respect_origin', status_code_ttl: [{ status_code: 404, value: -1 }] },
|
|
90
|
+
},
|
|
91
|
+
}] });
|
|
92
|
+
await call('PUT', `/zones/${zone.id}/rulesets/phases/http_response_headers_transform/entrypoint`, { rules: [{
|
|
93
|
+
description: 'svg csp',
|
|
94
|
+
expression: `(http.host eq "${assetsHost}" and ends_with(http.request.uri.path, ".svg"))`,
|
|
95
|
+
action: 'rewrite',
|
|
96
|
+
action_parameters: { headers: { 'Content-Security-Policy': { operation: 'set', value: CSP } } },
|
|
97
|
+
}] });
|
|
98
|
+
await call('PATCH', `/zones/${zone.id}/cache/tiered_cache_smart_topology_enable`, { value: 'on' });
|
|
99
|
+
console.log(`zone ${zoneName}: cache rule, SVG header rule and tiered cache`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// the store then refuses to delete or overwrite an image, whoever asks
|
|
103
|
+
async function lock(bucket) {
|
|
104
|
+
await call('PUT', `/accounts/${account}/r2/buckets/${bucket}/lock`, { rules: [{ id: 'images', enabled: true, prefix: 'images/', condition: { type: 'Indefinite' } }] });
|
|
105
|
+
console.log(`bucket ${bucket}: images/ locked`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function call(method, path, body) {
|
|
109
|
+
const res = await fetch(API + path, { method, body: body && JSON.stringify(body), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
|
|
110
|
+
const out = await res.json();
|
|
111
|
+
if (!out.success) fail(`${method} ${path} failed: ${JSON.stringify(out.errors, null, 2)}`);
|
|
112
|
+
return out.result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fail(message) { console.error(message); process.exit(1); }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sends an old zone to a new one by 301, the apex and the assets host alike, path and query kept:
|
|
3
|
+
//
|
|
4
|
+
// CF_TOKEN=<token> node node_modules/@openbooknetwork/kitap/scripts/cloudflare-redirect.mjs openhistoryatlas.org openhistorybook.org [--dry-run]
|
|
5
|
+
//
|
|
6
|
+
// The token needs Zone Read and Single Redirect Edit on the old zone. The rule replaces the zone's own redirect
|
|
7
|
+
// rules, so run it on a zone that has none or that this script made.
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const API = 'https://api.cloudflare.com/client/v4';
|
|
11
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { 'dry-run': { type: 'boolean' } } });
|
|
12
|
+
const [from, to] = positionals;
|
|
13
|
+
if (!from || !to) fail('usage: CF_TOKEN=<token> node cloudflare-redirect.mjs <old zone> <new zone> [--dry-run]');
|
|
14
|
+
const token = process.env.CF_TOKEN;
|
|
15
|
+
if (!token && !values['dry-run']) fail('set CF_TOKEN to a Cloudflare API token with Zone Read and Single Redirect Edit');
|
|
16
|
+
|
|
17
|
+
const body = redirectRules(from, to);
|
|
18
|
+
if (values['dry-run']) console.log(JSON.stringify(body, null, 2));
|
|
19
|
+
else {
|
|
20
|
+
await call('PUT', `/zones/${await zoneId(from)}/rulesets/phases/http_request_dynamic_redirect/entrypoint`, body);
|
|
21
|
+
console.log(`${from} redirects to ${to}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One rule per hostname: the apex to the new apex, the assets host to the new assets host, path and query kept.
|
|
25
|
+
function redirectRules(from, to) {
|
|
26
|
+
const rule = (fromHost, toHost) => ({
|
|
27
|
+
description: `${fromHost} to ${toHost}`,
|
|
28
|
+
expression: `(http.host eq "${fromHost}")`,
|
|
29
|
+
action: 'redirect',
|
|
30
|
+
action_parameters: { from_value: {
|
|
31
|
+
status_code: 301,
|
|
32
|
+
target_url: { expression: `concat("https://${toHost}", http.request.uri.path)` },
|
|
33
|
+
preserve_query_string: true,
|
|
34
|
+
} },
|
|
35
|
+
});
|
|
36
|
+
return { rules: [rule(from, to), rule(`assets.${from}`, `assets.${to}`)] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function zoneId(name) {
|
|
40
|
+
const res = await fetch(`${API}/zones?name=${name}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
41
|
+
const out = await res.json();
|
|
42
|
+
if (!out.success) fail(`zone lookup failed: ${JSON.stringify(out.errors, null, 2)}`);
|
|
43
|
+
if (!out.result.length) {
|
|
44
|
+
fail(`the token sees no zone named ${name}: give it Zone Read on that zone`);
|
|
45
|
+
}
|
|
46
|
+
return out.result[0].id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function call(method, path, body) {
|
|
50
|
+
const res = await fetch(API + path, {
|
|
51
|
+
method, body: JSON.stringify(body),
|
|
52
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
53
|
+
});
|
|
54
|
+
const out = await res.json();
|
|
55
|
+
if (!out.success) fail(`${method} ${path} failed: ${JSON.stringify(out.errors, null, 2)}`);
|
|
56
|
+
return out.result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fail(message) {
|
|
60
|
+
console.error(message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
package/src/app.html
CHANGED
|
@@ -111,7 +111,11 @@ nav.pages button:focus-visible,nav.pages a.pg:focus-visible{outline:2px solid va
|
|
|
111
111
|
.legend .r{width:1rem;height:0;border:0;border-top:2px solid var(--rc);position:relative}
|
|
112
112
|
.legend .r.arrow::after{content:"";position:absolute;inset-inline-end:-2px;top:-5px;border:4px solid transparent;border-inline-start:5px solid var(--rc)}
|
|
113
113
|
.legend .r.dashed{border-top-style:dashed}
|
|
114
|
-
|
|
114
|
+
/* the corner of the map: the Ask button over the credit bar, and the popups they open above both */
|
|
115
|
+
.corner{position:absolute;inset-inline-end:.714rem;bottom:.714rem;z-index:4;display:flex;flex-direction:column;align-items:flex-end;gap:.429rem;pointer-events:auto}
|
|
116
|
+
.ask{display:flex;align-items:center;gap:.357rem;background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.357rem .714rem;font:inherit;font-size:.929rem;font-weight:600;color:var(--ink);cursor:pointer}
|
|
117
|
+
.ask svg{width:1.143rem;height:1.143rem} .ask[hidden]{display:none} .ask:focus-visible{outline:2px solid var(--accent)}
|
|
118
|
+
.credit{background:var(--panel);border:1px solid var(--rule);padding:3px .571rem;font-size:.786rem;color:var(--ink-2);pointer-events:auto;
|
|
115
119
|
display:flex;align-items:center;gap:.571rem}
|
|
116
120
|
.credit a{color:inherit}
|
|
117
121
|
.credit :is(#mapdata,#othersrc){background:none;border:0;border-inline-start:1px solid var(--rule);padding:0;padding-inline-start:.571rem;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
|
|
@@ -124,6 +128,7 @@ nav.pages button:focus-visible,nav.pages a.pg:focus-visible{outline:2px solid va
|
|
|
124
128
|
.credits ul{margin:0;padding-inline-start:1rem;display:grid;gap:.286rem}
|
|
125
129
|
.credit #othersrc[hidden]{display:none}
|
|
126
130
|
.credits .made{display:none;margin:0 0 .429rem}
|
|
131
|
+
.credits .ask-list{display:grid;gap:.286rem;margin:0 0 .429rem} .credits .note{margin:0;color:var(--ink-2)}
|
|
127
132
|
/* map tools: the 2D | 3D switch at the top of the map, joined on phones by the Legend button */
|
|
128
133
|
.maptools{position:absolute;top:.714rem;inset-inline-end:.714rem;z-index:3;display:flex;gap:.429rem;align-items:flex-start}
|
|
129
134
|
.view{display:flex;background:var(--panel);border:1px solid var(--rule);border-radius:6px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.15)}
|
|
@@ -253,7 +258,7 @@ aside.story figcaption small{display:block;font-size:.786rem;color:var(--ink-2);
|
|
|
253
258
|
aside.story .text{grid-row:2;grid-column:1;z-index:2;background:var(--paper);overflow:auto;padding:.571rem .143rem 1.143rem}
|
|
254
259
|
body[data-tab="map"] aside.story .text{display:none}
|
|
255
260
|
/* in the text view the map's own overlays step aside; they sit above the text in the stacking order otherwise */
|
|
256
|
-
body[data-tab="text"] :is(.peek,.maptools,.legend,.
|
|
261
|
+
body[data-tab="text"] :is(.peek,.maptools,.legend,.corner,.maplibregl-popup,.hl-label){display:none}
|
|
257
262
|
.hl-label.pinned{top:calc(.571rem + max(40px,2.857rem) + .429rem)} /* below the map tools and the credit */
|
|
258
263
|
/* the peek card: the first two lines of the page's text over the bottom of the map; a tap opens the text */
|
|
259
264
|
.peek{display:block;position:absolute;inset-inline:.571rem;bottom:.571rem;z-index:3;background:var(--panel);border:1px solid var(--rule);border-radius:8px;
|
|
@@ -272,7 +277,9 @@ aside.story figcaption small{display:block;font-size:.786rem;color:var(--ink-2);
|
|
|
272
277
|
.legend-btn[hidden]{display:none}
|
|
273
278
|
.legend{top:calc(.571rem + max(40px,2.857rem) + .429rem);bottom:auto;inset-inline-start:.571rem;z-index:3;max-width:calc(100% - 1.143rem)}
|
|
274
279
|
body:not(.legend-open) .legend{display:none}
|
|
275
|
-
.
|
|
280
|
+
.corner{top:.571rem;bottom:auto;inset-inline-end:.571rem;flex-direction:row-reverse}
|
|
281
|
+
.ask{display:grid;place-items:center;width:max(44px,3.143rem);height:max(40px,2.857rem);padding:0;color:var(--ink-2)} .ask span{display:none}
|
|
282
|
+
.credit{padding:0;border:0;background:none}
|
|
276
283
|
.credit #made{display:none}
|
|
277
284
|
.credit :is(#mapdata,#othersrc){display:grid;place-items:center;width:max(44px,3.143rem);height:max(40px,2.857rem);padding:0;background:var(--panel);border:1px solid var(--rule);border-radius:6px;color:var(--ink-2)}
|
|
278
285
|
.credit :is(#mapdata,#othersrc) svg{display:block} .credit :is(#mapdata,#othersrc) span{display:none}
|
|
@@ -338,7 +345,7 @@ aside.story figcaption small{display:block;font-size:.786rem;color:var(--ink-2);
|
|
|
338
345
|
<div class="maptools"><button type="button" class="legend-btn" id="legend-btn"></button><div class="view" id="view" role="group"><button type="button" data-view="2d" aria-pressed="true"></button><button type="button" data-view="3d" aria-pressed="false"></button></div></div>
|
|
339
346
|
<div class="legend" id="legend"></div>
|
|
340
347
|
<div class="hl-label" id="hl-label" hidden></div>
|
|
341
|
-
<div class="credit" id="credit"><span id="made"></span><button type="button" id="mapdata" aria-expanded="false" aria-controls="credits"></button><div class="credits" id="credits" hidden></div><button type="button" id="othersrc" aria-expanded="false" aria-controls="othercredits" hidden></button><div class="credits" id="othercredits" hidden></div></div>
|
|
348
|
+
<div class="corner" id="corner"><button type="button" class="ask" id="ask" aria-expanded="false" aria-controls="askmenu" hidden></button><div class="credits" id="askmenu" hidden></div><div class="credit" id="credit"><span id="made"></span><button type="button" id="mapdata" aria-expanded="false" aria-controls="credits"></button><div class="credits" id="credits" hidden></div><button type="button" id="othersrc" aria-expanded="false" aria-controls="othercredits" hidden></button><div class="credits" id="othercredits" hidden></div></div></div>
|
|
342
349
|
<div class="peek" id="peek" role="button" tabindex="0"><p id="peek-text"></p><span class="more" id="peek-more"></span><button type="button" class="close" id="peek-close">×</button></div>
|
|
343
350
|
<div class="lightbox" id="lightbox" hidden>
|
|
344
351
|
<figure><img id="lb-img" alt=""><figcaption id="lb-cap"></figcaption></figure>
|
package/src/app.js
CHANGED
|
@@ -63,6 +63,7 @@ function renderChrome(){
|
|
|
63
63
|
$('mapdata').innerHTML = `${ICONS.info}<span>${esc(T().map_data)}</span>`; $('mapdata').setAttribute('aria-label', T().map_data);
|
|
64
64
|
$('mapdata').hidden = !B.credits.length;
|
|
65
65
|
$('othersrc').innerHTML = `${ICONS['external-link']}<span>${esc(T().other_sources)}</span>`; $('othersrc').setAttribute('aria-label', T().other_sources);
|
|
66
|
+
$('ask').innerHTML = `${ICONS['message-circle']}<span>${esc(T().ask)}</span>`; $('ask').setAttribute('aria-label', T().ask); $('ask').hidden = !B.site.ask.length;
|
|
66
67
|
$('credits').innerHTML = `<p class="made">${credit(T())}</p><h4>${esc(T().map_data)}</h4><ul>${B.credits.map(c => `<li>${esc(L(c))}</li>`).join('')}</ul>`;
|
|
67
68
|
$('prev').innerHTML = `${ICONS['chevron-left']} ${esc(T().prev)}`;
|
|
68
69
|
$('next').innerHTML = `${esc(T().next)} ${ICONS['chevron-right']}`;
|
|
@@ -201,11 +202,24 @@ $('peek').onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.prevent
|
|
|
201
202
|
$('peek-close').onclick = e => { e.stopPropagation(); $('peek').hidden = true; };
|
|
202
203
|
$('legend-btn').onclick = () => document.body.classList.toggle('legend-open');
|
|
203
204
|
// the data credit popup and the page's other sources popup share the credit bar, one open at a time
|
|
204
|
-
const CREDIT_POPUPS = { mapdata: 'credits', othersrc: 'othercredits' };
|
|
205
|
+
const CREDIT_POPUPS = { mapdata: 'credits', othersrc: 'othercredits', ask: 'askmenu' };
|
|
205
206
|
const showCredits = (open, btn = 'mapdata') => { for (const [b, p] of Object.entries(CREDIT_POPUPS)) { const on = open && b === btn; $(p).hidden = !on; $(b).setAttribute('aria-expanded', on); } };
|
|
206
207
|
$('mapdata').onclick = () => showCredits($('credits').hidden, 'mapdata');
|
|
207
208
|
$('othersrc').onclick = () => showCredits($('othercredits').hidden, 'othersrc');
|
|
208
|
-
|
|
209
|
+
// the assistants of site.yaml, each opened in a new tab with the page's text as the prompt; the reader's own account answers
|
|
210
|
+
const ASSISTANTS = { chatgpt: { name: 'ChatGPT', url: q => `https://chatgpt.com/?q=${q}` }, claude: { name: 'Claude', url: q => `https://claude.ai/new?q=${q}` } };
|
|
211
|
+
function askPrompt(p){
|
|
212
|
+
const d = document.createElement('div'); d.innerHTML = p === OVERVIEW ? `<p>${esc(L(B.summary))}</p>` : p.html[lang];
|
|
213
|
+
d.querySelectorAll('figure').forEach(f => f.remove()); // the prose only, without the image captions and credits
|
|
214
|
+
const text = d.textContent.replace(/\s+/g, ' ').trim().slice(0, 2500);
|
|
215
|
+
return `${T().ask_prompt.replace('{title}', L(p.title)).replace('{url}', location.href)}\n\n${text}`;
|
|
216
|
+
}
|
|
217
|
+
$('ask').onclick = () => {
|
|
218
|
+
const q = encodeURIComponent(askPrompt(here()));
|
|
219
|
+
$('askmenu').innerHTML = `<h4>${esc(T().ask_title)}</h4><div class="ask-list">${B.site.ask.map(id => `<a href="${esc(ASSISTANTS[id].url(q))}" target="_blank" rel="noopener">${esc(ASSISTANTS[id].name)} ${ICONS['external-link']}</a>`).join('')}</div><p class="note">${esc(T().ask_note)}</p>`;
|
|
220
|
+
showCredits($('askmenu').hidden, 'ask');
|
|
221
|
+
};
|
|
222
|
+
document.addEventListener('click', e => { if (!e.target.closest('.corner')) showCredits(false); });
|
|
209
223
|
const showSettings = open => { $('settings').hidden = !open; $('settings-btn').setAttribute('aria-expanded', open); };
|
|
210
224
|
$('settings-btn').onclick = () => showSettings($('settings').hidden);
|
|
211
225
|
document.addEventListener('click', e => { if (!$('settings').hidden && !e.target.closest('.prefs')) showSettings(false); });
|
package/src/build.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const readUi = dir => Object.fromEntries(fs.readdirSync(dir).filter(f => f.endsW
|
|
|
48
48
|
const UI = readUi(UI_DIR);
|
|
49
49
|
// Icons come from Lucide: the shell's own, and the ones an engine asks for by name
|
|
50
50
|
const ICON_DIR = path.dirname(require.resolve('lucide-static/icons/flag.svg'));
|
|
51
|
-
const SHELL_ICONS = ['x', 'external-link', 'chevron-left', 'chevron-right', 'list', 'info', 'settings', 'languages'];
|
|
51
|
+
const SHELL_ICONS = ['x', 'external-link', 'chevron-left', 'chevron-right', 'list', 'info', 'settings', 'languages', 'message-circle'];
|
|
52
52
|
function iconSvg(name, where) {
|
|
53
53
|
const file = path.join(ICON_DIR, name + '.svg');
|
|
54
54
|
if (!exists(file)) fail(where, `unknown icon "${name}", pick a name from https://lucide.dev/icons`);
|
|
@@ -375,7 +375,7 @@ function buildStory(p, storyDir, site, engine) {
|
|
|
375
375
|
// --- bundle and pages ---
|
|
376
376
|
const bundle = {
|
|
377
377
|
id: story.id, title: T(story.title, 'story.title'), summary, span, cover, languages: langs, defaultLanguage: defaultLang, ui, families, engine: engine.id,
|
|
378
|
-
site: { title: siteT(site.title, 'site.title'), languages: site.langs, defaultLanguage: site.defaultLang,
|
|
378
|
+
site: { title: siteT(site.title, 'site.title'), languages: site.langs, defaultLanguage: site.defaultLang, ask: site.ask ?? [],
|
|
379
379
|
source: site.repository ? `${site.repository.replace(/\/$/, '')}/tree/${site.branch}/${where.split(path.sep).join('/')}` : null },
|
|
380
380
|
themes: themeList(site.themes, ui, langs), defaultTheme: story.theme ?? site.theme,
|
|
381
381
|
credits: eb.credits ?? [], images, icons, pages: pages.map(({ dir, include, ...p }) => p), tree: navTree(tree),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// kitap image-candidates: searches Wikimedia Commons for each page's queries and articles, and downloads what it finds
|
|
2
|
+
// to <story folder>/.candidates/<page>/ for review, listed with author, license and source in .candidates/index.yaml.
|
|
3
|
+
// kitap image then adds a chosen one by its url. A file the book holds already, by its Commons page or its bytes, is
|
|
4
|
+
// listed with held: <id> and not kept.
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { storyFolders } from './folders.mjs';
|
|
9
|
+
import { imageIndex, findImage } from './image.mjs';
|
|
10
|
+
|
|
11
|
+
const API = 'https://commons.wikimedia.org/w/api.php';
|
|
12
|
+
const BITMAP = /\.(jpe?g|png|tiff?)$/i;
|
|
13
|
+
// an article is a Wikipedia URL or an English Wikipedia title
|
|
14
|
+
const articleApi = a => { const m = a.match(/^https?:\/\/([a-z-]+)\.wikipedia\.org\/wiki\/([^?#]+)/); return m ? [`https://${m[1]}.wikipedia.org/w/api.php`, decodeURIComponent(m[2]).replace(/_/g, ' ')] : ['https://en.wikipedia.org/w/api.php', a]; };
|
|
15
|
+
const strip = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
16
|
+
|
|
17
|
+
// queries is a YAML file keyed by story id, then by page id: { queries: [search terms], articles: [titles or URLs],
|
|
18
|
+
// files: [Commons file names] }, or a bare list of search terms. pages refetches those pages, out writes into
|
|
19
|
+
// .candidates/<out>/ so agents fetching at the same time keep separate index files. pause scales the waits.
|
|
20
|
+
export async function imageCandidates({ root = process.cwd(), story, queries, limit = 6, pages = null, out = '', log = console.log, fetch = globalThis.fetch, pause = 1 } = {}) {
|
|
21
|
+
const stories = storyFolders(path.join(root, 'content'), p => path.relative(root, p)); // full paths by folder name
|
|
22
|
+
if (!stories.has(story)) throw new Error(`"${story}" is not a story folder under content/`);
|
|
23
|
+
if (!queries) throw new Error('name the queries file with --queries <file>: YAML keyed by story, then page, each with queries, articles or files');
|
|
24
|
+
const spec = yaml.load(fs.readFileSync(path.resolve(root, queries), 'utf8'))?.[story];
|
|
25
|
+
if (!spec) throw new Error(`${queries}: no queries for the story "${story}"`);
|
|
26
|
+
const outDir = path.join(stories.get(story), '.candidates', out);
|
|
27
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
// Wikimedia asks for a contact in the user agent and throttles unnamed clients harder
|
|
30
|
+
const siteFile = path.join(root, 'site.yaml'), site = fs.existsSync(siteFile) ? yaml.load(fs.readFileSync(siteFile, 'utf8')) ?? {} : {};
|
|
31
|
+
const title = typeof site.title === 'object' ? Object.values(site.title)[0] : site.title;
|
|
32
|
+
const headers = { 'User-Agent': `${title ?? 'kitap'} image-candidates (${site.repository ?? site.url ?? 'https://github.com/openbooknetwork/kitap'})` };
|
|
33
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms * pause));
|
|
34
|
+
// Commons answers 429 when asked too fast: wait as told, or doubling from two seconds, up to six tries
|
|
35
|
+
async function get(url) {
|
|
36
|
+
for (let attempt = 0; ; attempt++) {
|
|
37
|
+
// a dropped connection is retried like a 5xx
|
|
38
|
+
const res = await fetch(url, { headers }).catch(e => { if (attempt >= 6) throw e; return { ok: false, status: 599, headers: new Headers() }; });
|
|
39
|
+
if (res.ok) return res;
|
|
40
|
+
if ((res.status === 429 || res.status >= 500) && attempt < 6) {
|
|
41
|
+
const wait = Number(res.headers.get('retry-after')) * 1000 || 2000 * 2 ** attempt;
|
|
42
|
+
log(` ${res.status}, waiting ${wait / 1000}s`); await sleep(wait); continue;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`${res.status} for ${url}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const api = async (params, base = API) => (await get(base + '?' + new URLSearchParams({ format: 'json', ...params }))).json();
|
|
48
|
+
|
|
49
|
+
// resume: pages already in index.yaml are kept and skipped
|
|
50
|
+
const indexFile = path.join(outDir, 'index.yaml');
|
|
51
|
+
const index = fs.existsSync(indexFile) ? yaml.load(fs.readFileSync(indexFile, 'utf8')) ?? {} : {};
|
|
52
|
+
const save = () => fs.writeFileSync(indexFile, yaml.dump(index, { lineWidth: 120 }));
|
|
53
|
+
const held = imageIndex({ root });
|
|
54
|
+
const seen = new Set(Object.values(index).flat().map(c => 'File:' + c.title));
|
|
55
|
+
for (const [page, s] of Object.entries(spec)) {
|
|
56
|
+
if (pages && !pages.includes(page)) continue;
|
|
57
|
+
if (pages && index[page]) { for (const c of index[page]) seen.delete('File:' + c.title); delete index[page]; fs.rmSync(path.join(outDir, page), { recursive: true, force: true }); }
|
|
58
|
+
if (index[page]) { log(`${page}: done earlier, skipping`); continue; }
|
|
59
|
+
const terms = Array.isArray(s) ? s : s.queries ?? [], files = Array.isArray(s) ? [] : s.files ?? [], articles = Array.isArray(s) ? [] : s.articles ?? [];
|
|
60
|
+
const titles = files.map(f => f.startsWith('File:') ? f : 'File:' + f);
|
|
61
|
+
// every bitmap an article uses; files local to Wikipedia, usually non-free, have no Commons record and drop out below
|
|
62
|
+
for (const a of articles) {
|
|
63
|
+
const [base, t] = articleApi(a);
|
|
64
|
+
const r = await api({ action: 'query', prop: 'images', titles: t, imlimit: 'max', redirects: 1 }, base);
|
|
65
|
+
const found = Object.values(r.query?.pages ?? {});
|
|
66
|
+
if (!found.length || found.some(p => 'missing' in p)) log(` no article "${t}"`);
|
|
67
|
+
for (const p of found) for (const im of p.images ?? []) if (BITMAP.test(im.title)) titles.push(im.title);
|
|
68
|
+
await sleep(1000);
|
|
69
|
+
}
|
|
70
|
+
for (const q of terms) {
|
|
71
|
+
const r = await api({ action: 'query', list: 'search', srsearch: `${q} filetype:bitmap`, srnamespace: 6, srlimit: limit * 2 });
|
|
72
|
+
const hits = (r.query?.search ?? []).map(h => h.title).filter(t => BITMAP.test(t) && !seen.has(t) && !titles.includes(t));
|
|
73
|
+
titles.push(...hits.slice(0, limit));
|
|
74
|
+
await sleep(1000);
|
|
75
|
+
}
|
|
76
|
+
const picked = [...new Set(titles)].filter(t => !seen.has(t));
|
|
77
|
+
if (!picked.length) { log(`${page}: nothing found`); continue; }
|
|
78
|
+
const infoPages = [];
|
|
79
|
+
for (let i = 0; i < picked.length; i += 50) { // the API takes at most 50 titles per call
|
|
80
|
+
const info = await api({ action: 'query', prop: 'imageinfo', titles: picked.slice(i, i + 50).join('|'), iiprop: 'url|extmetadata|size', iiurlwidth: 1280 }); // a standard thumbnail step, Wikimedia throttles other widths
|
|
81
|
+
infoPages.push(...Object.values(info.query?.pages ?? {}));
|
|
82
|
+
}
|
|
83
|
+
const dir = path.join(outDir, page);
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
index[page] = [];
|
|
86
|
+
let n = 0;
|
|
87
|
+
for (const p of infoPages) {
|
|
88
|
+
const ii = p.imageinfo?.[0]; if (!ii) continue;
|
|
89
|
+
const meta = ii.extmetadata ?? {};
|
|
90
|
+
// the extension comes from the downloaded thumbnail, so a long title cut to 80 characters keeps one
|
|
91
|
+
const url = ii.thumburl ?? ii.url, ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
92
|
+
const base = p.title.replace(/^File:/, '').replace(/\.\w+$/, '').replace(/[^\w.-]+/g, '_').slice(0, 80);
|
|
93
|
+
const commonsPage = `https://commons.wikimedia.org/wiki/${encodeURIComponent(p.title)}`;
|
|
94
|
+
const entry = { title: p.title.replace(/^File:/, ''), page: commonsPage, url, author: strip(meta.Artist?.value), license: strip(meta.LicenseShortName?.value),
|
|
95
|
+
date: strip(meta.DateTimeOriginal?.value), description: strip(meta.ImageDescription?.value).slice(0, 300), width: ii.width, height: ii.height };
|
|
96
|
+
seen.add(p.title);
|
|
97
|
+
const known = findImage(held, { source: commonsPage });
|
|
98
|
+
if (known) { index[page].push({ file: null, held: known.id, ...entry }); log(` held already: ${known.id}`); continue; }
|
|
99
|
+
const name = `${String(++n).padStart(2, '0')}-${base}${ext}`, file = path.join(dir, name);
|
|
100
|
+
try { fs.writeFileSync(file, Buffer.from(await (await get(url)).arrayBuffer())); } catch (e) { log(` skip ${p.title}: ${e.message}`); continue; }
|
|
101
|
+
// the same bytes in an image folder that names no source: the download goes
|
|
102
|
+
const same = findImage(held, { file });
|
|
103
|
+
if (same) { fs.rmSync(file); index[page].push({ file: null, held: same.id, ...entry }); log(` held already: ${same.id}`); continue; }
|
|
104
|
+
index[page].push({ file: name, ...entry });
|
|
105
|
+
await sleep(800);
|
|
106
|
+
}
|
|
107
|
+
log(`${page}: ${index[page].length} candidates`);
|
|
108
|
+
save();
|
|
109
|
+
}
|
|
110
|
+
log(`wrote ${path.relative(root, indexFile)}`);
|
|
111
|
+
return index;
|
|
112
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// The command line of kitap and of every engine: the book's commands with the options they take. An engine's binary
|
|
2
2
|
// calls run with its engine and its own commands, which are tried first.
|
|
3
3
|
import { parseArgs } from 'util';
|
|
4
|
-
import { build, check, dev, i18n, image, rehash, upload, deploy } from './index.mjs';
|
|
4
|
+
import { build, check, dev, i18n, image, imageCandidates, rehash, upload, deploy } from './index.mjs';
|
|
5
5
|
|
|
6
|
-
export const USAGE = (name = 'kitap', extra = '') => `usage: ${name} build [--strict] [--local-assets] [--only <story> ...] [--story <id>] [--out <dir>] | ${name} check <story> [page id ...] [--places] | ${name} dev [--story <id>] [--out <dir>] [--port <n>] | ${name} i18n <lang> [--story id] | ${name} image <file or url> <name> --caption <text> [--credit <text>] [--source <url>] [--story <id>] | ${name} rehash | ${name} upload [--story <id>] [--dry-run] | ${name} deploy [--dry-run]${extra}`;
|
|
6
|
+
export const USAGE = (name = 'kitap', extra = '') => `usage: ${name} build [--strict] [--local-assets] [--only <story> ...] [--story <id>] [--out <dir>] | ${name} check <story> [page id ...] [--places] | ${name} dev [--story <id>] [--out <dir>] [--port <n>] | ${name} i18n <lang> [--story id] | ${name} image <file or url> <name> --caption <text> [--credit <text>] [--source <url>] [--story <id>] | ${name} image-candidates <story> --queries <file> [--limit <n>] [--pages <id,id>] [--out <name>] | ${name} rehash | ${name} upload [--story <id>] [--dry-run] | ${name} deploy [--dry-run]${extra}`;
|
|
7
7
|
|
|
8
8
|
// argv are the arguments after the command name. engine is the engine module to build with, else site.yaml names it.
|
|
9
9
|
// own(cmd, positionals, options) runs an engine's own command and returns true when it took it. options adds flags.
|
|
10
10
|
export async function run(argv, { name = 'kitap', engine = null, enginePath = null, own = null, options = {}, usage = '' } = {}) {
|
|
11
11
|
let args;
|
|
12
12
|
try {
|
|
13
|
-
args = parseArgs({ args: argv, allowPositionals: true, options: { strict: { type: 'boolean' }, 'local-assets': { type: 'boolean' }, 'dry-run': { type: 'boolean' }, places: { type: 'boolean' }, story: { type: 'string' }, caption: { type: 'string' }, credit: { type: 'string' }, source: { type: 'string' }, only: { type: 'string', multiple: true }, out: { type: 'string' }, port: { type: 'string' }, engine: { type: 'string' }, ...options } });
|
|
13
|
+
args = parseArgs({ args: argv, allowPositionals: true, options: { strict: { type: 'boolean' }, 'local-assets': { type: 'boolean' }, 'dry-run': { type: 'boolean' }, places: { type: 'boolean' }, story: { type: 'string' }, caption: { type: 'string' }, credit: { type: 'string' }, source: { type: 'string' }, only: { type: 'string', multiple: true }, out: { type: 'string' }, port: { type: 'string' }, queries: { type: 'string' }, limit: { type: 'string' }, pages: { type: 'string' }, engine: { type: 'string' }, ...options } });
|
|
14
14
|
} catch (err) { // an unknown or incomplete flag
|
|
15
15
|
console.error(`${err.message}\n${USAGE(name, usage)}`);
|
|
16
16
|
process.exit(2);
|
|
@@ -29,6 +29,7 @@ export async function run(argv, { name = 'kitap', engine = null, enginePath = nu
|
|
|
29
29
|
}
|
|
30
30
|
else if (cmd === 'i18n' && lang) i18n({ lang, story: opt.story, engine: e });
|
|
31
31
|
else if (cmd === 'image' && lang) await image({ file: lang, name: rest[0], caption: opt.caption, credit: opt.credit, source: opt.source, story: opt.story });
|
|
32
|
+
else if (cmd === 'image-candidates' && lang) await imageCandidates({ story: lang, queries: opt.queries, limit: Number(opt.limit) || 6, pages: opt.pages?.split(',') ?? null, out: opt.out ?? '' });
|
|
32
33
|
else if (cmd === 'rehash') rehash();
|
|
33
34
|
else if (cmd === 'upload') await upload({ story: opt.story, dryRun: opt['dry-run'], engine: e });
|
|
34
35
|
else if (cmd === 'deploy') await deploy({ dryRun: opt['dry-run'] });
|
package/src/dev.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const RELOAD = `<script>(()=>{let v=null;setInterval(async()=>{try{const r=await
|
|
|
12
12
|
// Serve root/dist on the port and rebuild on changes: a change in one story's folder rebuilds that story and the main
|
|
13
13
|
// page, any other change every story. Returns the http server. Builds run in a child process, so package edits apply,
|
|
14
14
|
// and with --local-assets, so the images on this machine show before they are uploaded.
|
|
15
|
-
// story serves that one story as a site of its own
|
|
15
|
+
// story serves that one story as a site of its own. Every build writes into out, by default dist/, which the server serves.
|
|
16
16
|
export function dev({ root = process.cwd(), port = Number(process.env.PORT) || 8080, log = console.log, story = null, out = null, engine = null } = {}) {
|
|
17
17
|
const DIST = out ? path.resolve(root, out) : path.join(root, 'dist'), CONTENT = path.join(root, 'content');
|
|
18
18
|
// engine is the engine module's path, handed to each build and watched beside kitap's own source
|
|
@@ -25,7 +25,7 @@ export function dev({ root = process.cwd(), port = Number(process.env.PORT) || 8
|
|
|
25
25
|
building = true;
|
|
26
26
|
const t0 = Date.now(), wasAll = all, stories = [...changed];
|
|
27
27
|
all = false; changed = new Set();
|
|
28
|
-
const scope = story ? ['--story', story
|
|
28
|
+
const scope = [...(story ? ['--story', story] : wasAll ? [] : stories.flatMap(s => ['--only', s])), ...(out ? ['--out', out] : [])];
|
|
29
29
|
const p = spawn(process.execPath, [path.join(PKG, 'bin', 'kitap.mjs'), 'build', '--local-assets', ...(engine ? ['--engine', engine] : []), ...scope], { stdio: 'inherit', cwd: root });
|
|
30
30
|
p.on('exit', code => {
|
|
31
31
|
building = false;
|
package/src/i18n/en.yaml
CHANGED
|
@@ -28,6 +28,10 @@ search_stories: Search stories
|
|
|
28
28
|
no_match: "No story matches “{q}”."
|
|
29
29
|
map_data: Data
|
|
30
30
|
other_sources: Other sources
|
|
31
|
+
ask: Ask AI
|
|
32
|
+
ask_title: Ask about this page
|
|
33
|
+
ask_prompt: "I am reading “{title}” at {url}. Answer my questions about it from this text and the page's sources, and say when you are unsure."
|
|
34
|
+
ask_note: Opens in a new tab with the page's text as the question.
|
|
31
35
|
start: Start
|
|
32
36
|
not_found: There is no page at this address.
|
|
33
37
|
settings: Settings
|
package/src/i18n/tr.yaml
CHANGED
|
@@ -28,6 +28,10 @@ search_stories: Hikâye ara
|
|
|
28
28
|
no_match: "“{q}” ile eşleşen hikâye yok."
|
|
29
29
|
map_data: Veri
|
|
30
30
|
other_sources: Diğer kaynaklar
|
|
31
|
+
ask: Yapay zekâya sor
|
|
32
|
+
ask_title: Bu sayfayı sor
|
|
33
|
+
ask_prompt: "“{title}” sayfasını okuyorum: {url}. Sorularımı bu metne ve sayfanın kaynaklarına dayanarak yanıtla, emin olmadığında söyle."
|
|
34
|
+
ask_note: Yeni sekmede, sayfanın metni soru olarak açılır.
|
|
31
35
|
start: Başla
|
|
32
36
|
not_found: Bu adreste bir sayfa yok.
|
|
33
37
|
settings: Ayarlar
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ export { build, buildPages, checkStory as check, readStory, storyAssets, uiCatal
|
|
|
4
4
|
export { dev } from './dev.mjs';
|
|
5
5
|
export { i18n } from './translate.mjs';
|
|
6
6
|
export { image, imageIndex, findImage, rehash } from './image.mjs';
|
|
7
|
+
export { imageCandidates } from './candidates.mjs';
|
|
7
8
|
export { upload } from './upload.mjs';
|
|
8
9
|
export { deploy } from './deploy.mjs';
|
|
9
10
|
export { resolve } from './routes.mjs';
|
package/src/schema.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export const Site = z.object({
|
|
|
28
28
|
themes: Themes.optional(),
|
|
29
29
|
url: z.string().url().optional(), // where the site is published, for canonical links, sitemaps and llms.txt
|
|
30
30
|
repository: z.string().url().optional(), // the content repository: a Contribute link on the main page, a source link per story
|
|
31
|
+
ask: z.array(z.enum(['chatgpt', 'claude'])).default([]), // assistants a page offers to open with its text as the prompt
|
|
31
32
|
branch: z.string().default('main'),
|
|
32
33
|
// the object store that holds the images and the engine's objects: url serves them, bucket and endpoint take uploads
|
|
33
34
|
assets: z.object({ url: z.string().url().refine(u => u.endsWith('/'), 'ends in /'), bucket: z.string().min(1), endpoint: z.string().url() }).strict().optional(),
|