@descix/frqtl-node 0.6.0-p1.1 → 0.6.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.
Files changed (80) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +6 -4
  3. package/dist/dist-manifest.json +71 -65
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/fat/pkg-node/package.json +2 -10
  6. package/dist/frqtl/compat/providers/wasm-engine.js +3 -4
  7. package/dist/frqtl/compat/renderer/providers/p5-provider.js +1 -1
  8. package/dist/frqtl/objects/FrqtlApp.js +33 -6
  9. package/dist/frqtl/objects/RecipeContext.js +284 -13
  10. package/dist/frqtl/objects/constants/WallBehaviors.js +1 -1
  11. package/dist/frqtl/objects/instruments/capture.js +1 -24
  12. package/dist/frqtl/objects/instruments/hosted-eyes.js +216 -29
  13. package/dist/frqtl/objects/objects/makeControlsForObject.js +5 -5
  14. package/dist/frqtl/objects/record/Record.js +346 -0
  15. package/dist/frqtl/objects/record/open-record.js +22 -0
  16. package/dist/frqtl/objects/schema/engine-scopes.js +2 -2
  17. package/dist/frqtl/objects/schema/scopes.js +2 -2
  18. package/dist/frqtl/objects/setup/_worldInit.js +1 -2
  19. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +1 -2
  20. package/dist/frqtl/parts/CameraControls.js +8 -0
  21. package/dist/frqtl/parts/DetectorWall.js +1 -1
  22. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  23. package/dist/frqtl/parts/GraphingDetectorWall.js +7 -7
  24. package/dist/frqtl/parts/LargeObject.js +2 -2
  25. package/dist/frqtl/parts/LiveChart.js +2 -2
  26. package/dist/frqtl/parts/LogicGate.js +3 -3
  27. package/dist/frqtl/parts/OvenBox.js +1 -1
  28. package/dist/frqtl/parts/PointSource.js +1 -1
  29. package/dist/frqtl/parts/SlitLightSource.js +1 -1
  30. package/dist/frqtl/parts/SlitScreen.js +1 -1
  31. package/dist/frqtl/parts/SphereEmitter.js +12 -12
  32. package/dist/frqtl/parts/Trace.js +1 -1
  33. package/dist/frqtl/parts/Tunnel.js +11 -11
  34. package/dist/frqtl/parts/Wall.js +20 -10
  35. package/dist/frqtl/scenes/circuit-sat.js +1 -0
  36. package/dist/frqtl/scenes/dse-one-emitter.js +32 -34
  37. package/dist/frqtl/scenes/dse-two-emitters.js +33 -34
  38. package/dist/frqtl/scenes/mirror-route.js +96 -79
  39. package/dist/shim/bundle/index.js +118 -96
  40. package/dist/shim/bundle/indexHtml.js +66 -39
  41. package/dist/shim/controls/index.js +1 -1
  42. package/dist/shim/engine/artifact-shell.js +1 -1
  43. package/dist/shim/engine/router.js +3 -3
  44. package/dist/shim/host/host-view.js +228 -0
  45. package/dist/shim/host/index.js +18 -0
  46. package/dist/shim/index.js +3 -5
  47. package/dist/shim/notebook/format.js +1 -1
  48. package/dist/shim/notebook/kernel.js +1 -1
  49. package/dist/shim/notebook/setup-builder.js +2 -2
  50. package/dist/shim/notebook/store.js +2 -3
  51. package/dist/shim/package.json +1 -0
  52. package/dist/shim/page-params/index.js +2 -30
  53. package/dist/shim/project/fork.js +293 -0
  54. package/dist/shim/project/identity.js +3 -1
  55. package/dist/shim/project/index.js +16 -3
  56. package/dist/shim/project/link.js +224 -46
  57. package/dist/shim/project/manifest.js +151 -0
  58. package/dist/shim/project/parts-writer.js +68 -7
  59. package/dist/shim/project/read-text.js +43 -0
  60. package/dist/shim/project/record-format.js +345 -0
  61. package/dist/shim/project/specifiers.js +104 -15
  62. package/dist/shim/registry/exhibits.js +15 -34
  63. package/dist/shim/registry/index.js +3 -3
  64. package/dist/shim/registry/scene-roster.js +35 -0
  65. package/dist/shim/registry/scene-source.js +48 -64
  66. package/dist/shim/registry/walkthrough.js +6 -6
  67. package/dist/shim/render-state/apply.js +66 -31
  68. package/dist/shim/render-state/index.js +6 -2
  69. package/dist/shim/runtime/index.js +4 -4
  70. package/dist/shim/storage/codec-chains.js +88 -0
  71. package/dist/shim/storage/folders.js +205 -0
  72. package/dist/shim/storage/index.js +64 -8
  73. package/dist/shim/storage/make-namespace-store.js +5 -5
  74. package/llms.txt +4 -3
  75. package/package.json +19 -1
  76. package/dist/shim/bundle/frqnb.js +0 -112
  77. package/dist/shim/bundle/manifest.js +0 -95
  78. package/dist/shim/registry/experiments.js +0 -215
  79. package/dist/shim/storage/modules.js +0 -249
  80. package/dist/shim/storage/projects.js +0 -150
