@jtakeit/kit 0.9.0 → 0.11.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/catalogue.mjs +5 -0
- package/lib/kept.mjs +121 -0
- package/lib/lead/render.mjs +1 -1
- package/lib/meta.mjs +44 -3
- package/package.json +5 -1
- package/types/kept.d.ts +21 -0
- package/types/lead.d.ts +7 -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/catalogue.mjs
CHANGED
|
@@ -1533,6 +1533,11 @@ for (const page of pages) {
|
|
|
1533
1533
|
// for either way rather than reported as text nobody can touch.
|
|
1534
1534
|
for (const key of ['src', 'alt', 'poster']) onThePage.delete(`${itemPath}.${key}`);
|
|
1535
1535
|
});
|
|
1536
|
+
// The wall itself may be annotated too — the belt the logos run on,
|
|
1537
|
+
// the frame a gallery sits in — and a tap on it opens the same
|
|
1538
|
+
// gallery with nothing selected. Allowed, not asked for: a wall whose
|
|
1539
|
+
// every picture is tappable is complete without it (25 October 2026).
|
|
1540
|
+
onThePage.delete(path);
|
|
1536
1541
|
continue;
|
|
1537
1542
|
}
|
|
1538
1543
|
|
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/lead/render.mjs
CHANGED
|
@@ -145,7 +145,7 @@ export function renderLeadForm(props) {
|
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
147
|
${asks.map((ask) => `<div class="field">
|
|
148
|
-
<label class="field__label" for="lead-${esc(ask.key)}" data-jtk-fixed>${esc(ask.label)}${ask.required ? ' <span aria-hidden="true">*</span>' : ''}</label>
|
|
148
|
+
<label class="field__label" for="lead-${esc(ask.key)}"${ask.path ? at(ask.path) : ' data-jtk-fixed'}>${esc(ask.label)}${ask.required ? ' <span aria-hidden="true">*</span>' : ''}</label>
|
|
149
149
|
${askControl(ask)}
|
|
150
150
|
</div>`).join('\n ')}
|
|
151
151
|
|
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.11.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,6 +40,10 @@
|
|
|
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"
|
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/lead.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export interface Ask {
|
|
2
2
|
key: string;
|
|
3
3
|
label: string;
|
|
4
|
+
/**
|
|
5
|
+
* Where the words over the field come from, when the site keeps them in
|
|
6
|
+
* its content (a `<key>_label` field of the collecting block, say) and
|
|
7
|
+
* the owner may tap them. Without it the label is drawn fixed, as the
|
|
8
|
+
* catalogue declared it.
|
|
9
|
+
*/
|
|
10
|
+
path?: string;
|
|
4
11
|
kind: string;
|
|
5
12
|
required?: boolean;
|
|
6
13
|
max?: number;
|
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>;
|