@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
@@ -19,102 +19,119 @@
19
19
  // captured descriptor DATA) keeps the population bounded so FPS stays healthy, Restart-safe.
20
20
  //
21
21
  // ONE MODE-AWARE SOURCE. The mode strip selects the RENDERER the host boots
22
- // (renderState.renderer), and this scene reads it ONCE, before its world: `three` ⇒
22
+ // (renderState.renderer), and this scene reads it ONCE, inside buildExperiment: `three` ⇒
23
23
  // exp.createScene (a 600x400x600 world), else exp.createCanvas (600x400). The ring geometry,
24
- // tilts, colours, laser and gate are the SAME in both modes (2D≡3D); `IS3D` guards exactly
25
- // these deltas:
24
+ // tilts, colours, laser and gate are the SAME in both modes (2D≡3D). `parts` is written in the
25
+ // 3D default mode, and flat2D (below) derives the 2D block from it — the scene's own statement of
26
+ // two of its deltas:
26
27
  // (a) every mirror is a 3D SLAB — a z-slab centred on the beam plane z=0, rendered as a plate;
27
28
  // (b) the laser is VOLUMETRIC (is2D:false — it braids in y AND z; the slab depth catches the
28
- // z-braid; its tilted xy faces reflect by the 2θ law, its near/far z-faces bounce vz);
29
+ // z-braid; its tilted xy faces reflect by the 2θ law, its near/far z-faces bounce vz).
30
+ // `IS3D` guards the rest, in code after exp.declare:
29
31
  // (c) two invisible destroy z-collimators flush with the slab z-band;
30
- // (d) the population counter carries a z-slab covering the WHOLE universe z-band;
32
+ // (d) the population counter carries a z-slab covering the WHOLE universe z-band (the emitter's
33
+ // gateCounter names it, so it stays a code declaration);
31
34
  // (e) MOVE/LOOK camera joysticks (the OMNI_3D pattern).
32
35
  // The 3D mode's render intent — god-rays at the sun's settings, computed from the light
33
36
  // particles only (godraySource 'leaf'), and the 'table-top' camera — rides the registry mode's
34
37
  // renderState (lib/shim/registry/walkthrough.js), whose god-ray VALUES are the sun's, owned by
35
38
  // lib/shim/render/sun-look.js.
36
39
 
37
- import { CameraControls, SphereEmitter } from '@descix/frqtl-web/parts';
40
+ import { CameraControls, SphereEmitter, Wall } from '@descix/frqtl-web/parts';
38
41
  import * as FrqtlDisplay from '@descix/frqtl-web/compat/renderer/display-slot.js';
39
42
 