@@ -1,15 +1,19 @@
1
1
  /* Copyright 2023-2026 by Essam Abadir */
2
2
 
3
3
  /**
4
- * link.js — THE link site: a project (one or more ES module files held as text, or a served folder)
5
- * becomes imported modules, and its entry's default export is the scene.
4
+ * link.js — THE link site: a project (a folder of files held as text, or a served folder) becomes imported
5
+ * modules, and its entry's default export is the scene.
6
6
  *
7
- * linkProject(project) → Promise<{ buildExperiment, files, urls, project }> files: { path: Uint8Array } — the bytes linked
8
- * project = { files: { 'sketch.js': text, 'parts/X.js': text, … }, entry, mint, bare? }
9
- * | { url: '<served folder>/', entry, mint?, bare? }
7
+ * linkProject(project) → Promise<{ buildExperiment, module, manifest, files, urls, project }> THE result contract
8
+ * project = { files: { 'frqtl.json': text, 'sketch.js': text, 'parts/X.js': text, 'README.md': text, … }, entry?, mint, bare? }
9
+ * | { url: '<served folder>/', entry?, mint, bare? }
10
10
  * url absolute, or relative to the document's base URL in a page ('./'); ends in "/"
11
- * entry REQUIRED — the root-relative path of the file whose default export is
12
- * `function buildExperiment(exp)`
11
+ * entry the root-relative path of the module whose default export is `function buildExperiment(exp)`.
12
+ * It comes from frqtl.json (manifest.js, the one owner) when the project carries one; an `entry`
13
+ * given beside a manifest must EQUAL manifest.entry, or the link REJECTS naming both. With neither
14
+ * a manifest nor an entry the link REJECTS naming frqtl.json. A served folder's
15
+ * `<url>frqtl.json` is fetched first; a 404 there is allowed only when `entry` is given (then
16
+ * there is no manifest).
13
17
  * mint REQUIRED — the host's (text, path) → { url, release? }: `blobMint` in a page, `dataMint` in
14
18
  * node. Hosts differ ONLY here. A served folder is minted too: each file is fetched ONCE, as
15
19
  * bytes; those bytes are the identity, and the same bytes, decoded once (UTF-8, fatal), are
@@ -18,32 +22,50 @@
18
22
  * lib-identity path (specifiers.js libPathOf) under THIS copy's package root
19
23
  * (compat/package-root.js), so a project links against the very modules its boot loaded.
20
24
  * A host whose modules are not served one file per URL (frqtl-web.min.js) passes its own.
21
- * resolves { buildExperiment, files, urls, project } — files is the project's BYTES by path (fetched, for a
22
- * served folder; the UTF-8 encoding of each text, for a file map), the input of projectIdentity
23
- * (identity.js); urls is each file's minted URL by path (a forked part registers its kind `from` that
24
- * URL — lib/shim/project/parts-module.js maps it back to the path); project is the linked project as
25
- * TEXT — { files: { path: text }, entry, mint, bare? } — what a facade holds and re-links
26
- * (RecipeContext.build(linked)). A served folder's project is its decoded files.
25
+ * resolves
26
+ * buildExperiment the entry's default export
27
+ * module the entry module's namespace (its `parts` block is `module.parts`)
28
+ * manifest frqtl.json's normalized bag (manifest.js normalizeManifest), or null when the project
29
+ * was linked by `entry` alone
30
+ * files THE LINKED SET's bytes by path — frqtl.json and every module file (linkedFiles): fetched,
31
+ * for a served folder; the UTF-8 encoding of each text, for a file map. The input of
32
+ * projectIdentity (identity.js).
33
+ * urls each module's minted URL by path (a forked part registers its kind `from` that URL —
34
+ * lib/shim/project/parts-module.js maps it back to the path)
35
+ * project the linked project as TEXT — { files: { path: text }, entry, mint, bare? } — what a facade
36
+ * holds and re-links (RecipeContext.build(linked)). For a file map its files are EVERY file
37
+ * given (frqtl.json and README.md included), so a facade's sketch is the whole folder; for a
38
+ * served folder they are the files fetched, decoded.
27
39
  *
28
- * In a project, a file imports another project file by a RELATIVE specifier, and the package by
40
+ * THE LINKED SET (linkedFiles(files) → the sub-map): frqtl.json plus every MODULE file — a path ending ".js" or
41
+ * ".mjs" — a pure path function of the folder, so every host hashes one set. Each module file must be reached
42
+ * from the entry; any other file (README.md, notes.txt) travels with the folder, unlinked and unhashed.
43
+ *
44
+ * In a project, a module imports another project module by a RELATIVE specifier, and the package by
29
45
  * `@descix/frqtl-web/...`; nothing else. Relative specifiers are rewritten to the minted URLs of the files
30
46
  * they name, in topological order (a file is minted after everything it imports). REFUSED before anything
31
47
  * is minted, each naming the file, the specifier and the reason: a relative specifier that climbs out of
32
- * the root (package internals have no public specifier), one that names no project file (never an HTTP
33
- * sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, any other specifier;
34
- * an import cycle among project files; a file-map entry the entry never reaches (a project is exactly the
35
- * module graph its entry reaches, so every host hashes one set); a served file that is not UTF-8; a missing or non-function
36
- * default export of the entry, a missing entry, an empty project. Every argument error REJECTS.
48
+ * the root (package internals have no public specifier), one that names a non-module path (not ".js" /
49
+ * ".mjs": a data file, an extensionless name, frqtl.json), one that names no project file (never an HTTP
50
+ * sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, a part-internal row imported
51
+ * by any file but a part file (parts/*.js), any other specifier;
52
+ * an import cycle among project files; a module file the entry never reaches; an invalid frqtl.json; a
53
+ * served file that is not UTF-8; a missing or non-function default export of the entry, a missing entry,
54
+ * an empty project. Every argument error REJECTS.
55
+ *
56
+ * An error raised while the project EVALUATES (a duplicate kind, a throw at module top) is rethrown with every minted
57
+ * URL in its message named by its project path and every package file URL by its specifier (_namePaths).
37
58
  *
38
- * blobMint(text) → { url, release() } a `blob:` module URL (a page)
59
+ * blobMint(text) → { url, release() } a `blob:` module URL (a page), one per byte-identical text
39
60
  * dataMint(text) → { url, release() } a `data:` module URL (node)
40
61
  *
41
62
  * A one-string sketch is the one-file project: `{ files: { 'sketch.js': text }, entry: 'sketch.js' }` —
42
63
  * sketchProject(text) is that shape's one writer inside the tree (SKETCH_ENTRY its entry path).
43
64
  */
