@descix/frqtl-node 0.6.0-p0.2 → 0.6.0-p4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/README.md +8 -4
- package/dist/dist-manifest.json +70 -62
- package/dist/eyes/index.js +5 -2
- package/dist/frqtl/assets/vendor/acorn.LICENSE +21 -0
- package/dist/frqtl/assets/vendor/acorn.PINNED.json +9 -0
- package/dist/frqtl/assets/vendor/acorn.mjs +6266 -0
- package/dist/frqtl/compat/renderer/lo-drawn-center.js +41 -0
- package/dist/frqtl/experiments/_shared/boot-failure.js +60 -0
- package/dist/frqtl/objects/FrqtlApp.js +136 -30
- package/dist/frqtl/objects/ObjectFactory.js +4 -5
- package/dist/frqtl/objects/RecipeContext.js +617 -9
- package/dist/frqtl/objects/instruments/hosted-eyes.js +268 -57
- package/dist/frqtl/objects/objects/QuantumEmitter.js +1 -1
- package/dist/frqtl/objects/record/Record.js +326 -0
- package/dist/frqtl/objects/record/open-record.js +22 -0
- package/dist/frqtl/objects/schema/index.js +72 -0
- package/dist/frqtl/parts/DetectorWall.js +1 -1
- package/dist/frqtl/parts/FrqtlControlsPanel.js +4 -2
- package/dist/frqtl/parts/FrqtlVision.js +21 -6
- package/dist/frqtl/parts/Wall.js +2 -0
- package/dist/frqtl/scenes/absorbing-wall-3d.js +18 -14
- package/dist/frqtl/scenes/absorbing-wall.js +18 -14
- package/dist/frqtl/scenes/atomic-model.js +3 -0
- package/dist/frqtl/scenes/big-bang.js +3 -0
- package/dist/frqtl/scenes/blackbody.js +14 -11
- package/dist/frqtl/scenes/circuit-sat.js +7 -4
- package/dist/frqtl/scenes/collision-matrix-3d.js +3 -0
- package/dist/frqtl/scenes/counting-wall-3d.js +19 -15
- package/dist/frqtl/scenes/counting-wall.js +19 -15
- package/dist/frqtl/scenes/double-slit.js +3 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +64 -58
- package/dist/frqtl/scenes/dse-one-slit.js +59 -55
- package/dist/frqtl/scenes/dse-photoelectric.js +28 -24
- package/dist/frqtl/scenes/dse-tunnel-rebuild.js +124 -99
- package/dist/frqtl/scenes/dse-two-emitters.js +64 -58
- package/dist/frqtl/scenes/dse-two-slits.js +69 -65
- package/dist/frqtl/scenes/empty.js +3 -0
- package/dist/frqtl/scenes/laser-buildup.js +23 -16
- package/dist/frqtl/scenes/mirror-3d.js +31 -27
- package/dist/frqtl/scenes/mirror-route.js +4 -1
- package/dist/frqtl/scenes/mirror.js +24 -20
- package/dist/frqtl/scenes/omni-3d.js +7 -4
- package/dist/frqtl/scenes/omni-source.js +7 -4
- package/dist/frqtl/scenes/self-interference.js +13 -10
- package/dist/frqtl/scenes/smoking-gun.js +15 -11
- package/dist/frqtl/scenes/straight-walk.js +7 -4
- package/dist/frqtl/scenes/sun.js +11 -8
- package/dist/frqtl/scenes/two-source.js +22 -17
- package/dist/frqtl/scenes/wave.js +10 -7
- package/dist/shim/bundle/index.js +112 -96
- package/dist/shim/bundle/indexHtml.js +66 -39
- package/dist/shim/engine/router.js +2 -2
- package/dist/shim/index.js +1 -1
- package/dist/shim/notebook/format.js +1 -1
- package/dist/shim/notebook/store.js +2 -3
- package/dist/shim/page-params/index.js +2 -2
- package/dist/shim/project/index.js +20 -5
- package/dist/shim/project/link.js +182 -37
- package/dist/shim/project/manifest.js +151 -0
- package/dist/shim/project/parts-module.js +67 -0
- package/dist/shim/project/parts-writer.js +457 -0
- package/dist/shim/project/record-format.js +345 -0
- package/dist/shim/project/specifiers.js +41 -13
- package/dist/shim/registry/index.js +1 -1
- package/dist/shim/registry/scene-source.js +44 -30
- package/dist/shim/registry/walkthrough.js +13 -12
- package/dist/shim/render-state/apply.js +11 -14
- package/dist/shim/storage/codec-chains.js +88 -0
- package/dist/shim/storage/folders.js +205 -0
- package/dist/shim/storage/index.js +64 -8
- package/dist/shim/storage/make-namespace-store.js +5 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/dist/shim/bundle/frqnb.js +0 -112
- package/dist/shim/bundle/manifest.js +0 -95
- package/dist/shim/storage/modules.js +0 -249
- package/dist/shim/storage/projects.js +0 -150
|
@@ -4,16 +4,31 @@
|
|
|
4
4
|
* project/index.js — the `./project` entry of BOTH packages: the specifier table, the link site and the
|
|
5
5
|
* project identity, re-exported from their owners (this file owns nothing).
|
|
6
6
|
*
|
|
7
|
-
* FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor
|
|
8
|
-
*
|
|
7
|
+
* FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor,
|
|
8
|
+
* pageImportMapFor, FRQTL_WEB_HOST_SPECIFIERS specifiers.js (the host rows: a host page reads the
|
|
9
|
+
* dist root from its own map through them)
|
|
10
|
+
* linkProject, sketchProject, SKETCH_ENTRY link.js
|
|
9
11
|
* projectIdentity identity.js
|
|
12
|
+
* MANIFEST_FILE, MANIFEST_KEYS, normalizeManifest,
|
|
13
|
+
* parseManifest, serializeManifest, manifestFor manifest.js (THE owner of frqtl.json)
|
|
14
|
+
* RECORD_FILES, RECORD_KEYS, CHANGE_KEYS, OPS, BY,
|
|
15
|
+
* CLIP_SECONDS, CLIP_NAME, clipName, jsonText,
|
|
16
|
+
* recordFolderFaults record-format.js (THE owner of the Record folder's shape)
|
|
10
17
|
*
|
|
11
18
|
* PROJECT_EXPORTS is this entry's export list; a conformance test holds the module's exports equal to
|
|
12
19
|
* it, and the package gate holds it equal across @descix/frqtl-web and @descix/frqtl-node.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
|
-
export const PROJECT_EXPORTS = Object.freeze(['FRQTL_WEB_SPECIFIERS', 'libPathOf', 'importMapFor', '
|
|
22
|
+
export const PROJECT_EXPORTS = Object.freeze(['FRQTL_WEB_SPECIFIERS', 'libPathOf', 'importMapFor', 'pageImportMapFor',
|
|
23
|
+
'FRQTL_WEB_HOST_SPECIFIERS',
|
|
24
|
+
'linkProject', 'sketchProject', 'SKETCH_ENTRY', 'projectIdentity',
|
|
25
|
+
'MANIFEST_FILE', 'MANIFEST_KEYS', 'normalizeManifest', 'parseManifest', 'serializeManifest', 'manifestFor',
|
|
26
|
+
'RECORD_FILES', 'RECORD_KEYS', 'CHANGE_KEYS', 'OPS', 'BY', 'CLIP_SECONDS', 'CLIP_NAME', 'clipName', 'jsonText',
|
|
27
|
+
'recordFolderFaults']);
|
|
16
28
|
|
|
17
|
-
export { FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor } from './specifiers.js';
|
|
18
|
-
export { linkProject } from './link.js';
|
|
29
|
+
export { FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor, pageImportMapFor, FRQTL_WEB_HOST_SPECIFIERS } from './specifiers.js';
|
|
30
|
+
export { linkProject, sketchProject, SKETCH_ENTRY } from './link.js';
|
|
19
31
|
export { projectIdentity } from './identity.js';
|
|
32
|
+
export { MANIFEST_FILE, MANIFEST_KEYS, normalizeManifest, parseManifest, serializeManifest, manifestFor } from './manifest.js';
|
|
33
|
+
export { RECORD_FILES, RECORD_KEYS, CHANGE_KEYS, OPS, BY, CLIP_SECONDS, CLIP_NAME, clipName, jsonText,
|
|
34
|
+
recordFolderFaults } from './record-format.js';
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* link.js — THE link site: a project (
|
|
5
|
-
*
|
|
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,
|
|
8
|
-
* project = { files: { 'sketch.js': text, 'parts/X.js': text, … }, entry
|
|
9
|
-
* | { url: '<served folder>/', entry
|
|
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
|
|
12
|
-
*
|
|
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,20 +22,37 @@
|
|
|
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
|
|
22
|
-
*
|
|
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.
|
|
23
39
|
*
|
|
24
|
-
*
|
|
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
|
|
25
45
|
* `@descix/frqtl-web/...`; nothing else. Relative specifiers are rewritten to the minted URLs of the files
|
|
26
46
|
* they name, in topological order (a file is minted after everything it imports). REFUSED before anything
|
|
27
47
|
* is minted, each naming the file, the specifier and the reason: a relative specifier that climbs out of
|
|
28
|
-
* the root (package internals have no public specifier), one that names
|
|
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
|
|
29
50
|
* sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, any other specifier;
|
|
30
|
-
* an import cycle among project files; a file
|
|
31
|
-
*
|
|
32
|
-
*
|
|
51
|
+
* an import cycle among project files; a module file the entry never reaches; an invalid frqtl.json; a
|
|
52
|
+
* served file that is not UTF-8; a missing or non-function default export of the entry, a missing entry,
|
|
53
|
+
* an empty project. Every argument error REJECTS.
|
|
33
54
|
*
|
|
34
|
-
* blobMint(text) → { url, release() } a `blob:` module URL (a page)
|
|
55
|
+
* blobMint(text) → { url, release() } a `blob:` module URL (a page), one per byte-identical text
|
|
35
56
|
* dataMint(text) → { url, release() } a `data:` module URL (node)
|
|
36
57
|
*
|
|
37
58
|
* A one-string sketch is the one-file project: `{ files: { 'sketch.js': text }, entry: 'sketch.js' }` —
|
|
@@ -40,6 +61,7 @@
|
|
|
40
61
|
|
|
41
62
|
import { libPathOf, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
|
|
42
63
|
import { checkProjectPath, projectBytes } from './identity.js';
|
|
64
|
+
import { MANIFEST_FILE, parseManifest } from './manifest.js';
|
|
43
65
|
import { packageRoot } from '../../frqtl/compat/package-root.js';
|
|
44
66
|
|
|
45
67
|
const SKETCH_FORM = 'export default function buildExperiment(exp) { … }';
|
|
@@ -165,6 +187,11 @@ async function _walk(entry, sourceOf) {
|
|
|
165
187
|
_fail('"' + p + '" imports "' + imp.spec + '" — outside the project; package internals have no ' +
|
|
166
188
|
'public specifier (a project file imports the package by a @descix/frqtl-web/... row).');
|
|
167
189
|
}
|
|
190
|
+
if (!isModulePath(target)) {
|
|
191
|
+
_fail('"' + p + '" imports "' + imp.spec + '" (' + target + '), which is not a module path — a project ' +
|
|
192
|
+
'imports only its module files (' + MODULE_EXTENSIONS.join(' / ') + '); any other file of the folder ' +
|
|
193
|
+
'(a data file, an extensionless name, ' + MANIFEST_FILE + ') travels with it unlinked.');
|
|
194
|
+
}
|
|
168
195
|
list.push(Object.assign({ kind: 'file', target: target }, imp));
|
|
169
196
|
} else if (_isPackage(imp.spec)) {
|
|
170
197
|
if (!_isPublished(imp.spec)) {
|
|
@@ -214,17 +241,46 @@ function _rewrite(text, list, urlOf) {
|
|
|
214
241
|
return out;
|
|
215
242
|
}
|
|
216
243
|
|
|
244
|
+
/** The extensions of a project MODULE file; every other path of a folder is a non-module file. */
|
|
245
|
+
const MODULE_EXTENSIONS = Object.freeze(['.js', '.mjs']);
|
|
246
|
+
|
|
247
|
+
/** A project path names a module file (".js" / ".mjs"). */
|
|
248
|
+
function isModulePath(p) {
|
|
249
|
+
return MODULE_EXTENSIONS.some(function (x) { return p.endsWith(x); });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* THE LINKED SET of a folder: the sub-map of `files` holding frqtl.json and every module file (".js" /
|
|
254
|
+
* ".mjs"), values as given (text or bytes). A pure function of the paths, so every host — a page door,
|
|
255
|
+
* hosted eyes in node — hashes one set; the link refuses a folder whose module files the entry does not
|
|
256
|
+
* all reach, so for a folder that links this IS the set linked. Any other file passes through unhashed.
|
|
257
|
+
* @param {Object<string, *>} files
|
|
258
|
+
* @returns {Object<string, *>}
|
|
259
|
+
*/
|
|
260
|
+
export function linkedFiles(files) {
|
|
261
|
+
if (!files || typeof files !== 'object') {
|
|
262
|
+
_fail('linkedFiles(files): files is the map { path: text | bytes } (got ' + (files === null ? 'null' : typeof files) + ').');
|
|
263
|
+
}
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const p of Object.keys(files)) {
|
|
266
|
+
if (p === MANIFEST_FILE || isModulePath(p)) { out[p] = files[p]; }
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
217
271
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* reach, naming it.
|
|
272
|
+
* Every module file of a file map is reached from the entry (a project's modules are exactly the graph its
|
|
273
|
+
* entry imports, so linkedFiles is the set linked). REFUSES an unreached one, naming it.
|
|
221
274
|
*/
|
|
222
275
|
function _refuseUnreached(project, g) {
|
|
223
276
|
if (project.files === undefined) { return; }
|
|
224
|
-
const unused = Object.keys(project.files).filter(function (p) {
|
|
277
|
+
const unused = Object.keys(linkedFiles(project.files)).filter(function (p) {
|
|
278
|
+
return p !== MANIFEST_FILE && !Object.prototype.hasOwnProperty.call(g.files, p);
|
|
279
|
+
});
|
|
225
280
|
if (unused.length) {
|
|
226
|
-
_fail('unused project file "' + unused[0] + '" — a project holds the files its entry imports' +
|
|
227
|
-
(unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '
|
|
281
|
+
_fail('unused project file "' + unused[0] + '" — a project holds the module files its entry imports' +
|
|
282
|
+
(unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '; a non-module file ' +
|
|
283
|
+
'(README.md) travels with the folder unlinked.');
|
|
228
284
|
}
|
|
229
285
|
}
|
|
230
286
|
|
|
@@ -240,25 +296,84 @@ function _entryFunction(ns, entry) {
|
|
|
240
296
|
return ns.default;
|
|
241
297
|
}
|
|
242
298
|
|
|
299
|
+
/** The served folder's frqtl.json: { manifest, text, bytes }, or null on a 404. One fetch. */
|
|
300
|
+
async function _fetchManifest(base) {
|
|
301
|
+
const u = new URL(MANIFEST_FILE, base).href;
|
|
302
|
+
let res;
|
|
303
|
+
try { res = await fetch(u); }
|
|
304
|
+
catch (e) { _fail('fetching "' + MANIFEST_FILE + '" (' + u + ') failed: ' + String((e && e.message) || e) + '.'); }
|
|
305
|
+
if (res.status === 404) { return null; }
|
|
306
|
+
if (!res.ok) { _fail('"' + MANIFEST_FILE + '" is not served at ' + u + ' (HTTP ' + res.status + ').'); }
|
|
307
|
+
const raw = new Uint8Array(await res.arrayBuffer());
|
|
308
|
+
let text;
|
|
309
|
+
try { text = _UTF8.decode(raw); }
|
|
310
|
+
catch (e) { _fail('"' + MANIFEST_FILE + '" is not valid UTF-8 (' + u + ').'); }
|
|
311
|
+
let manifest;
|
|
312
|
+
try { manifest = parseManifest(text); }
|
|
313
|
+
catch (e) {
|
|
314
|
+
const type = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : null;
|
|
315
|
+
_fail('the served ' + MANIFEST_FILE + ' (' + u + ', HTTP ' + res.status + (type ? ', ' + type : '') + ') is refused: ' +
|
|
316
|
+
String((e && e.message) || e) + ' — a host that answers a missing file with a page serves no 404, so a folder ' +
|
|
317
|
+
'there carries its own ' + MANIFEST_FILE + '.');
|
|
318
|
+
}
|
|
319
|
+
return { manifest: manifest, text: text, bytes: raw };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The entry: frqtl.json's when the project carries one (a given `entry` must equal it), else the given one.
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function _entryOf(given, manifest) {
|
|
327
|
+
if (manifest) {
|
|
328
|
+
if (given !== undefined && given !== manifest.entry) {
|
|
329
|
+
_fail('entryOf: `entry` "' + given + '" disagrees with ' + MANIFEST_FILE + '\'s "entry" "' + manifest.entry +
|
|
330
|
+
'" — the manifest names the entry; drop `entry`, or make the two equal.');
|
|
331
|
+
}
|
|
332
|
+
return manifest.entry;
|
|
333
|
+
}
|
|
334
|
+
if (given === undefined) {
|
|
335
|
+
_fail('entryOf: no entry — the project carries no ' + MANIFEST_FILE + ' (manifest.js; manifestFor({ name, entry }) ' +
|
|
336
|
+
'writes one) and no `entry` was given (a one-string sketch: { files: { "sketch.js": text }, entry: "sketch.js" }).');
|
|
337
|
+
}
|
|
338
|
+
return given;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* entryOf(files, given) → the project's entry path — THE entry rule (every host asks it, never a copy): the entry
|
|
343
|
+
* frqtl.json names (read through its owner, parseManifest) when `files` carries one, and a `given` entry must equal
|
|
344
|
+
* it; else `given`. THROWS naming the rule: a disagreeing entry, no frqtl.json and no entry, an invalid frqtl.json.
|
|
345
|
+
* Not on PROJECT_EXPORTS (like linkedFiles).
|
|
346
|
+
* @param {Object<string, string>} files
|
|
347
|
+
* @param {string} [given]
|
|
348
|
+
* @returns {string}
|
|
349
|
+
*/
|
|
350
|
+
export function entryOf(files, given) {
|
|
351
|
+
const has = files && Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE);
|
|
352
|
+
return _entryOf(given, has ? parseManifest(files[MANIFEST_FILE]) : null);
|
|
353
|
+
}
|
|
354
|
+
|
|
243
355
|
/**
|
|
244
356
|
* @param {object} project — see the file header
|
|
245
|
-
* @returns {Promise<{buildExperiment: function, files: Object<string,
|
|
357
|
+
* @returns {Promise<{buildExperiment: function, module: object, manifest: ?object, files: Object<string, Uint8Array>,
|
|
358
|
+
* urls: Object<string, string>, project: {files: Object<string, string>, entry: string, mint: function, bare?: function}}>}
|
|
246
359
|
*/
|
|
247
360
|
export async function linkProject(project) {
|
|
248
361
|
if (!project || typeof project !== 'object') {
|
|
249
|
-
_fail('linkProject(project): project is { files,
|
|
362
|
+
_fail('linkProject(project): project is { files, mint } or { url, mint } (+ `entry` when it carries no ' +
|
|
363
|
+
MANIFEST_FILE + ') (got ' + typeof project + ').');
|
|
250
364
|
}
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
'
|
|
365
|
+
const given = project.entry;
|
|
366
|
+
if (given !== undefined) {
|
|
367
|
+
if (typeof given !== 'string' || !given) {
|
|
368
|
+
_fail('linkProject: `entry` is the root-relative path of the module whose default export is the scene (got ' +
|
|
369
|
+
JSON.stringify(given) + ').');
|
|
370
|
+
}
|
|
371
|
+
checkProjectPath(given);
|
|
255
372
|
}
|
|
256
|
-
checkProjectPath(entry);
|
|
257
373
|
const hasFiles = project.files !== undefined;
|
|
258
374
|
const hasUrl = project.url !== undefined;
|
|
259
375
|
if (hasFiles === hasUrl) {
|
|
260
|
-
_fail('linkProject: a project is EITHER { files
|
|
261
|
-
(hasFiles ? 'both' : 'neither') + ').');
|
|
376
|
+
_fail('linkProject: a project is EITHER { files } OR { url } (got ' + (hasFiles ? 'both' : 'neither') + ').');
|
|
262
377
|
}
|
|
263
378
|
const mint = project.mint;
|
|
264
379
|
if (typeof mint !== 'function') {
|
|
@@ -271,7 +386,8 @@ export async function linkProject(project) {
|
|
|
271
386
|
}
|
|
272
387
|
|
|
273
388
|
let sourceOf;
|
|
274
|
-
let
|
|
389
|
+
let found = null; // frqtl.json: { manifest, text, bytes }
|
|
390
|
+
let encoded = null;
|
|
275
391
|
if (hasFiles) {
|
|
276
392
|
const files = project.files;
|
|
277
393
|
if (!files || typeof files !== 'object' || Object.keys(files).length === 0) {
|
|
@@ -281,7 +397,10 @@ export async function linkProject(project) {
|
|
|
281
397
|
checkProjectPath(p);
|
|
282
398
|
if (typeof files[p] !== 'string') { _fail('linkProject: file "' + p + '" is not text.'); }
|
|
283
399
|
}
|
|
284
|
-
|
|
400
|
+
encoded = projectBytes(files);
|
|
401
|
+
if (Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE)) {
|
|
402
|
+
found = { manifest: parseManifest(files[MANIFEST_FILE]), text: files[MANIFEST_FILE], bytes: encoded[MANIFEST_FILE] };
|
|
403
|
+
}
|
|
285
404
|
sourceOf = async function (p, from, spec) {
|
|
286
405
|
if (!Object.prototype.hasOwnProperty.call(files, p)) {
|
|
287
406
|
_fail(from === undefined
|
|
@@ -298,7 +417,8 @@ export async function linkProject(project) {
|
|
|
298
417
|
if (typeof project.url !== 'string' || !project.url.endsWith('/')) {
|
|
299
418
|
_fail('linkProject({ url }): url is the served project folder, ending in "/" (got ' + JSON.stringify(project.url) + ').');
|
|
300
419
|
}
|
|
301
|
-
base = _folderUrl(project.url);
|
|
420
|
+
const base = _folderUrl(project.url);
|
|
421
|
+
found = await _fetchManifest(base);
|
|
302
422
|
sourceOf = async function (p, from, spec) {
|
|
303
423
|
const u = new URL(p, base).href;
|
|
304
424
|
let res;
|
|
@@ -317,10 +437,20 @@ export async function linkProject(project) {
|
|
|
317
437
|
return { text: text, bytes: raw };
|
|
318
438
|
};
|
|
319
439
|
}
|
|
440
|
+
const manifest = found ? found.manifest : null;
|
|
441
|
+
const entry = _entryOf(given, manifest);
|
|
442
|
+
if (!isModulePath(entry)) {
|
|
443
|
+
_fail('linkProject: the entry "' + entry + '" is not a module path (' + MODULE_EXTENSIONS.join(' / ') + ').');
|
|
444
|
+
}
|
|
320
445
|
|
|
321
446
|
const g = await _walk(entry, sourceOf);
|
|
322
447
|
_refuseUnreached(project, g);
|
|
323
448
|
|
|
449
|
+
// The linked set's bytes: a file map's through linkedFiles (its owner); a served folder's are what was fetched.
|
|
450
|
+
const bytes = hasFiles ? linkedFiles(encoded) : linkedFiles(Object.assign(found ? { [MANIFEST_FILE]: found.bytes } : {}, g.bytes));
|
|
451
|
+
const heldFiles = hasFiles ? Object.assign({}, project.files)
|
|
452
|
+
: Object.assign(found ? { [MANIFEST_FILE]: found.text } : {}, g.files);
|
|
453
|
+
|
|
324
454
|
const urls = {};
|
|
325
455
|
const minted = [];
|
|
326
456
|
try {
|
|
@@ -334,7 +464,10 @@ export async function linkProject(project) {
|
|
|
334
464
|
urls[p] = m.url;
|
|
335
465
|
}
|
|
336
466
|
const ns = await import(urls[entry]);
|
|
337
|
-
|
|
467
|
+
const held = { files: heldFiles, entry: entry, mint: mint };
|
|
468
|
+
if (project.bare !== undefined) { held.bare = project.bare; }
|
|
469
|
+
return { buildExperiment: _entryFunction(ns, entry), module: ns, manifest: manifest, files: bytes,
|
|
470
|
+
urls: Object.assign({}, urls), project: held };
|
|
338
471
|
} finally {
|
|
339
472
|
for (const m of minted) { if (typeof m.release === 'function') { m.release(); } }
|
|
340
473
|
}
|
|
@@ -357,10 +490,22 @@ export function sketchProject(text) {
|
|
|
357
490
|
return { files: files, entry: SKETCH_ENTRY };
|
|
358
491
|
}
|
|
359
492
|
|
|
360
|
-
/**
|
|
493
|
+
/** One `blob:` URL per byte-identical module text in this page (blobMint's memo). */
|
|
494
|
+
const _blobUrls = new Map();
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A `blob:` module URL for `text` (a page). MEMOIZED by text for the page's lifetime: a re-link of the same text
|
|
498
|
+
* (restart, a Record's parts reads) gets the same URL, so the ESM cache keeps ONE instance and a project's own part
|
|
499
|
+
* file registers its kind once — node's `data:` URLs are content-addressed the same way. `release()` is a no-op:
|
|
500
|
+
* the URL stays valid for the next link of those bytes.
|
|
501
|
+
*/
|
|
361
502
|
export function blobMint(text) {
|
|
362
|
-
|
|
363
|
-
|
|
503
|
+
let url = _blobUrls.get(text);
|
|
504
|
+
if (!url) {
|
|
505
|
+
url = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }));
|
|
506
|
+
_blobUrls.set(text, url);
|
|
507
|
+
}
|
|
508
|
+
return { url: url, release: function () {} };
|
|
364
509
|
}
|
|
365
510
|
|
|
366
511
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* parts-module.js — THE mapping from a kind's defining module to the specifier a sketch imports it by.
|
|
5
|
+
*
|
|
6
|
+
* moduleOfKind(from, urls) → '@descix/frqtl-web/parts' | '<project path>' | null
|
|
7
|
+
* from the kind row's `from` (objects/kinds.js registerKind: the defining module's own URL)
|
|
8
|
+
* urls the minted URL of every project file, by path ({ 'parts/MyEmitter.js': 'blob:…' }) — what
|
|
9
|
+
* the last linkProject returned (link.js), or null when no project is linked
|
|
10
|
+
* A part file of THIS copy of the package (packageRoot() + 'frqtl/parts/<File>.js') is a package part:
|
|
11
|
+
* the barrel's specifier. A project file's minted URL is that file's project path. Anything else (an
|
|
12
|
+
* internal kind under frqtl/objects/, a module outside the package) has no importable specifier: null.
|
|
13
|
+
*
|
|
14
|
+
* importSpecifierOf(module, entry) → the specifier `entry` writes in an import line for `module`
|
|
15
|
+
* a package specifier as it is; a project path relative to the entry's own folder ('./parts/X.js').
|
|
16
|
+
*
|
|
17
|
+
* Consumers: the declared census (FrqtlApp: each runner.declared row's `module`) and the parts writer's
|
|
18
|
+
* one added import line (parts-writer.js, through RecipeContext.toSketch). Nobody else maps a `from`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { packageRoot } from '../../frqtl/compat/package-root.js';
|
|
22
|
+
import { FRQTL_WEB_SPECIFIERS } from './specifiers.js';
|
|
23
|
+
|
|
24
|
+
const _BARREL = FRQTL_WEB_SPECIFIERS.find(function (r) { return r.libPath === 'frqtl/parts/index.js'; });
|
|
25
|
+
if (!_BARREL) {
|
|
26
|
+
throw new Error('[parts-module] FRQTL_WEB_SPECIFIERS has no row for the parts barrel (frqtl/parts/index.js). (FAIL-LOUD.)');
|
|
27
|
+
}
|
|
28
|
+
/** The specifier a package part is imported by: the parts barrel's row. */
|
|
29
|
+
export const PARTS_SPECIFIER = _BARREL.specifier;
|
|
30
|
+
|
|
31
|
+
const _PART_FILE = /^frqtl\/parts\/[A-Za-z_$][\w$]*\.js$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} from — a kind row's `from` URL
|
|
35
|
+
* @param {Object<string,string>|null} urls — project path → minted URL, from the last link
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function moduleOfKind(from, urls) {
|
|
39
|
+
if (typeof from !== 'string' || !from) {
|
|
40
|
+
throw new TypeError('[parts-module] moduleOfKind(from, urls): from is the kind row\'s module URL (got ' +
|
|
41
|
+
JSON.stringify(from) + '). (FAIL-LOUD.)');
|
|
42
|
+
}
|
|
43
|
+
const root = packageRoot();
|
|
44
|
+
if (from.startsWith(root) && _PART_FILE.test(from.slice(root.length))) { return PARTS_SPECIFIER; }
|
|
45
|
+
if (urls) {
|
|
46
|
+
for (const p of Object.keys(urls)) { if (urls[p] === from) { return p; } }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} module — moduleOfKind's answer (never null here)
|
|
53
|
+
* @param {string} entry — the importing file's project path (the sketch, 'sketch.js')
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function importSpecifierOf(module, entry) {
|
|
57
|
+
if (typeof module !== 'string' || !module) {
|
|
58
|
+
throw new TypeError('[parts-module] importSpecifierOf(module, entry): a module specifier or project path. (FAIL-LOUD.)');
|
|
59
|
+
}
|
|
60
|
+
if (module.startsWith('@')) { return module; }
|
|
61
|
+
const from = entry.split('/');
|
|
62
|
+
from.pop();
|
|
63
|
+
const to = module.split('/');
|
|
64
|
+
while (from.length && to.length > 1 && from[0] === to[0]) { from.shift(); to.shift(); }
|
|
65
|
+
const up = from.map(function () { return '..'; });
|
|
66
|
+
return (up.length ? up.join('/') + '/' : './') + to.join('/');
|
|
67
|
+
}
|