@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 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 `&quot;`,
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=,]|&quot;|&#34;|&#39;)(${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
+ }
@@ -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.9.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"
@@ -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>;