40
- export const parts = {};
43
+ const W = 600, H = 400, WORLD_D = 600; // THE WORLD this scene states (its co-init call below)
44
+ const f = Math.min(W, H) / 600;
45
+ // R 225·f: adjacent decagon vertices sit 2R·sin18° ≈ 0.618R apart vs slab
46
+ // height ~110·f, so the mirrors separate with clear gaps. Fit: R + rotated-slab half-diagonal
47
+ // (≈55·f) stays inside min(W,H)/2 with margin.
48
+ const CX = Math.round(W / 2), CY = Math.round(H / 2), R = Math.round(225 * f), HOP = 3, ROT = 12;
49
+ // CAP 6400 / burst 8 (brightness retune): a VERY BRIGHT braid. The cap is
50
+ // LIVE-controllable off the population counter (select 'population' in Layers → Inspector
51
+ // 'Population Gate').
52
+ const SW = Math.max(10, Math.round(14 * f)), SH = Math.max(70, Math.round(110 * f)), CAP = 6400;
53
+ // SD — the 3D slab z-depth: a 2:1 tile (twice the slab height, z-centred on the beam plane).
54
+ // The scene z-collimator clamps ALL quanta to this band, so every mirror provably faces every
55
+ // alive quantum.
56
+ const SD = 2 * SH;
57
+ // Each mirror a DIFFERENT colour = its identity (a red→magenta rainbow); the recolor hook
58
+ // stamps that colour onto every particle it reflects (string_map['render.color']).
59
+ const PAL = [[255,60,60],[255,140,0],[255,220,0],[150,230,40],[40,210,90],[0,210,190],[0,170,255],[70,90,255],[150,70,235],[235,50,190]];
60
+ // Hit-order vertices of the {10/3} star (m1..m10 named IN the order the beam visits them).
61
+ const order = []; for (var k = 0; k < 10; k++) { order.push((k * HOP) % 10); }
62
+ const D2R = Math.PI / 180;
63
+ const vpos = function (v) { var a = (v * 36 + ROT) * D2R; return [CX + R * Math.cos(a), CY + R * Math.sin(a)]; };
64
+ const pos = order.map(vpos);
65
+ const deg = function (a) { return ((a * 180 / Math.PI) % 360 + 360) % 360; };
66
+ const angOf = function (p, q) { return deg(Math.atan2(q[1] - p[1], q[0] - p[0])); };
67
+ // Per-mirror IN-PLANE orientation from the in/out chords: ori = (in+out−180)/2, branch chosen
68
+ // so the engine reflect law out = 180 + 2·ori − in reproduces the outgoing chord exactly.
69
+ // Orientation is z-axis rotation, so the 3D slab depth adds NO new angle.
70
+ const ORI = [];
71
+ for (var j = 0; j < 10; j++) {
72
+ var pr = pos[(j + 9) % 10], cu = pos[j], nx = pos[(j + 1) % 10];
73
+ var inA = angOf(pr, cu), outA = angOf(cu, nx);
74
+ var o = (((inA + outA - 180) / 2) % 360 + 360) % 360;
75
+ var op = ((180 + 2 * o - inA) % 360 + 360) % 360;
76
+ if (Math.abs(((op - outA + 540) % 360) - 180) > 0.5) { o = (o + 90) % 360; }
77
+ ORI.push(o);
78
+ }
79
+ // The NARROW STRAIGHT-WALK laser (the exemplar): the SAME emitter as Rung 1
80
+ // (Straight Walk) — preset 'particle-walk', every walk-character field INHERITED from the same
81
+ // factory defaults (chargeMode 'positive', dim 0 = 'light' P=1, fundamentalWaveLength 64 → the
82
+ // λ16 braided transverse ripple) — with ONLY the deliberate loop deltas: (a) a 2px source line
83
+ // (emissionBoxSide 2 vs r1's default 10), (b) seated on the m10→m1 leg JUST CLEAR of m10 (70·f
84
+ // past its vertex > the rotated-slab half-diagonal ≈55·f) and aimed along the leg (the longest
85
+ // clear throw: leg 1.618R ≈ 364·f), (c) a burst of 8/tick, and (d) the DECLARED emission gate.
86
+ const pS = pos[9], p0 = pos[0];
87
+ const dx = p0[0] - pS[0], dy = p0[1] - pS[1], L = Math.hypot(dx, dy), ux = dx / L, uy = dy / L;
88
+ const emit = [Math.round(pS[0] + 70 * f * ux), Math.round(pS[1] + 70 * f * uy)];
89
+ // EMISSION-CAP GATE as DECLARED DATA (the blackbody-thermostat pattern):
90
+ // emissionCap + gateCounter compile at CONSTRUCTION into a host tick-fn reading
91
+ // Wall.getLiveCount on the background counter named 'population' (placed below — the by-name
92
+ // reference resolves lazily on the first tick). The closed loop has NO sink, so the gate pauses
93
+ // emission at the cap and resumes below it: the population plateaus. Restart-safe: the parts
94
+ // writer keeps the two fields in the entry and the rebuilt emitter recompiles the gate (no free recipe tick-fn).
95
+ // is2D: false (the 3D default) opens activeAxes.z — the volumetric laser; flat2D writes it true.
96
+ // The parts, written in the 3D default mode (the mode strip's default): the laser, then the ten mirrors at the
97
+ // star vertices — each its own colour + the recolor tag, each a 3D SLAB (z-slab -SD/2..SD/2). 2D is derived from
98
+ // this block by flat2D below.
99
+ export const parts = {
100
+ source: [SphereEmitter, { preset: 'particle-walk', is2D: false, center: emit, orientationDeg: angOf(pS, p0), emissionBoxSide: 2, burstSize: 8, emissionCap: CAP, gateCounter: 'population' }],
101
+ m1: [Wall, { rect: [Math.round(pos[0][0] - SW / 2), Math.round(pos[0][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[0], color: PAL[0], hooks: ['recolor'] }],
102
+ m2: [Wall, { rect: [Math.round(pos[1][0] - SW / 2), Math.round(pos[1][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[1], color: PAL[1], hooks: ['recolor'] }],
103
+ m3: [Wall, { rect: [Math.round(pos[2][0] - SW / 2), Math.round(pos[2][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[2], color: PAL[2], hooks: ['recolor'] }],
104
+ m4: [Wall, { rect: [Math.round(pos[3][0] - SW / 2), Math.round(pos[3][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[3], color: PAL[3], hooks: ['recolor'] }],
105
+ m5: [Wall, { rect: [Math.round(pos[4][0] - SW / 2), Math.round(pos[4][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[4], color: PAL[4], hooks: ['recolor'] }],
106
+ m6: [Wall, { rect: [Math.round(pos[5][0] - SW / 2), Math.round(pos[5][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[5], color: PAL[5], hooks: ['recolor'] }],
107
+ m7: [Wall, { rect: [Math.round(pos[6][0] - SW / 2), Math.round(pos[6][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[6], color: PAL[6], hooks: ['recolor'] }],
108
+ m8: [Wall, { rect: [Math.round(pos[7][0] - SW / 2), Math.round(pos[7][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[7], color: PAL[7], hooks: ['recolor'] }],
109
+ m9: [Wall, { rect: [Math.round(pos[8][0] - SW / 2), Math.round(pos[8][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[8], color: PAL[8], hooks: ['recolor'] }],
110
+ m10: [Wall, { rect: [Math.round(pos[9][0] - SW / 2), Math.round(pos[9][1] - SH / 2), SW, SH], z: -Math.round(SD / 2), depth: SD, collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[9], color: PAL[9], hooks: ['recolor'] }],
111
+ };
112
+
113
+ /** This scene's 2D delta, stated once: the same keys in the same order, each props copied with its z-slab
114
+ * (z, depth) dropped and is2D written true where it is spelled. Pure; adds no key. */
115
+ function flat2D(parts) {
116
+ const out = {};
117
+ for (const key of Object.keys(parts)) {
118
+ const props = Object.assign({}, parts[key][1]);
119
+ delete props.z;
120
+ delete props.depth;
121
+ if ('is2D' in props) { props.is2D = true; }
122
+ out[key] = [parts[key][0], props];
123
+ }
124
+ return out;
125
+ }
41
126
 
42
127
  export default function buildExperiment(exp) {
43
128
  // The booted display (the host's renderState.renderer) decides which world this mode makes.
44
129
  var IS3D = FrqtlDisplay.name() === 'three';
45
- var W = 600, H = 400, WORLD_D = 600; // THE WORLD this scene states (its co-init call below)
46
- var f = Math.min(W, H) / 600;
47
- // R 225·f: adjacent decagon vertices sit 2R·sin18° ≈ 0.618R apart vs slab
48
- // height ~110·f, so the mirrors separate with clear gaps. Fit: R + rotated-slab half-diagonal
49
- // (≈55·f) stays inside min(W,H)/2 with margin.
50
- var CX = Math.round(W / 2), CY = Math.round(H / 2), R = Math.round(225 * f), HOP = 3, ROT = 12;
51
- // CAP 6400 / burst 8 (brightness retune): a VERY BRIGHT braid. The cap is
52
- // LIVE-controllable off the population counter (select 'population' in Layers → Inspector
53
- // 'Population Gate').
54
- var SW = Math.max(10, Math.round(14 * f)), SH = Math.max(70, Math.round(110 * f)), CAP = 6400;
55
- // SD — the 3D slab z-depth: a 2:1 tile (twice the slab height, z-centred on the beam plane).
56
- // The scene z-collimator clamps ALL quanta to this band, so every mirror provably faces every
57
- // alive quantum.
58
- var SD = 2 * SH;
59
- // Each mirror a DIFFERENT colour = its identity (a red→magenta rainbow); the recolor hook
60
- // stamps that colour onto every particle it reflects (string_map['render.color']).
61
- var PAL = [[255,60,60],[255,140,0],[255,220,0],[150,230,40],[40,210,90],[0,210,190],[0,170,255],[70,90,255],[150,70,235],[235,50,190]];
62
- // Hit-order vertices of the {10/3} star (m1..m10 named IN the order the beam visits them).
63
- var order = []; for (var k = 0; k < 10; k++) { order.push((k * HOP) % 10); }
64
- var D2R = Math.PI / 180;
65
- var vpos = function (v) { var a = (v * 36 + ROT) * D2R; return [CX + R * Math.cos(a), CY + R * Math.sin(a)]; };
66
- var pos = order.map(vpos);
67
- var deg = function (a) { return ((a * 180 / Math.PI) % 360 + 360) % 360; };
68
- var angOf = function (p, q) { return deg(Math.atan2(q[1] - p[1], q[0] - p[0])); };
69
- // Per-mirror IN-PLANE orientation from the in/out chords: ori = (in+out−180)/2, branch chosen
70
- // so the engine reflect law out = 180 + 2·ori − in reproduces the outgoing chord exactly.
71
- // Orientation is z-axis rotation, so the 3D slab depth adds NO new angle.
72
- var ORI = [];
73
- for (var j = 0; j < 10; j++) {
74
- var pr = pos[(j + 9) % 10], cu = pos[j], nx = pos[(j + 1) % 10];
75
- var inA = angOf(pr, cu), outA = angOf(cu, nx);
76
- var o = (((inA + outA - 180) / 2) % 360 + 360) % 360;
77
- var op = ((180 + 2 * o - inA) % 360 + 360) % 360;
78
- if (Math.abs(((op - outA + 540) % 360) - 180) > 0.5) { o = (o + 90) % 360; }
79
- ORI.push(o);
80
- }
81
- // The NARROW STRAIGHT-WALK laser (the exemplar): the SAME emitter as Rung 1
82
- // (Straight Walk) — preset 'particle-walk', every walk-character field INHERITED from the same
83
- // factory defaults (chargeMode 'positive', dim 0 = 'light' P=1, fundamentalWaveLength 64 → the
84
- // λ16 braided transverse ripple) — with ONLY the deliberate loop deltas: (a) a 2px source line
85
- // (emissionBoxSide 2 vs r1's default 10), (b) seated on the m10→m1 leg JUST CLEAR of m10 (70·f
86
- // past its vertex > the rotated-slab half-diagonal ≈55·f) and aimed along the leg (the longest
87
- // clear throw: leg 1.618R ≈ 364·f), (c) a burst of 8/tick, and (d) the DECLARED emission gate.
88
- var pS = pos[9], p0 = pos[0];
89
- var dx = p0[0] - pS[0], dy = p0[1] - pS[1], L = Math.hypot(dx, dy), ux = dx / L, uy = dy / L;
90
- var emit = [Math.round(pS[0] + 70 * f * ux), Math.round(pS[1] + 70 * f * uy)];
91
- // EMISSION-CAP GATE as DECLARED DATA (the blackbody-thermostat pattern):
92
- // emissionCap + gateCounter compile at CONSTRUCTION into a host tick-fn reading
93
- // Wall.getLiveCount on the background counter named 'population' (placed below — the by-name
94
- // reference resolves lazily on the first tick). The closed loop has NO sink, so the gate pauses
95
- // emission at the cap and resumes below it: the population plateaus. Restart-safe: codegen
96
- // captures the two fields and the rebuilt emitter recompiles the gate (no free recipe tick-fn).
97
- // is2D:!IS3D opens activeAxes.z in 3D — the volumetric laser.
98
- var SRC = {
99
- preset: 'particle-walk', is2D: !IS3D, center: emit, orientationDeg: angOf(pS, p0),
100
- emissionBoxSide: 2, burstSize: 8,
101
- emissionCap: CAP, gateCounter: 'population'
102
- };
130
+ var P = IS3D ? parts : flat2D(parts);
103
131
  // THE WORLD — first, then parts: a 3D scene + universe (600x400x600) or a 2D canvas + universe.
104
- if (IS3D) { exp.createScene(W, H, WORLD_D, SphereEmitter.worldCfg(SRC)); }
105
- else { exp.createCanvas(W, H, SphereEmitter.worldCfg(SRC)); }
106
- exp.declare(parts);
107
- exp.addObject('SphereEmitter', SRC);
108
- // The ten mirrors at the star vertices — each its own colour + the recolor tag. In 3D each is a
109
- // SLAB (z-slab −SD/2..SD/2); in 2D the bag is EMPTY, so the wall is the flat mirror.
110
- var ZSLAB = IS3D ? { z: -Math.round(SD / 2), depth: SD } : {};
111
- for (var i = 0; i < 10; i++) {
112
- var p = pos[i];
113
- exp.addObject('Wall', Object.assign(
114
- { rect: [Math.round(p[0] - SW / 2), Math.round(p[1] - SH / 2), SW, SH] }, ZSLAB,
115
- { collisionBehavior: 'bounce', bounceDirection: 'reflect', orientationDeg: ORI[i],
116
- name: 'm' + (i + 1), color: PAL[i], hooks: ['recolor'] }));
117
- }
132
+ if (IS3D) { exp.createScene(W, H, WORLD_D, SphereEmitter.worldCfg(P.source[1])); }
133
+ else { exp.createCanvas(W, H, SphereEmitter.worldCfg(P.source[1])); }
134
+ exp.declare(P);
118
135
  if (IS3D) {
119
136
  // SCENE Z-COLLIMATOR (without it the z lets things pass through instead of
120
137
  // reflecting): the legs' z-braid diffuses lap over lap; any quantum beyond the mirrors'
@@ -1,121 +1,143 @@
1
1
  /* Copyright 2023-2026 by Essam Abadir */
2
2
 
3
3
  /**
4
- * lib/shim/bundle/index.js — the recipe-bundle assembler + importer
5
- * The canonical owner of the bundle format
6
- * (design §2): a folder that zips for download —
4
+ * lib/shim/bundle/index.js — Export and Import of a project folder.
7
5
  *
8
- * experiment.js ← THE source of truth (the buildExperiment(exp) recipe)
9
- * experiment.frqnb ← teaching/gallery projection (notebook/format.serialize)
10
- * index.html ← standalone host (@descix/frqtl-web bootProject over the served folder)
11
- * manifest.json ← Module envelope minus `source` + bundle-only fields (E1)
12
- * preview.png ← optional golden screenshot
6
+ * A project is a folder (the stored row's owner is
7
+ * lib/shim/storage/folders.js). The Export IS the folder, zipped, plus the host page that boots it:
13
8
  *
14
- * PURE + DOM-free: emits/consumes `Uint8Array`. STUDIO's export button and any
15
- * future DeSciX publisher CONSUME this — passing the generated `experiment.js`
16
- * source + envelope + renderState DOWN (this module never imports codegen/
17
- * save-state — those live above the shim; the arrow stays one-way). The browser
18
- * `Blob`+`a.click` download trigger is a THIN feature-side helper.
9
+ * exportFolder(files, renderState, { sdkVersion }) → zipBytes
10
+ * the zip holds EXACTLY the folder's files plus a generated `index.html` (indexHtml.js: bootProject over the
11
+ * served folder, frqtl.json naming the entry, the boot bag inlined by its one writer).
12
+ * importArchive(bytes, fileName) → { files, renderState } (a normalized folder row)
13
+ * a zip → its files minus `index.html`, whose boot bag (indexHtml.js readBootBag) becomes renderState;
14
+ * it must carry frqtl.json at its root (an archive with no index.html imports with
15
+ * renderState null)
16
+ * one `.js` → the one-file project { 'sketch.js': text, 'frqtl.json': manifestFor({ name: <basename>,
17
+ * entry: 'sketch.js' }) }, renderState null — a 3D sketch then fails loud at boot on the
18
+ * renderer (the existing named refusal); nothing guesses a display
19
+ * REFUSED, naming the migration: a zip carrying `experiment.js` or `manifest.json`, or a JSON v4 module
20
+ * envelope — the retired export format. REFUSED by rule: a one-file `.mjs` (frqtl.json's entry rule admits a
21
+ * `.js` entry only), anything else.
19
22
  *
20
- * Layer 2 (physics): none — pure format/packaging.
23
+ * PURE + DOM-free (Uint8Array in and out). The browser download and file read are lib/features/ide/bundle-export.js.
24
+ * zip.js (the STORE zip) is imported by the Record too; its API is frozen. Layer 2 (physics): none.
21
25
  */
22
26
 
23
27
  'use strict';
24
28
 
25
- import { buildManifest, manifestToEnvelope } from './manifest.js';
26
- import { buildFrqnb } from './frqnb.js';
27
- import { buildIndexHtml } from './indexHtml.js';
28
- import { zipStore, unzipStore, textBytes, bytesText } from './zip.js';
29
- import { normalizeSavedRenderState } from '../render-state/index.js';
29
+ import { zipStore, unzipStore, textBytes } from './zip.js';
30
+ import { buildIndexHtml, readBootBag } from './indexHtml.js';
31
+ import { normalizeFolderRow, EXPORT_PAGE } from '../storage/folders.js';
32
+ import { MANIFEST_FILE, MANIFEST_KEYS, parseManifest, serializeManifest, manifestFor } from '../project/manifest.js';
30
33
 
31
- // ── Canonical bundle file names ──────────────────────────────────────────────
32
- export var FILE_EXPERIMENT_JS = 'experiment.js';
33
- export var FILE_EXPERIMENT_FRQNB = 'experiment.frqnb';
34
- export var FILE_INDEX_HTML = 'index.html';
35
- export var FILE_MANIFEST_JSON = 'manifest.json';
36
- export var FILE_PREVIEW_PNG = 'preview.png';
34
+ /** The files of the retired export format (the v4 envelope's bundle). */
35
+ export var RETIRED_ARCHIVE_FILES = Object.freeze(['experiment.js', 'manifest.json']);
37
36
 
38
- /**
39
- * Assemble a recipe bundle from a Module envelope + bundle inputs.
40
- *
41
- * Conceptually the dispatch's `(source, envelopeMeta, renderState, previewPngBytes,
42
- * sdkVersion)`: here `source` + `renderState` + `envelopeMeta` arrive together AS
43
- * the Module envelope (the exact shape STUDIO's Save path already builds), so the
44
- * bundle↔envelope round-trip is a clean identity. `manifest` ferries the envelope
45
- * minus `source` (E1) — no field re-enumeration.
46
- *
47
- * @param {object} envelope — a `lib/shim/storage/modules.js` Module envelope
48
- * (`{id,name,source,renderState,…}`; `source` = the recipe, REQUIRED).
49
- * @param {object} opts
50
- * @param {string} opts.sdkVersion — REQUIRED CDN pin (derived, never a literal).
51
- * @param {Uint8Array|null} [opts.previewPngBytes] — preview image bytes, or null.
52
- * @param {string} [opts.stage='600x400'] — canvas stage for the .frqnb projection.
53
- * @param {string} [opts.prose] — optional teaching blurb for the .frqnb markdown.
54
- * @returns {{files: {name:string, bytes:Uint8Array}[], zipBytes: Uint8Array}}
55
- */
56
- export function assembleBundle(envelope, opts) {
57
- if (!envelope || typeof envelope !== 'object') {
58
- throw new Error('[bundle] assembleBundle(envelope): envelope must be an object.');
59
- }
60
- if (typeof envelope.source !== 'string' || envelope.source.length === 0) {
61
- throw new Error('[bundle] assembleBundle: envelope.source (the recipe) is required.');
62
- }
63
- opts = opts || {};
64
- var preview = (opts.previewPngBytes instanceof Uint8Array && opts.previewPngBytes.length > 0)
65
- ? opts.previewPngBytes : null;
37
+ var RETIRED = 'this is the retired export format; the project folder replaced it (frqtl.json + sketch.js)';
38
+ var SKETCH = 'sketch.js';
66
39
 
67
- var manifest = buildManifest(envelope, {
68
- sdkVersion: opts.sdkVersion,
69
- preview: preview ? FILE_PREVIEW_PNG : null
70
- });
40
+ var _strict = new TextDecoder('utf-8', { fatal: true });
71
41
 
72
- var normalizedRs = normalizeSavedRenderState(envelope.renderState);
73
-
74
- var files = [
75
- { name: FILE_EXPERIMENT_JS, bytes: textBytes(envelope.source) },
76
- { name: FILE_EXPERIMENT_FRQNB, bytes: textBytes(buildFrqnb({
77
- name: envelope.name, source: envelope.source,
78
- renderState: normalizedRs, stage: opts.stage, prose: opts.prose
79
- })) },
80
- { name: FILE_INDEX_HTML, bytes: textBytes(buildIndexHtml({
81
- name: envelope.name, sdkVersion: opts.sdkVersion, renderState: normalizedRs,
82
- scripts: Array.isArray(envelope.scripts) ? envelope.scripts : [] // the envelope's setup-factory delta
83
- })) },
84
- { name: FILE_MANIFEST_JSON, bytes: textBytes(JSON.stringify(manifest, null, 2)) }
85
- ];
86
- if (preview) { files.push({ name: FILE_PREVIEW_PNG, bytes: preview }); }
87
-
88
- return { files: files, zipBytes: zipStore(files) };
42
+ function _fail(msg) {
43
+ throw new Error('[bundle] ' + msg + ' (FAIL-LOUD.)');
89
44
  }
90
45
 
91
46
  /**
92
- * Import a recipe bundle: recover the Module envelope (source re-attached from
93
- * `experiment.js` per E1) + the preview bytes.
94
- *
95
- * @param {Uint8Array} zipBytes — a bundle `.zip` (from `assembleBundle`).
96
- * @returns {{envelope: object, previewPngBytes: Uint8Array|null}}
47
+ * @param {Object<string,string>} files — the folder (frqtl.json + its files)
48
+ * @param {object|null} renderState — the host's boot bag (normalized by its owner through the folder row)
49
+ * @param {{sdkVersion: string}} opts — the `@descix/frqtl-web` pin the page imports (derived, never a literal)
50
+ * @returns {Uint8Array} the zip
97
51
  */
98
- export function importBundle(zipBytes) {
99
- var entries = unzipStore(zipBytes);
100
- var byName = Object.create(null);
101
- for (var i = 0; i < entries.length; i++) { byName[entries[i].name] = entries[i].bytes; }
52
+ export function exportFolder(files, renderState, opts) {
53
+ var row = normalizeFolderRow({ files: files, renderState: renderState });
54
+ var sdkVersion = opts && opts.sdkVersion;
55
+ var name = parseManifest(row.files[MANIFEST_FILE]).name;
56
+ var entries = Object.keys(row.files).map(function (p) { return { name: p, bytes: textBytes(row.files[p]) }; });
57
+ entries.push({ name: EXPORT_PAGE, bytes: textBytes(buildIndexHtml({ name: name, sdkVersion: sdkVersion, renderState: row.renderState })) });
58
+ return zipStore(entries);
59
+ }
60
+
61
+ function _isZip(bytes) {
62
+ return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b &&
63
+ ((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06));
64
+ }
102
65
 
103
- if (!byName[FILE_MANIFEST_JSON]) {
104
- throw new Error('[bundle] importBundle: ' + FILE_MANIFEST_JSON + ' missing from the archive.');
66
+ function _text(bytes, what) {
67
+ try { return _strict.decode(bytes); }
68
+ catch (e) { _fail(what + ' is not UTF-8 text — a project folder holds text files only.'); }
69
+ }
70
+
71
+ function _baseName(fileName) {
72
+ var base = String(fileName).split(/[\\/]/).pop();
73
+ return base;
74
+ }
75
+
76
+ function _fromZip(bytes) {
77
+ var entries = unzipStore(bytes).filter(function (e) { return !/\/$/.test(e.name); });
78
+ var names = entries.map(function (e) { return e.name; });
79
+ var retired = RETIRED_ARCHIVE_FILES.filter(function (f) { return names.indexOf(f) !== -1; });
80
+ if (retired.length) {
81
+ _fail('the archive carries ' + retired.join(' and ') + ': ' + RETIRED + '.');
105
82
  }
106
- if (!byName[FILE_EXPERIMENT_JS]) {
107
- throw new Error('[bundle] importBundle: ' + FILE_EXPERIMENT_JS + ' missing from the archive.');
83
+ if (names.indexOf(MANIFEST_FILE) === -1) {
84
+ var tops = names.map(function (n) { return n.split('/')[0]; }).filter(function (t, i, a) { return a.indexOf(t) === i; });
85
+ if (tops.length === 1 && names.indexOf(tops[0] + '/' + MANIFEST_FILE) !== -1 &&
86
+ names.every(function (n) { return n.indexOf(tops[0] + '/') === 0; })) {
87
+ _fail('the archive carries no ' + MANIFEST_FILE + ' at its root — the archive holds the folder `' + tops[0] + '/` rather ' +
88
+ 'than its contents — zip the folder\'s CONTENTS (' + MANIFEST_FILE + ' at the root), as an Export writes it.');
89
+ }
90
+ _fail('the archive carries no ' + MANIFEST_FILE + ' at its root — a project archive is the folder zipped ' +
91
+ '(frqtl.json + sketch.js + …), as an Export writes it.');
108
92
  }
93
+ var files = {};
94
+ var renderState = null;
95
+ entries.forEach(function (e) {
96
+ var text = _text(e.bytes, '"' + e.name + '"');
97
+ if (e.name === EXPORT_PAGE) { renderState = readBootBag(text); return; }
98
+ files[e.name] = text;
99
+ });
100
+ return normalizeFolderRow({ files: files, renderState: renderState });
101
+ }
109
102
 
110
- var manifest;
111
- try {
112
- manifest = JSON.parse(bytesText(byName[FILE_MANIFEST_JSON]));
113
- } catch (e) {
114
- throw new Error('[bundle] importBundle: ' + FILE_MANIFEST_JSON + ' is not valid JSON — ' + (e && e.message));
103
+ function _fromJson(text) {
104
+ var raw;
105
+ try { raw = JSON.parse(text); }
106
+ catch (e) { _fail('the file is neither a project archive (.zip) nor a sketch (.js), and not valid JSON either.'); }
107
+ if (raw && typeof raw === 'object' && ('schemaVersion' in raw || 'scripts' in raw) && 'source' in raw) {
108
+ _fail('the file is a v4 module envelope: ' + RETIRED + '.');
115
109
  }
116
- var source = bytesText(byName[FILE_EXPERIMENT_JS]);
117
- var envelope = manifestToEnvelope(manifest, source);
118
- var previewPngBytes = byName[FILE_PREVIEW_PNG] || null;
110
+ _fail('the JSON file is not a project — Import takes a project archive (.zip) or one sketch (.js).');
111
+ }
112
+
113
+ function _fromSketch(text, fileName) {
114
+ var base = _baseName(fileName);
115
+ var name = base.replace(/\.js$/, '');
116
+ if (!name) { _fail('the sketch file "' + base + '" has no name before ".js".'); }
117
+ var files = {};
118
+ files[MANIFEST_FILE] = serializeManifest(manifestFor({ name: name, entry: SKETCH }));
119
+ files[SKETCH] = text;
120
+ return normalizeFolderRow({ files: files, renderState: null });
121
+ }
119
122
 
120
- return { envelope: envelope, previewPngBytes: previewPngBytes };
123
+ /**
124
+ * @param {Uint8Array} bytes
125
+ * @param {string} fileName — the imported file's name (a one-file sketch's basename is its project name)
126
+ * @returns {Readonly<{files: Object<string,string>, renderState: object}>}
127
+ */
128
+ export function importArchive(bytes, fileName) {
129
+ if (!(bytes instanceof Uint8Array)) { _fail('importArchive(bytes, fileName): bytes is a Uint8Array.'); }
130
+ if (typeof fileName !== 'string' || fileName.length === 0) { _fail('importArchive(bytes, fileName): the file\'s name.'); }
131
+ if (_isZip(bytes)) { return _fromZip(bytes); }
132
+ var base = _baseName(fileName);
133
+ if (/\.mjs$/i.test(base)) {
134
+ var entryRule = MANIFEST_KEYS.filter(function (r) { return r.key === 'entry'; })[0].rule;
135
+ _fail('"' + base + '" is a .mjs module — a one-file Import takes a .js sketch, because a project\'s entry is ' +
136
+ entryRule + ' (frqtl.json, lib/shim/project/manifest.js MANIFEST_KEYS). Rename it to .js and import it again.');
137
+ }
138
+ var text = _text(bytes, '"' + base + '"');
139
+ if (/\.js$/.test(base)) { return _fromSketch(text, base); }
140
+ if (/\.json$/i.test(base) || /^\s*\{/.test(text)) { return _fromJson(text); }
141
+ _fail('"' + base + '" is not a project — Import takes a project archive (.zip, as an Export writes it) or one ' +
142
+ 'sketch (.js).');
121
143
  }
@@ -1,21 +1,24 @@
1
1
  /* Copyright 2023-2026 by Essam Abadir */
2
2
 
3
3
  /**
4
- * lib/shim/bundle/indexHtml.js — the standalone `index.html` projection of a
5
- * recipe bundle.
4
+ * lib/shim/bundle/indexHtml.js — the generated `index.html` of an exported project folder: the HOST PAGE,
5
+ * and the ONE writer + ONE parser of the boot bag it carries.
6
6
  *
7
- * A byte-template host that boots the bundle folder as a SERVED PROJECT: `bootProject(mount,
8
- * { url: './', entry: 'experiment.js' }, { renderState })` from `@descix/frqtl-web@<pin>` on the CDN. The
9
- * recipe is a sketch module whose imports name the published self-reference (`@descix/frqtl-web/...`);
10
- * the page's import map carries those rows and the scene doors' row, rendered by the ONE table
11
- * (`pageImportMapFor(<the pinned dist root>/)`, lib/shim/project/specifiers.js), so the sketch resolves
12
- * to the SAME module URLs the boot loads. The pinned dist root is the router's one spelling
13
- * (`cdnDistBaseFor`); the `@<pin>` is the passed-in `sdkVersion` (DERIVED at export time — never a
14
- * literal here).
7
+ * buildIndexHtml({ name, sdkVersion, renderState }) → the page text
8
+ * readBootBag(html) → the renderState it carries (the import's one reader)
9
+ * BOOT_BAG_ID 'frqtl-boot' — the bag's element id
15
10
  *
16
- * DRY: `experiment.js` stays the ONE source of truth — the page links it where it is served rather than
17
- * embedding a second copy. `renderState` (small, manifest-derived) is embedded inline as JSON. A page
18
- * opened via `file://` cannot fetch the project: the error says to serve the folder over http.
11
+ * The page boots the folder it sits in as a SERVED PROJECT: `bootProject(mount, { url: './' }, { renderState })`
12
+ * from `@descix/frqtl-web@<pin>` on the CDN. `frqtl.json` names the entry (P2a: the linker reads it first), so
13
+ * the page names no file. The import map is the ONE table's (`pageImportMapFor(<the pinned dist root>/)`,
14
+ * lib/shim/project/specifiers.js), so a sketch's `@descix/frqtl-web/...` imports resolve to the same module URLs
15
+ * the boot loads. The pinned dist root is the router's one spelling (`cdnDistBaseFor`); the pin is the passed-in
16
+ * `sdkVersion` (derived at export time, never a literal here).
17
+ *
18
+ * The boot bag — the host's renderState, which never enters frqtl.json or the project identity — is embedded as
19
+ * `<script type="application/json" id="frqtl-boot">`, `<` escaped so no payload closes the element. The page reads
20
+ * it from that element; an Import reads it back with readBootBag and drops the page. A page opened via `file://`
21
+ * cannot fetch the folder: it says to serve the folder over http.
19
22
  *
20
23
  * Layer 2 (physics): none — pure host projection.
21
24
  */
@@ -25,41 +28,38 @@
25
28
  import { pageImportMapFor, FRQTL_WEB_BOOT } from '../project/specifiers.js';
26
29
  import { cdnDistBaseFor } from '../engine/router.js';
27
30
 
31
+ /** The boot bag's element id. */
32
+ export var BOOT_BAG_ID = 'frqtl-boot';
33
+
34
+ var _BAG_OPEN = '<script type="application/json" id="' + BOOT_BAG_ID + '">';
35
+ var _BAG_CLOSE = '</script>';
36
+
28
37
  function _escapeHtml(s) {
29
38
  return String(s == null ? '' : s)
30
39
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
31
40
  }
32
41
 
33
- /** Safe inline-JSON: escape `<` so a `</script>`-like payload can't break out. */
42
+ /** Safe inline JSON: `<` escaped so a `</script>`-like payload cannot break out. */
34
43
  function _inlineJson(obj) {
35
44
  return JSON.stringify(obj == null ? null : obj).replace(/</g, '\\u003c');
36
45
  }
37
46
 
38
47
  /**
39
- * Build the standalone `index.html` for a recipe bundle.
40
- *
41
48
  * @param {object} args
42
- * @param {string} args.name — project name (page title).
43
- * @param {string} args.sdkVersion — the `@descix/frqtl-web@<pin>` version (REQUIRED).
44
- * @param {object} [args.renderState] — normalized renderState bag (inlined as JSON;
45
- * forwarded to bootProject's `{renderState}`).
46
- * @param {string[]} [args.scripts] — the v4 envelope's setup-factory delta (inlined as JSON): nothing
47
- * classic is left to load, so the page REFUSES a non-empty list at boot,
48
- * naming the fix (import the factory in the sketch).
49
- * @param {string} [args.recipeFile='experiment.js'] — the project's entry file.
50
- * @returns {string} the complete `index.html`.
49
+ * @param {string} args.name — the project's name (frqtl.json `name`; the page title)
50
+ * @param {string} args.sdkVersion — the `@descix/frqtl-web@<pin>` version (REQUIRED)
51
+ * @param {object|null} args.renderState — the normalized boot bag (null boots with the page's defaults)
52
+ * @returns {string} the complete `index.html`
51
53
  */
52
54
  export function buildIndexHtml(args) {
53
55
  args = args || {};
54
56
  if (typeof args.sdkVersion !== 'string' || args.sdkVersion.length === 0) {
55
- throw new Error('[bundle/indexHtml] buildIndexHtml: args.sdkVersion is REQUIRED (the CDN pin).');
57
+ throw new Error('[bundle/indexHtml] buildIndexHtml: args.sdkVersion is REQUIRED (the CDN pin). (FAIL-LOUD.)');
58
+ }
59
+ if (typeof args.name !== 'string' || args.name.length === 0) {
60
+ throw new Error('[bundle/indexHtml] buildIndexHtml: args.name is the project\'s frqtl.json name. (FAIL-LOUD.)');
56
61
  }
57
- var name = (typeof args.name === 'string' && args.name.length > 0) ? args.name : 'FRQTL Recipe';
58
- var recipeFile = (typeof args.recipeFile === 'string' && args.recipeFile.length > 0)
59
- ? args.recipeFile : 'experiment.js';
60
62
  var importMapJson = _inlineJson(pageImportMapFor(cdnDistBaseFor(args.sdkVersion) + '/'));
61
- var renderStateJson = _inlineJson(args.renderState || null);
62
- var scriptsJson = _inlineJson(Array.isArray(args.scripts) ? args.scripts : []);
63
63
 
64
64
  return [
65
65
  '<!DOCTYPE html>',
@@ -67,7 +67,7 @@ export function buildIndexHtml(args) {
67
67
  '<head>',
68
68
  ' <meta charset="UTF-8">',
69
69
  ' <meta name="viewport" content="width=device-width, initial-scale=1">',
70
- ' <title>' + _escapeHtml(name) + ' — FRQTL</title>',
70
+ ' <title>' + _escapeHtml(args.name) + ' — FRQTL</title>',
71
71
  ' <style>',
72
72
  ' html,body{margin:0;height:100%;background:#0b0b0f;color:#ddd;',
73
73
  ' font:14px/1.5 ui-sans-serif,system-ui,sans-serif;}',
@@ -80,23 +80,21 @@ export function buildIndexHtml(args) {
80
80
  '<body>',
81
81
  ' <div id="frqtl-mount"></div>',
82
82
  ' <pre id="frqtl-err"></pre>',
83
+ ' ' + _BAG_OPEN + _inlineJson(args.renderState) + _BAG_CLOSE,
83
84
  ' <script type="importmap">' + importMapJson + '</script>',
84
85
  ' <script type="module">',
85
86
  ' import { bootProject } from ' + JSON.stringify(FRQTL_WEB_BOOT.specifier) + ';',
86
- ' const RENDER_STATE = ' + renderStateJson + ';',
87
- ' const SCRIPTS = ' + scriptsJson + ';',
87
+ ' const renderState = JSON.parse(document.getElementById(' + JSON.stringify(BOOT_BAG_ID) + ').textContent);',
88
88
  ' const mount = document.getElementById("frqtl-mount");',
89
89
  ' const errBox = document.getElementById("frqtl-err");',
90
90
  ' function fail(title, detail){',
91
91
  ' errBox.style.display = "block";',
92
92
  ' errBox.textContent = title + "\\n\\n" + detail;',
93
93
  ' }',
94
- ' if (SCRIPTS.length > 0) {',
95
- ' fail("FRQTL project boot refused", "scripts " + JSON.stringify(SCRIPTS) + " is a classic setup-factory delta, and nothing classic is left to load — import the factory in the sketch (it imports what it uses from @descix/frqtl-web/...).");',
96
- ' } else if (location.protocol === "file:") {',
97
- ' fail("Serve this folder over http", "e.g. `npx serve` — a file:// page cannot fetch ' + _escapeHtml(recipeFile) + '.");',
94
+ ' if (location.protocol === "file:") {',
95
+ ' fail("Serve this folder over http", "e.g. `npx serve` — a file:// page cannot fetch the project files.");',
98
96
  ' } else {',
99
- ' bootProject(mount, { url: new URL("./", location.href).href, entry: ' + JSON.stringify(recipeFile) + ' }, { renderState: RENDER_STATE })',
97
+ ' bootProject(mount, { url: new URL("./", location.href).href }, { renderState: renderState })',
100
98
  ' .catch((e) => fail("FRQTL project boot failed", String(e && e.stack || e)));',
101
99
  ' }',
102
100
  ' </script>',
@@ -105,3 +103,32 @@ export function buildIndexHtml(args) {
105
103
  ''
106
104
  ].join('\n');
107
105
  }
106
+
107
+ /**
108
+ * THE parser of the boot bag: the renderState an exported page carries (null when it carries none).
109
+ * THROWS when the page holds no bag element, or more than one, or one that is not JSON.
110
+ * @param {string} html
111
+ * @returns {object|null}
112
+ */
113
+ export function readBootBag(html) {
114
+ if (typeof html !== 'string') {
115
+ throw new Error('[bundle/indexHtml] readBootBag(html): the page text (got ' + typeof html + '). (FAIL-LOUD.)');
116
+ }
117
+ var at = html.indexOf(_BAG_OPEN);
118
+ if (at === -1) {
119
+ throw new Error('[bundle/indexHtml] the index.html carries no boot bag (<script type="application/json" id="' +
120
+ BOOT_BAG_ID + '">) — it is not the page an Export writes. (FAIL-LOUD.)');
121
+ }
122
+ if (html.indexOf(_BAG_OPEN, at + 1) !== -1) {
123
+ throw new Error('[bundle/indexHtml] the index.html carries two boot bags — an Export writes one. (FAIL-LOUD.)');
124
+ }
125
+ var start = at + _BAG_OPEN.length;
126
+ var end = html.indexOf(_BAG_CLOSE, start);
127
+ if (end === -1) {
128
+ throw new Error('[bundle/indexHtml] the boot bag element is not closed. (FAIL-LOUD.)');
129
+ }
130
+ try { return JSON.parse(html.slice(start, end)); }
131
+ catch (e) {
132
+ throw new Error('[bundle/indexHtml] the boot bag is not valid JSON: ' + ((e && e.message) || e) + '. (FAIL-LOUD.)');
133
+ }
134
+ }
@@ -101,7 +101,7 @@
101
101
  // action — a MOMENTARY verb: {id,label,action} where `action` is a member of
102
102
  // the closed ACTIONS vocabulary below. Stateless (no `value`, no
103
103
  // read-back) and serializable by NAME, so a declared array of them
104
- // round-trips through codegen with no new machinery. Actions are
104
+ // round-trips through the scene's text with no new machinery. Actions are
105
105
  // deliberately NOT expressed as a `bind` — a bind is a property
106
106
  // read/write pair, and `get()` of "restart" is meaningless.
107
107
  //