44
65
 
45
- import { libPathOf, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
66
+ import { libPathOf, tierOf, specifierOfLibPath, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
46
67
  import { checkProjectPath, projectBytes } from './identity.js';
68
+ import { MANIFEST_FILE, parseManifest } from './manifest.js';
47
69
  import { packageRoot } from '../../frqtl/compat/package-root.js';
48
70
 
49
71
  const SKETCH_FORM = 'export default function buildExperiment(exp) { … }';
@@ -116,6 +138,14 @@ function _publishedList() {
116
138
  return FRQTL_WEB_SPECIFIERS.map(function (r) { return r.specifier + (r.specifier.endsWith('/') ? '<file>' : ''); }).join(', ');
117
139
  }
118
140
 
141
+ /** The tier a forked part's internals carry (specifiers.js SPECIFIER_TIERS). */
142
+ const PART_INTERNAL = 'part-internal';
143
+
144
+ /** A project file under parts/ (a part file: the only importer of a part-internal module). */
145
+ function _isPartFile(p) {
146
+ return /^parts\/[^/]+\.m?js$/.test(p);
147
+ }
148
+
119
149
  function _isPackage(spec) {
120
150
  return spec === FRQTL_WEB_PACKAGE || spec.startsWith(FRQTL_WEB_PACKAGE + '/');
121
151
  }
@@ -169,12 +199,21 @@ async function _walk(entry, sourceOf) {
169
199
  _fail('"' + p + '" imports "' + imp.spec + '" — outside the project; package internals have no ' +
170
200
  'public specifier (a project file imports the package by a @descix/frqtl-web/... row).');
171
201
  }
202
+ if (!isModulePath(target)) {
203
+ _fail('"' + p + '" imports "' + imp.spec + '" (' + target + '), which is not a module path — a project ' +
204
+ 'imports only its module files (' + MODULE_EXTENSIONS.join(' / ') + '); any other file of the folder ' +
205
+ '(a data file, an extensionless name, ' + MANIFEST_FILE + ') travels with it unlinked.');
206
+ }
172
207
  list.push(Object.assign({ kind: 'file', target: target }, imp));
173
208
  } else if (_isPackage(imp.spec)) {
174
209
  if (!_isPublished(imp.spec)) {
175
210
  _fail('"' + p + '" imports "' + imp.spec + '" — not a published specifier; the table ' +
176
211
  '(lib/shim/project/specifiers.js FRQTL_WEB_SPECIFIERS) offers: ' + _publishedList() + '.');
177
212
  }
213
+ if (tierOf(imp.spec) === PART_INTERNAL && !_isPartFile(p)) {
214
+ _fail('"' + p + '" imports "' + imp.spec + '" — a part-internal module is imported by a part file ' +
215
+ '(parts/*.js), never by a sketch — a sketch imports parts by @descix/frqtl-web/parts.');
216
+ }
178
217
  list.push(Object.assign({ kind: 'package' }, imp));
179
218
  } else {
180
219
  _fail('"' + p + '" imports "' + imp.spec + '" — a project file imports another project file by a ' +
@@ -218,17 +257,46 @@ function _rewrite(text, list, urlOf) {
218
257
  return out;
219
258
  }
220
259
 
260
+ /** The extensions of a project MODULE file; every other path of a folder is a non-module file. */
261
+ const MODULE_EXTENSIONS = Object.freeze(['.js', '.mjs']);
262
+
263
+ /** A project path names a module file (".js" / ".mjs"). */
264
+ function isModulePath(p) {
265
+ return MODULE_EXTENSIONS.some(function (x) { return p.endsWith(x); });
266
+ }
267
+
221
268
  /**
222
- * A project is EXACTLY the module graph its entry reaches, so every host hashes one set: a served folder is
223
- * that set by construction, and a file map may hold nothing else. REFUSES a file-map entry the walk did not
224
- * reach, naming it.
269
+ * THE LINKED SET of a folder: the sub-map of `files` holding frqtl.json and every module file (".js" /
270
+ * ".mjs"), values as given (text or bytes). A pure function of the paths, so every host — a page door,
271
+ * hosted eyes in node — hashes one set; the link refuses a folder whose module files the entry does not
272
+ * all reach, so for a folder that links this IS the set linked. Any other file passes through unhashed.
273
+ * @param {Object<string, *>} files
274
+ * @returns {Object<string, *>}
275
+ */
276
+ export function linkedFiles(files) {
277
+ if (!files || typeof files !== 'object') {
278
+ _fail('linkedFiles(files): files is the map { path: text | bytes } (got ' + (files === null ? 'null' : typeof files) + ').');
279
+ }
280
+ const out = {};
281
+ for (const p of Object.keys(files)) {
282
+ if (p === MANIFEST_FILE || isModulePath(p)) { out[p] = files[p]; }
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /**
288
+ * Every module file of a file map is reached from the entry (a project's modules are exactly the graph its
289
+ * entry imports, so linkedFiles is the set linked). REFUSES an unreached one, naming it.
225
290
  */
226
291
  function _refuseUnreached(project, g) {
227
292
  if (project.files === undefined) { return; }
228
- const unused = Object.keys(project.files).filter(function (p) { return !Object.prototype.hasOwnProperty.call(g.files, p); });
293
+ const unused = Object.keys(linkedFiles(project.files)).filter(function (p) {
294
+ return p !== MANIFEST_FILE && !Object.prototype.hasOwnProperty.call(g.files, p);
295
+ });
229
296
  if (unused.length) {
230
- _fail('unused project file "' + unused[0] + '" — a project holds the files its entry imports' +
231
- (unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '.');
297
+ _fail('unused project file "' + unused[0] + '" — a project holds the module files its entry imports' +
298
+ (unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '; a non-module file ' +
299
+ '(README.md) travels with the folder unlinked.');
232
300
  }
233
301
  }
234
302
 
@@ -244,26 +312,84 @@ function _entryFunction(ns, entry) {
244
312
  return ns.default;
245
313
  }
246
314
 
315
+ /** The served folder's frqtl.json: { manifest, text, bytes }, or null on a 404. One fetch. */
316
+ async function _fetchManifest(base) {
317
+ const u = new URL(MANIFEST_FILE, base).href;
318
+ let res;
319
+ try { res = await fetch(u); }
320
+ catch (e) { _fail('fetching "' + MANIFEST_FILE + '" (' + u + ') failed: ' + String((e && e.message) || e) + '.'); }
321
+ if (res.status === 404) { return null; }
322
+ if (!res.ok) { _fail('"' + MANIFEST_FILE + '" is not served at ' + u + ' (HTTP ' + res.status + ').'); }
323
+ const raw = new Uint8Array(await res.arrayBuffer());
324
+ let text;
325
+ try { text = _UTF8.decode(raw); }
326
+ catch (e) { _fail('"' + MANIFEST_FILE + '" is not valid UTF-8 (' + u + ').'); }
327
+ let manifest;
328
+ try { manifest = parseManifest(text); }
329
+ catch (e) {
330
+ const type = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : null;
331
+ _fail('the served ' + MANIFEST_FILE + ' (' + u + ', HTTP ' + res.status + (type ? ', ' + type : '') + ') is refused: ' +
332
+ String((e && e.message) || e) + ' — a host that answers a missing file with a page serves no 404, so a folder ' +
333
+ 'there carries its own ' + MANIFEST_FILE + '.');
334
+ }
335
+ return { manifest: manifest, text: text, bytes: raw };
336
+ }
337
+
338
+ /**
339
+ * The entry: frqtl.json's when the project carries one (a given `entry` must equal it), else the given one.
340
+ * @returns {string}
341
+ */
342
+ function _entryOf(given, manifest) {
343
+ if (manifest) {
344
+ if (given !== undefined && given !== manifest.entry) {
345
+ _fail('entryOf: `entry` "' + given + '" disagrees with ' + MANIFEST_FILE + '\'s "entry" "' + manifest.entry +
346
+ '" — the manifest names the entry; drop `entry`, or make the two equal.');
347
+ }
348
+ return manifest.entry;
349
+ }
350
+ if (given === undefined) {
351
+ _fail('entryOf: no entry — the project carries no ' + MANIFEST_FILE + ' (manifest.js; manifestFor({ name, entry }) ' +
352
+ 'writes one) and no `entry` was given (a one-string sketch: { files: { "sketch.js": text }, entry: "sketch.js" }).');
353
+ }
354
+ return given;
355
+ }
356
+
357
+ /**
358
+ * entryOf(files, given) → the project's entry path — THE entry rule (every host asks it, never a copy): the entry
359
+ * frqtl.json names (read through its owner, parseManifest) when `files` carries one, and a `given` entry must equal
360
+ * it; else `given`. THROWS naming the rule: a disagreeing entry, no frqtl.json and no entry, an invalid frqtl.json.
361
+ * Not on PROJECT_EXPORTS (like linkedFiles).
362
+ * @param {Object<string, string>} files
363
+ * @param {string} [given]
364
+ * @returns {string}
365
+ */
366
+ export function entryOf(files, given) {
367
+ const has = files && Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE);
368
+ return _entryOf(given, has ? parseManifest(files[MANIFEST_FILE]) : null);
369
+ }
370
+
247
371
  /**
248
372
  * @param {object} project — see the file header
249
- * @returns {Promise<{buildExperiment: function, files: Object<string, Uint8Array>, urls: Object<string, string>,
250
- * project: {files: Object<string, string>, entry: string, mint: function, bare?: function}}>}
373
+ * @returns {Promise<{buildExperiment: function, module: object, manifest: ?object, files: Object<string, Uint8Array>,
374
+ * urls: Object<string, string>, project: {files: Object<string, string>, entry: string, mint: function, bare?: function}}>}
251
375
  */
252
376
  export async function linkProject(project) {
253
377
  if (!project || typeof project !== 'object') {
254
- _fail('linkProject(project): project is { files, entry, mint } or { url, entry } (got ' + typeof project + ').');
378
+ _fail('linkProject(project): project is { files, mint } or { url, mint } (+ `entry` when it carries no ' +
379
+ MANIFEST_FILE + ') (got ' + typeof project + ').');
255
380
  }
256
- const entry = project.entry;
257
- if (typeof entry !== 'string' || !entry) {
258
- _fail('linkProject: `entry` is REQUIRED — the path of the file whose default export is the scene ' +
259
- '(a one-string sketch: { files: { "sketch.js": text }, entry: "sketch.js" }).');
381
+ const given = project.entry;
382
+ if (given !== undefined) {
383
+ if (typeof given !== 'string' || !given) {
384
+ _fail('linkProject: `entry` is the root-relative path of the module whose default export is the scene (got ' +
385
+ JSON.stringify(given) + ').');
386
+ }
387
+ checkProjectPath(given);
260
388
  }
261
- checkProjectPath(entry);
262
389
  const hasFiles = project.files !== undefined;
263
390
  const hasUrl = project.url !== undefined;
264
391
  if (hasFiles === hasUrl) {
265
- _fail('linkProject: a project is EITHER { files, entry } OR { url, entry } (got ' +
266
- (hasFiles ? 'both' : 'neither') + ').');
392
+ _fail('linkProject: a project is EITHER { files } OR { url } (got ' + (hasFiles ? 'both' : 'neither') + ').');
267
393
  }
268
394
  const mint = project.mint;
269
395
  if (typeof mint !== 'function') {
@@ -276,7 +402,8 @@ export async function linkProject(project) {
276
402
  }
277
403
 
278
404
  let sourceOf;
279
- let base = null;
405
+ let found = null; // frqtl.json: { manifest, text, bytes }
406
+ let encoded = null;
280
407
  if (hasFiles) {
281
408
  const files = project.files;
282
409
  if (!files || typeof files !== 'object' || Object.keys(files).length === 0) {
@@ -286,7 +413,10 @@ export async function linkProject(project) {
286
413
  checkProjectPath(p);
287
414
  if (typeof files[p] !== 'string') { _fail('linkProject: file "' + p + '" is not text.'); }
288
415
  }
289
- const encoded = projectBytes(files);
416
+ encoded = projectBytes(files);
417
+ if (Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE)) {
418
+ found = { manifest: parseManifest(files[MANIFEST_FILE]), text: files[MANIFEST_FILE], bytes: encoded[MANIFEST_FILE] };
419
+ }
290
420
  sourceOf = async function (p, from, spec) {
291
421
  if (!Object.prototype.hasOwnProperty.call(files, p)) {
292
422
  _fail(from === undefined
@@ -303,7 +433,8 @@ export async function linkProject(project) {
303
433
  if (typeof project.url !== 'string' || !project.url.endsWith('/')) {
304
434
  _fail('linkProject({ url }): url is the served project folder, ending in "/" (got ' + JSON.stringify(project.url) + ').');
305
435
  }
306
- base = _folderUrl(project.url);
436
+ const base = _folderUrl(project.url);
437
+ found = await _fetchManifest(base);
307
438
  sourceOf = async function (p, from, spec) {
308
439
  const u = new URL(p, base).href;
309
440
  let res;
@@ -322,10 +453,20 @@ export async function linkProject(project) {
322
453
  return { text: text, bytes: raw };
323
454
  };
324
455
  }
456
+ const manifest = found ? found.manifest : null;
457
+ const entry = _entryOf(given, manifest);
458
+ if (!isModulePath(entry)) {
459
+ _fail('linkProject: the entry "' + entry + '" is not a module path (' + MODULE_EXTENSIONS.join(' / ') + ').');
460
+ }
325
461
 
326
462
  const g = await _walk(entry, sourceOf);
327
463
  _refuseUnreached(project, g);
328
464
 
465
+ // The linked set's bytes: a file map's through linkedFiles (its owner); a served folder's are what was fetched.
466
+ const bytes = hasFiles ? linkedFiles(encoded) : linkedFiles(Object.assign(found ? { [MANIFEST_FILE]: found.bytes } : {}, g.bytes));
467
+ const heldFiles = hasFiles ? Object.assign({}, project.files)
468
+ : Object.assign(found ? { [MANIFEST_FILE]: found.text } : {}, g.files);
469
+
329
470
  const urls = {};
330
471
  const minted = [];
331
472
  try {
@@ -338,15 +479,40 @@ export async function linkProject(project) {
338
479
  minted.push(m);
339
480
  urls[p] = m.url;
340
481
  }
341
- const ns = await import(urls[entry]);
342
- const held = { files: g.files, entry: entry, mint: mint };
482
+ let ns;
483
+ try { ns = await import(urls[entry]); }
484
+ catch (err) { throw _namePaths(err, urls); }
485
+ const held = { files: heldFiles, entry: entry, mint: mint };
343
486
  if (project.bare !== undefined) { held.bare = project.bare; }
344
- return { buildExperiment: _entryFunction(ns, entry), files: g.bytes, urls: Object.assign({}, urls), project: held };
487
+ return { buildExperiment: _entryFunction(ns, entry), module: ns, manifest: manifest, files: bytes,
488
+ urls: Object.assign({}, urls), project: held };
345
489
  } finally {
346
490
  for (const m of minted) { if (typeof m.release === 'function') { m.release(); } }
347
491
  }
348
492
  }
349
493
 
494
+ /**
495
+ * An error raised while the project evaluates, with every minted URL in its message replaced by the project path
496
+ * it names (byte-identical files share one minted URL: all their paths), and every package file URL by its
497
+ * specifier — so a duplicate kind reads "kind "Wall" is already registered by @descix/frqtl-web/parts/Wall.js;
498
+ * parts/Wall2.js registers it again". The SAME error is rethrown (its stack kept): enrichment, never a catch.
499
+ */
500
+ function _namePaths(err, urls) {
501
+ if (!err || typeof err.message !== 'string') { return err; }
502
+ const byUrl = new Map();
503
+ for (const p of Object.keys(urls)) { byUrl.set(urls[p], (byUrl.get(urls[p]) || []).concat(p)); }
504
+ let msg = err.message;
505
+ for (const [url, paths] of [...byUrl.entries()].sort(function (a, b) { return b[0].length - a[0].length; })) {
506
+ msg = msg.split(url).join(paths.join(' (= ') + (paths.length > 1 ? ', byte-identical)' : ''));
507
+ }
508
+ const root = packageRoot();
509
+ msg = msg.replace(new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '([^\\s;,)\'"]+)', 'g'), function (m, lp) {
510
+ return specifierOfLibPath(lp) || m;
511
+ });
512
+ try { err.message = msg; } catch (_) { /* a frozen error keeps its own text */ }
513
+ return err;
514
+ }
515
+
350
516
  /** The entry path of a one-string sketch held as a project. */
351
517
  export const SKETCH_ENTRY = 'sketch.js';
352
518
 
@@ -364,10 +530,22 @@ export function sketchProject(text) {
364
530
  return { files: files, entry: SKETCH_ENTRY };
365
531
  }
366
532
 
367
- /** A `blob:` module URL for `text` (a page). */
533
+ /** One `blob:` URL per byte-identical module text in this page (blobMint's memo). */
534
+ const _blobUrls = new Map();
535
+
536
+ /**
537
+ * A `blob:` module URL for `text` (a page). MEMOIZED by text for the page's lifetime: a re-link of the same text
538
+ * (restart, a Record's parts reads) gets the same URL, so the ESM cache keeps ONE instance and a project's own part
539
+ * file registers its kind once — node's `data:` URLs are content-addressed the same way. `release()` is a no-op:
540
+ * the URL stays valid for the next link of those bytes.
541
+ */
368
542
  export function blobMint(text) {
369
- const url = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }));
370
- return { url: url, release: function () { URL.revokeObjectURL(url); } };
543
+ let url = _blobUrls.get(text);
544
+ if (!url) {
545
+ url = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }));
546
+ _blobUrls.set(text, url);
547
+ }
548
+ return { url: url, release: function () {} };
371
549
  }
372
550
 
373
551
  /**
@@ -0,0 +1,151 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * manifest.js — THE owner of `frqtl.json`, the one file a host reads first in a project folder.
5
+ *
6
+ * MANIFEST_FILE 'frqtl.json' — its path at the project root
7
+ * MANIFEST_KEYS the typed key table, in the order a manifest is written
8
+ * normalizeManifest(partial) → the full bag (frozen, keys in MANIFEST_KEYS order; an absent optional key
9
+ * stays absent). THROWS on an unknown key, a missing required key, a value
10
+ * of the wrong type or outside its row's rule — each naming the key.
11
+ * parseManifest(text) strict JSON → normalizeManifest. Any valid JSON formatting reads.
12
+ * serializeManifest(bag) JSON.stringify(the normalized bag, null, 2) + '\n' — THE writer of every
13
+ * system-made manifest (one-file Import, Save As, projectOf).
14
+ * manifestFor({ name, entry }) the one constructor of a fresh bag: kind 'scene', no version.
15
+ *
16
+ * A manifest holds what the folder IS — its name, its kind, its entry module, the author's own version
17
+ * string — and nothing about how a host boots it: display, seed, camera and render rows are the HOST's
18
+ * renderState, kept beside the folder, never in this file. `version` is never interpreted.
19
+ *
20
+ * Consumers ferry the normalized bag; none re-lists these keys (gate: lib/shim/project/test/manifest.test.mjs,
21
+ * driven off MANIFEST_KEYS).
22
+ */
23
+
24
+ import { checkProjectPath } from './identity.js';
25
+
26
+ /** The manifest's path at the project root. */
27
+ export const MANIFEST_FILE = 'frqtl.json';
28
+
29
+ function _fail(msg) {
30
+ throw new Error('[' + MANIFEST_FILE + '] ' + msg + ' (FAIL-LOUD.)');
31
+ }
32
+
33
+ function _keyList() {
34
+ return MANIFEST_KEYS.map(function (r) { return r.key; }).join(', ');
35
+ }
36
+
37
+ /**
38
+ * THE typed key table. Each row: `key`; `type` (the JSON type of its value); `required`; `rule` (a
39
+ * sentence naming what a legal value is); `check(value)` — throws naming the key when the value breaks
40
+ * the rule (the type is checked first, from `type`).
41
+ * @type {ReadonlyArray<{key: string, type: 'string', required: boolean, rule: string, check: function(*): void}>}
42
+ */
43
+ export const MANIFEST_KEYS = Object.freeze([
44
+ Object.freeze({
45
+ key: 'name', type: 'string', required: true,
46
+ rule: 'a non-empty string — the project\'s name',
47
+ check: function (v) {
48
+ if (v.length === 0) { _fail('"name" is empty — a project\'s name is a non-empty string.'); }
49
+ }
50
+ }),
51
+ Object.freeze({
52
+ key: 'kind', type: 'string', required: true,
53
+ rule: '"scene" (a notebook project, "notebook", comes with P5)',
54
+ check: function (v) {
55
+ if (v === 'notebook') {
56
+ _fail('"kind": "notebook" — a notebook project comes with P5 (notebooks on projects); a project ' +
57
+ 'today is "kind": "scene".');
58
+ }
59
+ if (v !== 'scene') {
60
+ _fail('"kind": ' + JSON.stringify(v) + ' is not a project kind — "scene", or "notebook" (P5).');
61
+ }
62
+ }
63
+ }),
64
+ Object.freeze({
65
+ key: 'entry', type: 'string', required: true,
66
+ rule: 'a root-relative project path ending ".js" — the module whose default export is buildExperiment(exp)',
67
+ check: function (v) {
68
+ try { checkProjectPath(v); }
69
+ catch (e) { _fail('"entry": ' + String((e && e.message) || e)); }
70
+ if (!v.endsWith('.js')) {
71
+ _fail('"entry": ' + JSON.stringify(v) + ' does not end ".js" — the entry is the project module whose ' +
72
+ 'default export is buildExperiment(exp).');
73
+ }
74
+ }
75
+ }),
76
+ Object.freeze({
77
+ key: 'version', type: 'string', required: false,
78
+ rule: 'the author\'s own string, never interpreted (optional)',
79
+ check: function () {}
80
+ })
81
+ ]);
82
+
83
+ /**
84
+ * @param {object} partial — the manifest's keys (an absent optional key, or one whose value is undefined, is absent)
85
+ * @returns {Readonly<{name: string, kind: 'scene', entry: string, version?: string}>}
86
+ */
87
+ export function normalizeManifest(partial) {
88
+ if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
89
+ _fail('a manifest is an object { ' + _keyList() + ' } (got ' +
90
+ (Array.isArray(partial) ? 'an array' : partial === null ? 'null' : typeof partial) + ').');
91
+ }
92
+ const known = MANIFEST_KEYS.map(function (r) { return r.key; });
93
+ const unknown = Object.keys(partial).filter(function (k) { return known.indexOf(k) === -1; });
94
+ if (unknown.length) {
95
+ _fail('unknown key' + (unknown.length > 1 ? 's ' : ' ') + unknown.map(function (k) { return JSON.stringify(k); }).join(', ') +
96
+ ' — a manifest holds exactly MANIFEST_KEYS (lib/shim/project/manifest.js): ' + _keyList() +
97
+ '. How a host boots the folder (display, seed, camera, render rows) is its renderState, kept beside the folder.');
98
+ }
99
+ const bag = {};
100
+ for (const row of MANIFEST_KEYS) {
101
+ const v = partial[row.key];
102
+ if (v === undefined) {
103
+ if (row.required) { _fail('"' + row.key + '" is REQUIRED — ' + row.rule + '.'); }
104
+ continue;
105
+ }
106
+ if (typeof v !== row.type) {
107
+ _fail('"' + row.key + '" is ' + (v === null ? 'null' : typeof v) + ' — ' + row.rule + '.');
108
+ }
109
+ row.check(v);
110
+ bag[row.key] = v;
111
+ }
112
+ return Object.freeze(bag);
113
+ }
114
+
115
+ /**
116
+ * @param {string} text — the bytes of frqtl.json, decoded
117
+ * @returns {Readonly<object>} the normalized bag
118
+ */
119
+ export function parseManifest(text) {
120
+ if (typeof text !== 'string') {
121
+ _fail('parseManifest(text): the text of ' + MANIFEST_FILE + ' (got ' + typeof text + ').');
122
+ }
123
+ let raw;
124
+ try { raw = JSON.parse(text); }
125
+ catch (e) { _fail('is not valid JSON: ' + String((e && e.message) || e) + '.'); }
126
+ return normalizeManifest(raw);
127
+ }
128
+
129
+ /**
130
+ * @param {object} bag — normalized here first, so the writer never writes a manifest its reader refuses
131
+ * @returns {string}
132
+ */
133
+ export function serializeManifest(bag) {
134
+ return JSON.stringify(normalizeManifest(bag), null, 2) + '\n';
135
+ }
136
+
137
+ /**
138
+ * @param {{name: string, entry: string}} spec
139
+ * @returns {Readonly<object>} the normalized bag { name, kind: 'scene', entry }
140
+ */
141
+ export function manifestFor(spec) {
142
+ if (!spec || typeof spec !== 'object') {
143
+ _fail('manifestFor({ name, entry }) (got ' + (spec === null ? 'null' : typeof spec) + ').');
144
+ }
145
+ const stray = Object.keys(spec).filter(function (k) { return k !== 'name' && k !== 'entry'; });
146
+ if (stray.length) {
147
+ _fail('manifestFor({ name, entry }) takes the name and the entry only (got ' + stray.join(', ') +
148
+ ') — a fresh project is kind "scene" with no version; any other bag goes through normalizeManifest.');
149
+ }
150
+ return normalizeManifest({ name: spec.name, kind: 'scene', entry: spec.entry });
151
+ }