@jtakeit/kit 0.8.0 → 0.10.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/README.md +26 -0
- package/lib/devdiary.mjs +199 -0
- package/lib/kept.mjs +121 -0
- package/lib/meta.mjs +44 -3
- package/package.json +6 -1
- package/types/kept.d.ts +21 -0
- package/types/meta.d.ts +16 -0
package/README.md
CHANGED
|
@@ -131,10 +131,36 @@ import { writeMeta } from '@jtakeit/kit/meta';
|
|
|
131
131
|
await writeMeta('dist', {
|
|
132
132
|
collections, blocks, locales, // the declaration: the specimens are derived from it
|
|
133
133
|
immutable: ['/_next/static/'], // where this generator keeps its hashed files; absent, the edge assumes Astro's
|
|
134
|
+
cache: [{ prefix: '/clips/', maxAge: 86400 }], // files that keep their names, kept a day; see below
|
|
134
135
|
redirects: [{ from: '/old', to: '/new', status: 301 }],
|
|
135
136
|
});
|
|
136
137
|
```
|
|
137
138
|
|
|
139
|
+
### A file a script picks by name
|
|
140
|
+
|
|
141
|
+
A film or a photograph a script addresses has no attribute pointing at it, so
|
|
142
|
+
the generator does not hash it and it lives in `public/`, which the edge
|
|
143
|
+
serves for a minute. `keepByName` gives it a name that carries its bytes,
|
|
144
|
+
after the build and before `writeMeta`:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { keepByName } from '@jtakeit/kit/kept';
|
|
148
|
+
|
|
149
|
+
const { kept, unreferenced } = keepByName('dist', { dirs: ['clips', 'scenes'] });
|
|
150
|
+
// /clips/einsatz.mp4 → /_astro/kept/clips/einsatz.3f9a1c2b.mp4, kept for a year
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Every address the build wrote whole — in a script, `src`, `data-src`,
|
|
154
|
+
`url(…)` — is pointed at the copy; the originals stay, and `unreferenced`
|
|
155
|
+
names the ones a script assembles and that therefore still go by name.
|
|
156
|
+
|
|
157
|
+
Or keep the names and declare how long: `cache: [{ prefix: '/clips/', maxAge:
|
|
158
|
+
86400 }]` beside `immutable`. The platform then holds every such name to its
|
|
159
|
+
contents for that long once the site has an address — other bytes under it are
|
|
160
|
+
refused, `JTK_E_CACHED_FILE_CHANGED`, and lowering `maxAge` frees nothing
|
|
161
|
+
sooner. A day for names that may be reused; a year only for names that carry
|
|
162
|
+
their version.
|
|
163
|
+
|
|
138
164
|
The **specimen pages** are the one thing in that list a site has to build as
|
|
139
165
|
well as declare: one page per block a post may hold, at
|
|
140
166
|
`<collection prefix>/_fl-<type>[-<view>]/` and per extra language — the panel
|
package/lib/devdiary.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diary on the dev server — so a booking form can be walked on localhost.
|
|
3
|
+
*
|
|
4
|
+
* On the platform `/api/availability`, `/api/book` and the rest are the edge's;
|
|
5
|
+
* on `astro dev` there was nothing there, and every booking form on localhost
|
|
6
|
+
* said nothing at all: no times, no button that did anything (Schorem, 15
|
|
7
|
+
* October 2026). A developer judging a page by walking it could not walk the
|
|
8
|
+
* one thing the page is for.
|
|
9
|
+
*
|
|
10
|
+
* This answers the same requests, in the same shape, from the site's own files:
|
|
11
|
+
* the hours, the zone and the slot length in `jtk/bookings.json`, the services
|
|
12
|
+
* and the people in `jtk/content` (the collections `jtk/catalogue.json` names
|
|
13
|
+
* for the bookings module). A booking is kept in memory until the server
|
|
14
|
+
* stops — so the time it took is taken — and nothing ever leaves the machine.
|
|
15
|
+
* It is not the platform's diary: no buffers between visits, no resource's own
|
|
16
|
+
* hours, no rates by the hour of the week. Close enough to walk; `preflight`
|
|
17
|
+
* with `try_booking` is still the test of the real one.
|
|
18
|
+
*
|
|
19
|
+
* import { devDiary } from '@jtakeit/kit/devdiary';
|
|
20
|
+
* server.middlewares.use('/api', devDiary(process.cwd()));
|
|
21
|
+
*/
|
|
22
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
26
|
+
|
|
27
|
+
async function json(path) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every content file under jtk/content, with its slug. */
|
|
36
|
+
async function entries(root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const walk = async (dir) => {
|
|
39
|
+
let names = [];
|
|
40
|
+
try {
|
|
41
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const one of names) {
|
|
46
|
+
const full = join(dir, one.name);
|
|
47
|
+
if (one.isDirectory()) await walk(full);
|
|
48
|
+
else if (one.name.endsWith('.json')) {
|
|
49
|
+
const doc = await json(full);
|
|
50
|
+
if (doc?.collection) out.push({ ...doc, slug: String(doc.path ?? one.name).split('/').filter(Boolean).pop()?.replace(/\.json$/, '') });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
await walk(join(root, 'jtk', 'content'));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minutes a zone is ahead of UTC on a given day, from the platform's own Intl. */
|
|
59
|
+
function offsetOf(zone, at) {
|
|
60
|
+
try {
|
|
61
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).formatToParts(at);
|
|
62
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
63
|
+
const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
|
|
64
|
+
return Math.round((local - at.getTime()) / 60000);
|
|
65
|
+
} catch {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the site's files say, read fresh on every request so an edit shows at once. */
|
|
71
|
+
async function read(root) {
|
|
72
|
+
const catalogue = (await json(join(root, 'jtk', 'catalogue.json'))) ?? {};
|
|
73
|
+
const roles = catalogue.modules?.bookings ?? {};
|
|
74
|
+
const settings = (await json(join(root, 'jtk', 'bookings.json')))?.blocks?.[0] ?? {};
|
|
75
|
+
const all = await entries(root);
|
|
76
|
+
const servicesOf = all.filter((e) => e.collection === (roles.services ?? 'services'));
|
|
77
|
+
const peopleOf = all.filter((e) => e.collection === (roles.resources ?? 'masters'));
|
|
78
|
+
const services = servicesOf.map((e) => {
|
|
79
|
+
const b = e.blocks?.[0] ?? {};
|
|
80
|
+
return { slug: e.slug, takes: Number(b.takes) || 0, costs: Number(b.costs) || 0, title: b.title ?? e.slug };
|
|
81
|
+
});
|
|
82
|
+
const people = peopleOf.map((e) => ({ slug: e.slug, does: e.blocks?.[0]?.does ?? null }));
|
|
83
|
+
return { settings, services, people };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const send = (res, status, body) => {
|
|
87
|
+
res.statusCode = status;
|
|
88
|
+
res.setHeader('Content-Type', 'application/json');
|
|
89
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
90
|
+
res.setHeader('X-Jtk-Dev-Diary', '1');
|
|
91
|
+
res.end(JSON.stringify(body));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const bodyOf = (req) =>
|
|
95
|
+
new Promise((done) => {
|
|
96
|
+
let raw = '';
|
|
97
|
+
req.on('data', (chunk) => (raw += chunk));
|
|
98
|
+
req.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
done(JSON.parse(raw || '{}'));
|
|
101
|
+
} catch {
|
|
102
|
+
done({});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** @param {string} root the site's directory */
|
|
108
|
+
export function devDiary(root) {
|
|
109
|
+
/** start ISO → slugs of the people taken then */
|
|
110
|
+
const taken = new Map();
|
|
111
|
+
let made = 0;
|
|
112
|
+
|
|
113
|
+
return async (req, res, next) => {
|
|
114
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
115
|
+
const path = url.pathname.replace(/^\/api/, '') || '/';
|
|
116
|
+
|
|
117
|
+
if (path === '/turnstile') return send(res, 200, { sitekey: '' });
|
|
118
|
+
|
|
119
|
+
if (path === '/lead' && req.method === 'POST') {
|
|
120
|
+
const body = await bodyOf(req);
|
|
121
|
+
console.log(`[dev diary] an enquiry, kept on this machine: ${JSON.stringify(body).slice(0, 200)}`);
|
|
122
|
+
return send(res, 200, { ok: true, dev: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (path === '/availability') {
|
|
126
|
+
const { settings, services, people } = await read(root);
|
|
127
|
+
if (!settings.zone || !Array.isArray(settings.hours) || settings.hours.length === 0) {
|
|
128
|
+
return send(res, 412, {
|
|
129
|
+
code: 'failed_precondition',
|
|
130
|
+
message: !settings.zone ? 'nobody has said which timezone this business keeps' : 'nobody has said when this business is open',
|
|
131
|
+
details: { code: 'JTK_E_DIARY_NOT_READY', means: 'jtk/bookings.json needs `zone` and `hours`.' },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const asked = (url.searchParams.get('service') ?? '').split(',').concat(url.searchParams.getAll('services')).filter(Boolean);
|
|
135
|
+
const chosen = services.filter((s) => asked.includes(s.slug));
|
|
136
|
+
const takes = chosen.reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
137
|
+
const costs = chosen.reduce((sum, s) => sum + s.costs, 0);
|
|
138
|
+
const every = Number(settings.slot_every) || 15;
|
|
139
|
+
const lead = Number(settings.lead) || 0;
|
|
140
|
+
const zone = settings.zone;
|
|
141
|
+
const from = new Date(url.searchParams.get('from') ?? Date.now());
|
|
142
|
+
const to = new Date(url.searchParams.get('to') ?? Date.now() + 86400000);
|
|
143
|
+
const resource = url.searchParams.get('resource') ?? '';
|
|
144
|
+
const able = people.filter((p) => (!resource || p.slug === resource) && (!p.does || chosen.every((s) => p.does.includes(s.slug))));
|
|
145
|
+
const earliest = Date.now() + lead * 60000;
|
|
146
|
+
const slots = [];
|
|
147
|
+
// Walk the days in the business's own zone, from its local midnight.
|
|
148
|
+
for (let day = new Date(from.getTime()); day < to; day = new Date(day.getTime() + 86400000)) {
|
|
149
|
+
const offset = offsetOf(zone, day);
|
|
150
|
+
const local = new Date(day.getTime() + offset * 60000);
|
|
151
|
+
const midnightUtc = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - offset * 60000;
|
|
152
|
+
const weekday = DAYS[new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate())).getUTCDay()];
|
|
153
|
+
for (const span of settings.hours.filter((h) => h.day === weekday)) {
|
|
154
|
+
for (let m = Number(span.opens); m + takes <= Number(span.closes); m += every) {
|
|
155
|
+
const start = midnightUtc + m * 60000;
|
|
156
|
+
if (start < earliest || start < from.getTime() || start >= to.getTime()) continue;
|
|
157
|
+
const iso = new Date(start).toISOString();
|
|
158
|
+
const busy = taken.get(iso) ?? [];
|
|
159
|
+
const free = (able.length ? able : [{ slug: '' }]).map((p) => p.slug).filter((s) => !busy.includes(s));
|
|
160
|
+
slots.push({ start: iso, ends: new Date(start + takes * 60000).toISOString(), free: free.filter(Boolean), costs_minor: costs });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return send(res, 200, {
|
|
165
|
+
zone,
|
|
166
|
+
slots: slots.filter((s) => s.free.length > 0 || people.length === 0),
|
|
167
|
+
party_max: 0,
|
|
168
|
+
quantity_max: 1,
|
|
169
|
+
scale: settings.scale ?? 'hourly',
|
|
170
|
+
currency: settings.currency ?? '',
|
|
171
|
+
services: services.map((s) => ({ slug: s.slug, scale: settings.scale ?? 'hourly', takes: s.takes, fewest: 1, most: 1 })),
|
|
172
|
+
dev: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (path === '/book' && req.method === 'POST') {
|
|
177
|
+
const body = await bodyOf(req);
|
|
178
|
+
const { people, services, settings } = await read(root);
|
|
179
|
+
const start = body.start ? new Date(body.start).toISOString() : '';
|
|
180
|
+
if (!start) return send(res, 400, { code: 'invalid_argument', message: 'a booking needs a start' });
|
|
181
|
+
const busy = taken.get(start) ?? [];
|
|
182
|
+
const who = body.resource || people.map((p) => p.slug).find((s) => !busy.includes(s)) || '';
|
|
183
|
+
taken.set(start, [...busy, who]);
|
|
184
|
+
made += 1;
|
|
185
|
+
console.log(`[dev diary] booked ${body.service ?? (body.services ?? []).join('+')} at ${start}${who ? ` with ${who}` : ''} for ${body.name ?? 'somebody'} — on this machine only, nothing reached the platform`);
|
|
186
|
+
// The answer the form draws its last screen from: when, until when, with
|
|
187
|
+
// whom — without `start` and `ends` it cannot say the time, and says
|
|
188
|
+
// the booking failed instead.
|
|
189
|
+
const names = [body.service, ...(Array.isArray(body.services) ? body.services : [])].filter(Boolean);
|
|
190
|
+
const takes = services.filter((s) => names.includes(s.slug)).reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
191
|
+
const ends = new Date(new Date(start).getTime() + takes * 60000).toISOString();
|
|
192
|
+
return send(res, 200, { id: `dev-${made}`, state: 'confirmed', start, ends, amount_minor: 0, currency: settings.currency ?? '', token: `dev-${made}`, manage_path: '', telegram_url: '', resource: who, dev: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (path === '/booking/cancel' && req.method === 'POST') return send(res, 200, { ok: true, dev: true });
|
|
196
|
+
|
|
197
|
+
return next();
|
|
198
|
+
};
|
|
199
|
+
}
|
package/lib/kept.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files a script addresses by name, given a name that carries their contents.
|
|
3
|
+
*
|
|
4
|
+
* ── why ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The generator hashes what the HTML refers to — a stylesheet, an `<img>` —
|
|
7
|
+
* and puts it under `/_astro/`, which the edge keeps in browsers for a year.
|
|
8
|
+
* A film or a photograph a script picks by name is not referred to by any
|
|
9
|
+
* attribute, so it has to live in `public/`, and `public/` keeps its names:
|
|
10
|
+
* the edge serves it for a minute, and every returning visitor downloads the
|
|
11
|
+
* hero film again (railtec, 19 October 2026: two megabytes).
|
|
12
|
+
*
|
|
13
|
+
* Declaring a lifetime for the directory (`cache` in `_meta.json`) is one
|
|
14
|
+
* answer, and it costs the names: the platform then holds each one to its
|
|
15
|
+
* contents for as long as was declared. This is the other answer, and the one
|
|
16
|
+
* that costs nothing later — after the build, each file under the named
|
|
17
|
+
* directories is copied under `/_astro/kept/` with a hash of its bytes in its
|
|
18
|
+
* name, and every place the build wrote its address as a literal is pointed
|
|
19
|
+
* at the copy. A new photograph is a new name by construction.
|
|
20
|
+
*
|
|
21
|
+
* ── what it does not do ─────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* It rewrites an address only where the build wrote it whole — `"/clips/
|
|
24
|
+
* einsatz.mp4"` in a script, `src`, `data-src`, `url(…)`. An address a script
|
|
25
|
+
* assembles (`'/scenes/scene-' + i + '.webp'`) cannot be found, so the
|
|
26
|
+
* original stays where it was and keeps working, at its minute; the result
|
|
27
|
+
* names every file nothing pointed at, so the site can write those addresses
|
|
28
|
+
* out in full. The originals are never removed.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { dirname, extname, join, relative, sep } from 'node:path';
|
|
33
|
+
|
|
34
|
+
/** Where the copies go: under Astro's hashed directory, which the edge keeps for a year. */
|
|
35
|
+
export const KEPT_DIR = '_astro/kept';
|
|
36
|
+
|
|
37
|
+
/** The files whose text may name an address. */
|
|
38
|
+
const TEXT = new Set(['.html', '.js', '.mjs', '.css']);
|
|
39
|
+
|
|
40
|
+
const posix = (path) => path.split(sep).join('/');
|
|
41
|
+
|
|
42
|
+
function walk(dir, out = []) {
|
|
43
|
+
for (const name of readdirSync(dir)) {
|
|
44
|
+
if (name.startsWith('.')) continue;
|
|
45
|
+
const full = join(dir, name);
|
|
46
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
47
|
+
else out.push(full);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `scene-1.webp` and its bytes → `scene-1.3f9a1c2b.webp`. */
|
|
53
|
+
export function keptName(name, bytes) {
|
|
54
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
55
|
+
const ext = extname(name);
|
|
56
|
+
return `${name.slice(0, name.length - ext.length)}.${hash}${ext}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Copies the files under `dirs` of a build directory to hashed names and
|
|
61
|
+
* points the build's literal references at them.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} dist the build directory
|
|
64
|
+
* @param {{ dirs: string[]; base?: string }} options
|
|
65
|
+
* `dirs`, as they sit under `public/` — `['clips', 'scenes']`; `base`, the
|
|
66
|
+
* site's base path when it is not `/` (Astro's `base`)
|
|
67
|
+
* @returns {{ kept: { from: string; to: string }[]; rewritten: number; unreferenced: string[] }}
|
|
68
|
+
* every copy as addresses, how many references were pointed at one, and the
|
|
69
|
+
* addresses no file named whole — still served by name, at the edge's minute
|
|
70
|
+
*/
|
|
71
|
+
export function keepByName(dist, { dirs, base = '/' } = {}) {
|
|
72
|
+
if (!Array.isArray(dirs) || dirs.length === 0) return { kept: [], rewritten: 0, unreferenced: [] };
|
|
73
|
+
const root = `/${base.replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/');
|
|
74
|
+
|
|
75
|
+
const kept = [];
|
|
76
|
+
for (const one of dirs) {
|
|
77
|
+
const dir = one.replace(/^\/+|\/+$/g, '');
|
|
78
|
+
if (dir === '' || dir.startsWith('_astro') || dir.startsWith('.')) {
|
|
79
|
+
throw new TypeError(`keepByName: "${one}" is not a directory of public/ (not the root, not _astro, not hidden)`);
|
|
80
|
+
}
|
|
81
|
+
const from = join(dist, dir);
|
|
82
|
+
if (!existsSync(from)) continue;
|
|
83
|
+
for (const file of walk(from)) {
|
|
84
|
+
if (extname(file).toLowerCase() === '.html') continue;
|
|
85
|
+
const inside = posix(relative(from, file));
|
|
86
|
+
const hashed = posix(join(dirname(inside), keptName(inside.split('/').pop(), readFileSync(file))));
|
|
87
|
+
const to = join(dist, KEPT_DIR, dir, hashed);
|
|
88
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
89
|
+
copyFileSync(file, to);
|
|
90
|
+
kept.push({ from: `${root}${dir}/${inside}`, to: `${root}${KEPT_DIR}/${dir}/${hashed.replace(/^\.\//, '')}` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (kept.length === 0) return { kept, rewritten: 0, unreferenced: [] };
|
|
94
|
+
|
|
95
|
+
// One pattern for every address, longest first, bounded the way an address
|
|
96
|
+
// is written: after a quote, a bracket, a space, `=` or `,` — or `"`,
|
|
97
|
+
// which is how a JSON list in a `data-` attribute reaches the HTML — and
|
|
98
|
+
// before a quote, a bracket, a space, `,`, `?`, `#` or `&`. `/clips/a.mp4`
|
|
99
|
+
// must not match inside `/clips/a.mp4.old` or `/x/clips/a.mp4`.
|
|
100
|
+
const to = new Map(kept.map((one) => [one.from, one.to]));
|
|
101
|
+
const escaped = [...to.keys()].sort((a, b) => b.length - a.length).map((one) => one.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
102
|
+
const pattern = new RegExp(`(?<=["'\`(\\s=,]|"|"|')(${escaped.join('|')})(?=["'\`)\\s,?#&])`, 'g');
|
|
103
|
+
|
|
104
|
+
const used = new Set();
|
|
105
|
+
let rewritten = 0;
|
|
106
|
+
const copies = join(dist, KEPT_DIR) + sep;
|
|
107
|
+
for (const file of walk(dist)) {
|
|
108
|
+
if (file.startsWith(copies) || !TEXT.has(extname(file).toLowerCase())) continue;
|
|
109
|
+
const text = readFileSync(file, 'utf8');
|
|
110
|
+
let changed = false;
|
|
111
|
+
const next = text.replace(pattern, (address) => {
|
|
112
|
+
used.add(address);
|
|
113
|
+
rewritten += 1;
|
|
114
|
+
changed = true;
|
|
115
|
+
return to.get(address);
|
|
116
|
+
});
|
|
117
|
+
if (changed) writeFileSync(file, next);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { kept, rewritten, unreferenced: kept.map((one) => one.from).filter((one) => !used.has(one)) };
|
|
121
|
+
}
|
package/lib/meta.mjs
CHANGED
|
@@ -133,15 +133,56 @@ export async function hiddenEntries(root = join(process.cwd(), 'jtk', 'content')
|
|
|
133
133
|
* @param {string[]} [what.locales]
|
|
134
134
|
* @param {string[]} [what.immutable] e.g. `['/_astro/']`
|
|
135
135
|
* @param {{ from: string; to: string; status?: number }[]} [what.redirects]
|
|
136
|
-
* @returns {Promise<{ redirects: object[]; drafts: string[]; immutable?: string[] }>}
|
|
136
|
+
* @returns {Promise<{ redirects: object[]; drafts: string[]; immutable?: string[]; cache?: { prefix: string; maxAge: number }[] }>}
|
|
137
137
|
*/
|
|
138
|
-
export async function buildMeta({ root, collections = [], blocks = [], locales = [], immutable, redirects = [] } = {}) {
|
|
138
|
+
export async function buildMeta({ root, collections = [], blocks = [], locales = [], immutable, cache, redirects = [] } = {}) {
|
|
139
139
|
const drafts = [...(await hiddenEntries(root)), ...specimenAddresses(collections, blocks, locales)].sort();
|
|
140
140
|
const meta = { redirects, drafts };
|
|
141
141
|
if (Array.isArray(immutable) && immutable.length > 0) meta.immutable = immutable;
|
|
142
|
+
const lifetimes = cacheRules(cache);
|
|
143
|
+
if (lifetimes.length > 0) meta.cache = lifetimes;
|
|
142
144
|
return meta;
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/** A year: the most the edge keeps anything for. */
|
|
148
|
+
const YEAR = 31_536_000;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The lifetimes a build declares for directories of files that keep their own
|
|
152
|
+
* names — `public/clips/`, a film or a photograph a script addresses — read
|
|
153
|
+
* the way the edge reads them (jtakeit-core edge/src/meta.ts): each prefix as
|
|
154
|
+
* `/dir/`, never `/`, a whole number of seconds, at most a year.
|
|
155
|
+
*
|
|
156
|
+
* Thrown on rather than dropped, unlike the edge: the edge reads somebody
|
|
157
|
+
* else's file and must not fall over, the build is ours and should be told.
|
|
158
|
+
*
|
|
159
|
+
* Every name under such a directory is then held by the platform to its
|
|
160
|
+
* contents, once the site has an address, until the last visitor's copy may
|
|
161
|
+
* have run out: other bytes under it are refused (JTK_E_CACHED_FILE_CHANGED),
|
|
162
|
+
* and lowering `maxAge` frees nothing sooner. A day for names that may be
|
|
163
|
+
* reused; a year only for names that carry their version — or put the files
|
|
164
|
+
* under a hashed name with `keepByName` (`@jtakeit/kit/kept`) and declare
|
|
165
|
+
* nothing.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ prefix: string; maxAge: number }[] | undefined} cache
|
|
168
|
+
* @returns {{ prefix: string; maxAge: number }[]}
|
|
169
|
+
*/
|
|
170
|
+
export function cacheRules(cache) {
|
|
171
|
+
if (cache === undefined || cache === null) return [];
|
|
172
|
+
if (!Array.isArray(cache)) throw new TypeError('cache is a list of { prefix, maxAge }');
|
|
173
|
+
return cache.map((one, i) => {
|
|
174
|
+
if (one === null || typeof one !== 'object' || typeof one.prefix !== 'string') {
|
|
175
|
+
throw new TypeError(`cache[${i}] needs a prefix, like "/clips/"`);
|
|
176
|
+
}
|
|
177
|
+
const trimmed = one.prefix.replace(/^\/+|\/+$/g, '');
|
|
178
|
+
if (trimmed === '') throw new TypeError(`cache[${i}]: a prefix of "/" would cover the whole site, pages included`);
|
|
179
|
+
if (typeof one.maxAge !== 'number' || !Number.isFinite(one.maxAge) || one.maxAge < 0) {
|
|
180
|
+
throw new TypeError(`cache[${i}]: maxAge is a number of seconds, like 86400 for a day`);
|
|
181
|
+
}
|
|
182
|
+
return { prefix: `/${trimmed}/`, maxAge: Math.min(Math.floor(one.maxAge), YEAR) };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
145
186
|
/**
|
|
146
187
|
* Writes `_meta.json` into a build directory — whatever happens, empty lists
|
|
147
188
|
* and all, because a missing file and an empty one mean different things to
|
|
@@ -149,7 +190,7 @@ export async function buildMeta({ root, collections = [], blocks = [], locales =
|
|
|
149
190
|
*
|
|
150
191
|
* @param {string} dir the build directory, `dist/`
|
|
151
192
|
* @param {Parameters<typeof buildMeta>[0]} what
|
|
152
|
-
* @returns {Promise<{ redirects: object[]; drafts: string[]; immutable?: string[] }>}
|
|
193
|
+
* @returns {Promise<{ redirects: object[]; drafts: string[]; immutable?: string[]; cache?: { prefix: string; maxAge: number }[] }>}
|
|
153
194
|
*/
|
|
154
195
|
export async function writeMeta(dir, what = {}) {
|
|
155
196
|
const meta = await buildMeta(what);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtakeit/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The framework-free half of a site on jtakeit: the booking form as an element and as markup, for Astro through @jtakeit/astro and for any other generator directly",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,11 +40,16 @@
|
|
|
40
40
|
"types": "./types/meta.d.ts",
|
|
41
41
|
"default": "./lib/meta.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./kept": {
|
|
44
|
+
"types": "./types/kept.d.ts",
|
|
45
|
+
"default": "./lib/kept.mjs"
|
|
46
|
+
},
|
|
43
47
|
"./kits": {
|
|
44
48
|
"types": "./types/kits.d.ts",
|
|
45
49
|
"default": "./lib/kits.mjs"
|
|
46
50
|
},
|
|
47
51
|
"./catalogue": "./lib/catalogue.mjs",
|
|
52
|
+
"./devdiary": "./lib/devdiary.mjs",
|
|
48
53
|
"./codes": "./lib/codes.mjs",
|
|
49
54
|
"./elements/booking": {
|
|
50
55
|
"types": "./types/elements-booking.d.ts",
|
package/types/kept.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Where the copies go: under Astro's hashed directory, which the edge keeps for a year. */
|
|
2
|
+
export const KEPT_DIR: string;
|
|
3
|
+
|
|
4
|
+
/** `scene-1.webp` and its bytes → `scene-1.3f9a1c2b.webp`. */
|
|
5
|
+
export function keptName(name: string, bytes: Uint8Array): string;
|
|
6
|
+
|
|
7
|
+
export interface Kept {
|
|
8
|
+
/** Every copy, as addresses: `/clips/a.mp4` → `/_astro/kept/clips/a.3f9a1c2b.mp4`. */
|
|
9
|
+
kept: { from: string; to: string }[];
|
|
10
|
+
/** How many references in the build were pointed at a copy. */
|
|
11
|
+
rewritten: number;
|
|
12
|
+
/** Addresses no file of the build named whole — still served by name, at the edge's minute. */
|
|
13
|
+
unreferenced: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Copies the files under `dirs` of a build directory to names that carry a
|
|
18
|
+
* hash of their bytes, under `/_astro/kept/`, and points the build's literal
|
|
19
|
+
* references at them. The originals stay.
|
|
20
|
+
*/
|
|
21
|
+
export function keepByName(dist: string, options: { dirs: string[]; base?: string }): Kept;
|
package/types/meta.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ export interface Meta {
|
|
|
2
2
|
redirects: { from: string; to: string; status?: number }[];
|
|
3
3
|
drafts: string[];
|
|
4
4
|
immutable?: string[];
|
|
5
|
+
cache?: CacheRule[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** How long browsers keep the files under a directory whose names carry no hash. */
|
|
9
|
+
export interface CacheRule {
|
|
10
|
+
/** The directory, e.g. `/clips/`. Never `/`. */
|
|
11
|
+
prefix: string;
|
|
12
|
+
/** Seconds, at most a year. Pages under the directory are never covered. */
|
|
13
|
+
maxAge: number;
|
|
5
14
|
}
|
|
6
15
|
|
|
7
16
|
export interface MetaInput {
|
|
@@ -12,6 +21,12 @@ export interface MetaInput {
|
|
|
12
21
|
locales?: string[];
|
|
13
22
|
/** The directories whose file names carry a content hash, e.g. `['/_astro/']`. */
|
|
14
23
|
immutable?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Lifetimes for directories of files that keep their names. Each name is
|
|
26
|
+
* then held to its contents by the platform for that long: new contents
|
|
27
|
+
* take a new name (JTK_E_CACHED_FILE_CHANGED).
|
|
28
|
+
*/
|
|
29
|
+
cache?: CacheRule[];
|
|
15
30
|
redirects?: { from: string; to: string; status?: number }[];
|
|
16
31
|
}
|
|
17
32
|
|
|
@@ -22,5 +37,6 @@ export function specimenAddresses(
|
|
|
22
37
|
locales?: string[],
|
|
23
38
|
): string[];
|
|
24
39
|
export function hiddenEntries(root?: string, now?: number): Promise<string[]>;
|
|
40
|
+
export function cacheRules(cache?: CacheRule[]): CacheRule[];
|
|
25
41
|
export function buildMeta(what?: MetaInput): Promise<Meta>;
|
|
26
42
|
export function writeMeta(dir: string, what?: MetaInput): Promise<Meta>;
|