@onmark/cli 0.3.0 → 0.5.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 (39) hide show
  1. package/onmark-release.json +94 -64
  2. package/package.json +4 -4
  3. package/packages/authoring/dist/src/frame-motion.d.ts +10 -2
  4. package/packages/authoring/dist/src/frame-motion.js +29 -6
  5. package/packages/authoring/dist/src/index.d.ts +2 -2
  6. package/packages/authoring/dist/src/motion.d.ts +16 -4
  7. package/packages/authoring/dist/src/presentation.js +160 -6
  8. package/packages/authoring/dist/src/types.d.ts +1 -1
  9. package/packages/bundler/dist/src/authored_html.d.ts +19 -7
  10. package/packages/bundler/dist/src/authored_html.js +109 -21
  11. package/packages/bundler/dist/src/bundle_projection.d.ts +9 -0
  12. package/packages/bundler/dist/src/bundle_projection.js +66 -0
  13. package/packages/bundler/dist/src/command.js +38 -14
  14. package/packages/bundler/dist/src/generated/bundle-manifest.d.ts +4 -5
  15. package/packages/bundler/dist/src/generated/bundle-manifest.js +1 -0
  16. package/packages/bundler/dist/src/generated/bundle-projection-validator.d.ts +7 -0
  17. package/packages/bundler/dist/src/generated/bundle-projection-validator.js +432 -0
  18. package/packages/bundler/dist/src/generated/bundle-projection.d.ts +25 -0
  19. package/packages/bundler/dist/src/generated/bundle-projection.js +2 -0
  20. package/packages/bundler/dist/src/index.d.ts +1 -1
  21. package/packages/bundler/dist/src/presentation.d.ts +14 -7
  22. package/packages/bundler/dist/src/presentation.js +71 -30
  23. package/packages/motion-gsap/dist/src/index.d.ts +10 -2
  24. package/packages/motion-gsap/dist/src/index.js +27 -5
  25. package/packages/runtime/dist/src/generated/browser-request.d.ts +19 -2
  26. package/packages/runtime/dist/src/generated/browser-response.d.ts +46 -1
  27. package/packages/runtime/dist/src/generated/bundle-layout.d.ts +1 -1
  28. package/packages/runtime/dist/src/generated/bundle-layout.js +1 -0
  29. package/packages/runtime/dist/src/generated/runtime-contract.d.ts +4 -0
  30. package/packages/runtime/dist/src/generated/runtime-contract.js +4 -0
  31. package/packages/runtime/dist/src/generated/validators.d.ts +2 -2
  32. package/packages/runtime/dist/src/generated/validators.js +1482 -572
  33. package/packages/runtime/dist/src/index.d.ts +4 -4
  34. package/packages/runtime/dist/src/index.js +1 -1
  35. package/packages/runtime/dist/src/presentation.d.ts +18 -3
  36. package/packages/runtime/dist/src/presentation.js +112 -7
  37. package/packages/runtime/dist/src/session.d.ts +7 -3
  38. package/packages/runtime/dist/src/session.js +77 -20
  39. package/packages/runtime/dist/src/types.d.ts +3 -2
@@ -9,6 +9,7 @@ const ELEMENTS = Object.freeze({
9
9
  scene: "om-scene",
10
10
  shot: "om-shot",
11
11
  title: "om-title",
12
+ transition: "om-transition",
12
13
  video: "video",
13
14
  });
14
15
  // Whole-film artifacts may contain semantic nodes absent from a projected
@@ -17,6 +18,7 @@ const VISIBILITY_RULE = [
17
18
  "[data-om-node][hidden],",
18
19
  "om-film > om-scene:not([data-om-node]),",
19
20
  "om-scene > om-shot:not([data-om-node]),",
21
+ "om-scene > om-transition,",
20
22
  "om-shot > :is(video, om-title, om-cta):not([data-om-node]) {",
21
23
  " display: none !important;",
22
24
  "}",
@@ -32,6 +34,7 @@ export function createDomPresentationBindings(options) {
32
34
  bindFilm: document.bindFilm.bind(document),
33
35
  bindScene: document.bindScene.bind(document),
34
36
  bindShot: document.bindShot.bind(document),
37
+ bindTransition: document.bindTransition.bind(document),
35
38
  bindVideo: document.bindVideo.bind(document),
36
39
  bindOverlay: document.bindOverlay.bind(document),
37
40
  async bindExtensions(plan) {
@@ -87,15 +90,31 @@ class AuthoredDocument {
87
90
  }
88
91
  bindVideo(placement) {
89
92
  const element = requiredNode(this.#nodeIndex(), placement.node, "video", ELEMENTS.video);
90
- const bound = bindElement(element, placement.node);
91
93
  element.muted = true;
92
94
  element.playsInline = true;
93
95
  this.#record("video", element, placement.node, placement.interval);
94
- return {
95
- ...bound,
96
+ return bindVideoElement(element, placement.node, this.#videoSource(placement));
97
+ }
98
+ bindTransition(placement) {
99
+ const nodes = this.#nodeIndex();
100
+ const element = requiredNode(nodes, placement.node, "transition", ELEMENTS.transition);
101
+ const outgoingElement = requiredNodeId(nodes, placement.outgoingShotId, "outgoing shot", ELEMENTS.shot);
102
+ const incomingElement = requiredNodeId(nodes, placement.incomingShotId, "incoming shot", ELEMENTS.shot);
103
+ const bound = bindElement(element, placement.node);
104
+ this.#targets.push(Object.freeze({
105
+ element,
106
+ incomingElement,
107
+ interval: placement.interval,
108
+ kind: "transition",
109
+ node: placement.node,
110
+ outgoingElement,
111
+ }));
112
+ return Object.freeze({
113
+ dispose: bound.dispose,
96
114
  element,
97
- source: this.#videoSource(placement),
98
- };
115
+ incomingElement,
116
+ outgoingElement,
117
+ });
99
118
  }
100
119
  bindOverlay(placement) {
101
120
  if (placement.kind === "caption") {
@@ -157,7 +176,13 @@ function collectAuthoredNodes(document, plan) {
157
176
  const browserVideoShots = new Set(plan.videos.map(({ shotId }) => shotId));
158
177
  for (const scene of semanticChildren(film, ELEMENTS.scene)) {
159
178
  indexed.push(scene);
160
- for (const shot of semanticChildren(scene, ELEMENTS.shot)) {
179
+ const structure = semanticChildren(scene, `${ELEMENTS.shot}, ${ELEMENTS.transition}`);
180
+ for (const element of structure) {
181
+ if (element.matches(ELEMENTS.transition)) {
182
+ indexed.push(element);
183
+ continue;
184
+ }
185
+ const shot = element;
161
186
  indexed.push(shot);
162
187
  // Browser Plans assign dense semantic preorder IDs. The shot just
163
188
  // admitted is therefore the parent ID carried by projected videos.
@@ -170,6 +195,9 @@ function collectAuthoredNodes(document, plan) {
170
195
  }
171
196
  return Object.freeze({ film, elements: Object.freeze(indexed) });
172
197
  }
198
+ function requiredNodeId(nodes, nodeId, role, selector) {
199
+ return requiredNode(nodes, { nodeId }, role, selector);
200
+ }
173
201
  function requiredNode(nodes, node, role, selector) {
174
202
  const element = nodes.elements[node.nodeId];
175
203
  if (element === undefined) {
@@ -205,6 +233,132 @@ function bindElement(element, node, release) {
205
233
  },
206
234
  };
207
235
  }
236
+ function bindVideoElement(element, node, source) {
237
+ const bound = bindElement(element, node);
238
+ const authoredVisibility = element.style.visibility;
239
+ let layoutVisible = false;
240
+ return {
241
+ element,
242
+ source,
243
+ setVisible: bound.setVisible,
244
+ setLayoutVisible(visible) {
245
+ if (visible) {
246
+ element.style.visibility = "hidden";
247
+ bound.setVisible(true);
248
+ }
249
+ else {
250
+ bound.setVisible(false);
251
+ element.style.visibility = authoredVisibility;
252
+ }
253
+ layoutVisible = visible;
254
+ },
255
+ measureLayout() {
256
+ if (!layoutVisible) {
257
+ throw new Error("video layout requires a visible layout-only element");
258
+ }
259
+ return measureVideoLayout(element, node.nodeId);
260
+ },
261
+ dispose() {
262
+ bound.setVisible(false);
263
+ element.style.visibility = authoredVisibility;
264
+ bound.dispose();
265
+ },
266
+ };
267
+ }
268
+ function measureVideoLayout(element, nodeId) {
269
+ requireStaticVideoStyle(element);
270
+ const rectangle = pixelRectangle(element.getBoundingClientRect());
271
+ const style = getComputedStyle(element);
272
+ return Object.freeze({
273
+ nodeId,
274
+ objectFit: objectFit(style.objectFit),
275
+ objectPosition: objectPosition(style.objectPosition),
276
+ rectangle,
277
+ });
278
+ }
279
+ function requireStaticVideoStyle(element) {
280
+ const style = getComputedStyle(element);
281
+ const unsupported = [
282
+ ["border-radius", style.borderRadius, "0px"],
283
+ ["filter", style.filter, "none"],
284
+ ["mix-blend-mode", style.mixBlendMode, "normal"],
285
+ ["opacity", style.opacity, "1"],
286
+ ["transform", style.transform, "none"],
287
+ ];
288
+ for (const [name, actual, expected] of unsupported) {
289
+ if (actual !== expected) {
290
+ throw new Error(`layout-only video requires ${name}: ${expected}, found ${actual}`);
291
+ }
292
+ }
293
+ for (const name of [
294
+ "borderBottomWidth",
295
+ "borderLeftWidth",
296
+ "borderRightWidth",
297
+ "borderTopWidth",
298
+ "paddingBottom",
299
+ "paddingLeft",
300
+ "paddingRight",
301
+ "paddingTop",
302
+ ]) {
303
+ if (style[name] !== "0px") {
304
+ throw new Error(`layout-only video requires ${cssName(name)}: 0`);
305
+ }
306
+ }
307
+ if (style.clipPath !== "none") {
308
+ throw new Error("layout-only video requires clip-path: none");
309
+ }
310
+ }
311
+ function pixelRectangle(rectangle) {
312
+ const values = [rectangle.x, rectangle.y, rectangle.width, rectangle.height];
313
+ if (devicePixelRatio !== 1 ||
314
+ !values.every(Number.isSafeInteger) ||
315
+ rectangle.x < 0 ||
316
+ rectangle.y < 0 ||
317
+ rectangle.width <= 0 ||
318
+ rectangle.height <= 0 ||
319
+ rectangle.right > document.documentElement.clientWidth ||
320
+ rectangle.bottom > document.documentElement.clientHeight) {
321
+ throw new Error("layout-only video requires a positive, pixel-aligned viewport rectangle");
322
+ }
323
+ return Object.freeze({
324
+ height: rectangle.height,
325
+ width: rectangle.width,
326
+ x: rectangle.x,
327
+ y: rectangle.y,
328
+ });
329
+ }
330
+ function objectFit(value) {
331
+ if (value === "fill" || value === "contain" || value === "cover") {
332
+ return value;
333
+ }
334
+ throw new Error(`layout-only video does not support object-fit: ${value}`);
335
+ }
336
+ function objectPosition(value) {
337
+ const [x, y, ...rest] = value.trim().split(/\s+/u);
338
+ if (x === undefined || y === undefined || rest.length > 0) {
339
+ throw new Error("layout-only video requires two percentage positions");
340
+ }
341
+ return Object.freeze({
342
+ x: percentageMillionths(x),
343
+ y: percentageMillionths(y),
344
+ });
345
+ }
346
+ function percentageMillionths(value) {
347
+ const match = /^(?<whole>[0-9]{1,3})(?:\.(?<fraction>[0-9]{1,4}))?%$/u.exec(value);
348
+ if (match?.groups === undefined) {
349
+ throw new Error("layout-only video object-position must use exact percentages");
350
+ }
351
+ const whole = Number(match.groups["whole"]);
352
+ const fraction = Number((match.groups["fraction"] ?? "").padEnd(4, "0"));
353
+ const millionths = whole * 10_000 + fraction;
354
+ if (millionths > 1_000_000) {
355
+ throw new Error("layout-only video object-position must lie between 0% and 100%");
356
+ }
357
+ return millionths;
358
+ }
359
+ function cssName(value) {
360
+ return value.replace(/[A-Z]/gu, (character) => `-${character.toLowerCase()}`);
361
+ }
208
362
  function requireAuthoredId(element, node) {
209
363
  const expected = node.authoredId ?? "";
210
364
  if (element.id !== expected) {
@@ -1 +1 @@
1
- export type { PresentationExtension, PresentationExtensionContext, PresentationTarget, PresentationTargetKind, } from "./motion.js";
1
+ export type { PresentationExtension, PresentationExtensionContext, PresentationElementTarget, PresentationElementTargetKind, PresentationTarget, PresentationTargetKind, PresentationTransitionTarget, } from "./motion.js";
@@ -1,16 +1,18 @@
1
+ import type { PresentationVisualCapability } from "../../../runtime/dist/src/index.js";
1
2
  import { type HtmlImageResource } from "./html_image.js";
2
3
  /** Authored document bytes and optional inline module prepared for esbuild. */
3
4
  export interface AuthoredHtml {
4
5
  readonly document: string;
5
6
  readonly motion: string | undefined;
7
+ readonly visualCapability: PresentationVisualCapability | undefined;
6
8
  readonly resources: readonly HtmlImageResource[];
7
- readonly regions: readonly AuthoredHtmlRegion[];
9
+ readonly shots: readonly AuthoredHtmlShot[];
8
10
  readonly regionStructure: AuthoredHtmlRegionStructure;
9
11
  readonly resolveDirectory: string;
10
12
  readonly runtimeOffset: number;
11
13
  }
12
- /** One shot selection within the shared authored-document structure. */
13
- export interface AuthoredHtmlRegion {
14
+ /** One dense screenplay-order shot within the authored document. */
15
+ export interface AuthoredHtmlShot {
14
16
  readonly scene: number;
15
17
  readonly shot: number;
16
18
  }
@@ -20,9 +22,19 @@ export interface AuthoredHtmlRegionStructure {
20
22
  }
21
23
  export interface AuthoredHtmlScene {
22
24
  readonly range: SourceRange;
23
- readonly shots: readonly SourceRange[];
25
+ readonly shots: readonly AuthoredHtmlShotRange[];
26
+ readonly transitions: readonly AuthoredHtmlTransitionRange[];
24
27
  }
25
- /** One materialized shot-scoped browser document. */
28
+ export interface AuthoredHtmlShotRange {
29
+ readonly index: number;
30
+ readonly range: SourceRange;
31
+ }
32
+ export interface AuthoredHtmlTransitionRange {
33
+ readonly incomingShot: number;
34
+ readonly outgoingShot: number;
35
+ readonly range: SourceRange;
36
+ }
37
+ /** One materialized render-region browser document. */
26
38
  export interface AuthoredHtmlRegionDocument {
27
39
  readonly document: string;
28
40
  readonly runtimeOffset: number;
@@ -33,8 +45,8 @@ export declare class AuthoredHtmlError extends Error {
33
45
  }
34
46
  /** Reads one bounded UTF-8 document and isolates its motion module. */
35
47
  export declare function readAuthoredHtml(path: string, maxOutputBytes: number, resolveDirectory?: string): Promise<AuthoredHtml>;
36
- /** Materializes one shot projection without retaining every projected document. */
37
- export declare function projectShotDocument(html: AuthoredHtml, region: AuthoredHtmlRegion): AuthoredHtmlRegionDocument;
48
+ /** Materializes one Rust-planned region without retaining every projection. */
49
+ export declare function projectRegionDocument(html: AuthoredHtml, shotIndices: readonly number[]): AuthoredHtmlRegionDocument;
38
50
  export interface SourceRange {
39
51
  readonly end: number;
40
52
  readonly start: number;
@@ -6,6 +6,7 @@ import { parse } from "parse5";
6
6
  import { freezeHtmlImages } from "./html_image.js";
7
7
  const MAX_HTML_BYTES = 8 * 1024 * 1024;
8
8
  const MOTION_ATTRIBUTE = "data-om-motion";
9
+ const VISUAL_CAPABILITY_META = "onmark:visual-capability";
9
10
  const RUNTIME_SCRIPT = '<script type="module" src="./presentation.js"></script>';
10
11
  /** Invalid or unreadable authored HTML at the Node bundling boundary. */
11
12
  export class AuthoredHtmlError extends Error {
@@ -18,6 +19,7 @@ export class AuthoredHtmlError extends Error {
18
19
  export async function readAuthoredHtml(path, maxOutputBytes, resolveDirectory) {
19
20
  const absolute = resolve(path);
20
21
  const source = await readBoundedSource(absolute);
22
+ const visualCapability = declaredVisualCapability(parseDocument(source));
21
23
  const directory = resolveDirectory === undefined
22
24
  ? dirname(absolute)
23
25
  : resolve(resolveDirectory);
@@ -28,6 +30,7 @@ export async function readAuthoredHtml(path, maxOutputBytes, resolveDirectory) {
28
30
  ...extracted,
29
31
  resources: frozen.resources,
30
32
  resolveDirectory: directory,
33
+ visualCapability,
31
34
  });
32
35
  }
33
36
  // ── Bounded input
@@ -119,39 +122,81 @@ function projectShotStructure(source) {
119
122
  const film = findElement(document, "om-film");
120
123
  if (film === undefined) {
121
124
  return {
122
- regions: Object.freeze([]),
125
+ shots: Object.freeze([]),
123
126
  regionStructure: Object.freeze({ scenes: Object.freeze([]) }),
124
127
  };
125
128
  }
126
- const scenes = childElements(film, "om-scene").map((scene) => {
127
- const shots = childElements(scene, "om-shot").map((shot) => removableElementRange(source, shot));
128
- return Object.freeze({
129
- range: removableElementRange(source, scene),
130
- shots: Object.freeze(shots),
131
- });
132
- });
133
- const regions = scenes.flatMap((scene, sceneIndex) => scene.shots.map((_, shotIndex) => Object.freeze({ scene: sceneIndex, shot: shotIndex })));
129
+ const shots = [];
130
+ const scenes = childElements(film, "om-scene").map((scene, sceneIndex) => projectSceneStructure(source, scene, sceneIndex, shots));
134
131
  return {
135
- regions: Object.freeze(regions),
132
+ shots: Object.freeze(shots),
136
133
  regionStructure: Object.freeze({ scenes: Object.freeze(scenes) }),
137
134
  };
138
135
  }
139
- /** Materializes one shot projection without retaining every projected document. */
140
- export function projectShotDocument(html, region) {
141
- const selectedScene = html.regionStructure.scenes[region.scene];
142
- const selectedShot = selectedScene?.shots[region.shot];
143
- if (selectedScene === undefined || selectedShot === undefined) {
144
- throw new AuthoredHtmlError("browser projection selects an unknown shot");
136
+ function projectSceneStructure(source, scene, sceneIndex, documentShots) {
137
+ const shots = [];
138
+ const transitions = [];
139
+ let outgoingShot;
140
+ let pendingTransition;
141
+ for (const element of directElements(scene)) {
142
+ if (element.tagName === "om-transition") {
143
+ if (outgoingShot === undefined || pendingTransition !== undefined) {
144
+ throw transitionProjectionError();
145
+ }
146
+ pendingTransition = removableElementRange(source, element);
147
+ continue;
148
+ }
149
+ if (element.tagName !== "om-shot") {
150
+ continue;
151
+ }
152
+ const index = documentShots.length;
153
+ const shot = Object.freeze({ scene: sceneIndex, shot: shots.length });
154
+ documentShots.push(shot);
155
+ shots.push(Object.freeze({
156
+ index,
157
+ range: removableElementRange(source, element),
158
+ }));
159
+ if (pendingTransition !== undefined && outgoingShot !== undefined) {
160
+ transitions.push(Object.freeze({
161
+ incomingShot: index,
162
+ outgoingShot,
163
+ range: pendingTransition,
164
+ }));
165
+ pendingTransition = undefined;
166
+ }
167
+ outgoingShot = index;
168
+ }
169
+ if (pendingTransition !== undefined) {
170
+ throw transitionProjectionError();
145
171
  }
172
+ return Object.freeze({
173
+ range: removableElementRange(source, scene),
174
+ shots: Object.freeze(shots),
175
+ transitions: Object.freeze(transitions),
176
+ });
177
+ }
178
+ function transitionProjectionError() {
179
+ return new AuthoredHtmlError("compiled transition boundaries cannot be projected into browser regions");
180
+ }
181
+ /** Materializes one Rust-planned region without retaining every projection. */
182
+ export function projectRegionDocument(html, shotIndices) {
183
+ const selectedShots = selectedShotSet(html, shotIndices);
146
184
  const ranges = [];
147
- for (const [sceneIndex, scene] of html.regionStructure.scenes.entries()) {
148
- if (sceneIndex !== region.scene) {
185
+ for (const scene of html.regionStructure.scenes) {
186
+ if (!scene.shots.some((shot) => selectedShots.has(shot.index))) {
149
187
  ranges.push(scene.range);
150
188
  continue;
151
189
  }
152
- for (const [shotIndex, shot] of scene.shots.entries()) {
153
- if (shotIndex !== region.shot) {
154
- ranges.push(shot);
190
+ for (const shot of scene.shots) {
191
+ if (!selectedShots.has(shot.index)) {
192
+ ranges.push(shot.range);
193
+ }
194
+ }
195
+ for (const transition of scene.transitions) {
196
+ const retained = selectedShots.has(transition.outgoingShot) &&
197
+ selectedShots.has(transition.incomingShot);
198
+ if (!retained) {
199
+ ranges.push(transition.range);
155
200
  }
156
201
  }
157
202
  }
@@ -160,6 +205,16 @@ export function projectShotDocument(html, region) {
160
205
  runtimeOffset: projectOffset(html.runtimeOffset, ranges),
161
206
  });
162
207
  }
208
+ function selectedShotSet(html, shotIndices) {
209
+ const selected = new Set();
210
+ for (const index of shotIndices) {
211
+ if (html.shots[index] === undefined) {
212
+ throw new AuthoredHtmlError(`browser projection selects unknown shot ${index}`);
213
+ }
214
+ selected.add(index);
215
+ }
216
+ return selected;
217
+ }
163
218
  function parseDocument(source) {
164
219
  const errors = [];
165
220
  const document = parse(source, {
@@ -215,6 +270,33 @@ function collectScripts(node) {
215
270
  });
216
271
  return scripts;
217
272
  }
273
+ function declaredVisualCapability(document) {
274
+ const declarations = [];
275
+ visit(document, (element) => {
276
+ if (element.tagName !== "meta") {
277
+ return;
278
+ }
279
+ const attributes = new Map(element.attrs.map(({ name, value }) => [name, value]));
280
+ if (attributes.get("name") === VISUAL_CAPABILITY_META) {
281
+ declarations.push(attributes.get("content") ?? "");
282
+ }
283
+ });
284
+ if (declarations.length > 1) {
285
+ throw new AuthoredHtmlError(`authored HTML may contain at most one ${VISUAL_CAPABILITY_META} declaration`);
286
+ }
287
+ const [declaration] = declarations;
288
+ if (declaration === undefined) {
289
+ return undefined;
290
+ }
291
+ switch (declaration) {
292
+ case "browserComposite":
293
+ case "separableBackdrop":
294
+ case "separableOverlay":
295
+ return declaration;
296
+ default:
297
+ throw new AuthoredHtmlError(`${VISUAL_CAPABILITY_META} must be browserComposite, separableBackdrop, or separableOverlay`);
298
+ }
299
+ }
218
300
  function findElement(node, name) {
219
301
  const pending = [node];
220
302
  while (pending.length > 0) {
@@ -234,6 +316,9 @@ function findElement(node, name) {
234
316
  function childElements(parent, name) {
235
317
  return parent.childNodes.filter((child) => "tagName" in child && child.tagName === name);
236
318
  }
319
+ function directElements(parent) {
320
+ return parent.childNodes.filter((child) => "tagName" in child);
321
+ }
237
322
  function visit(node, visitor) {
238
323
  const pending = [node];
239
324
  while (pending.length > 0) {
@@ -328,9 +413,12 @@ function isCompilerOnlyAttribute(tagName, attributeName) {
328
413
  case "om-title":
329
414
  return attributeName === "cue" || attributeName === "delay";
330
415
  case "om-shot":
416
+ case "om-transition":
331
417
  return attributeName === "duration";
332
418
  case "video":
333
419
  return (attributeName === "delay" ||
420
+ attributeName === "hold-last" ||
421
+ attributeName === "plays" ||
334
422
  attributeName === "speed" ||
335
423
  attributeName === "src" ||
336
424
  attributeName === "trim");
@@ -0,0 +1,9 @@
1
+ import type { BundleProjection } from "./generated/bundle-projection.js";
2
+ /** Invalid or unreadable projection data at the bundler process boundary. */
3
+ export declare class BundleProjectionError extends Error {
4
+ constructor(message: string, options?: ErrorOptions);
5
+ }
6
+ /** Reads and validates one bounded Rust-owned projection snapshot. */
7
+ export declare function readBundleProjection(path: string): Promise<BundleProjection>;
8
+ /** Decodes one checked projection at an in-process or file boundary. */
9
+ export declare function decodeBundleProjection(value: unknown): BundleProjection;
@@ -0,0 +1,66 @@
1
+ // Bounded reader for the Rust-owned render-region projection contract.
2
+ // It validates process input before authored HTML is projected or published.
3
+ import { open } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { validateBundleProjection } from "./generated/bundle-projection-validator.js";
6
+ const MAX_PROJECTION_BYTES = 1024 * 1024;
7
+ /** Invalid or unreadable projection data at the bundler process boundary. */
8
+ export class BundleProjectionError extends Error {
9
+ constructor(message, options) {
10
+ super(message, options);
11
+ this.name = "BundleProjectionError";
12
+ }
13
+ }
14
+ /** Reads and validates one bounded Rust-owned projection snapshot. */
15
+ export async function readBundleProjection(path) {
16
+ const absolute = resolve(path);
17
+ let file;
18
+ try {
19
+ file = await open(absolute, "r");
20
+ }
21
+ catch (error) {
22
+ throw new BundleProjectionError(`cannot open bundle projection ${absolute}`, { cause: error });
23
+ }
24
+ try {
25
+ const bytes = await readBoundedProjection(file);
26
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
27
+ return decodeBundleProjection(value);
28
+ }
29
+ catch (error) {
30
+ if (error instanceof BundleProjectionError) {
31
+ throw error;
32
+ }
33
+ throw new BundleProjectionError(`cannot read bundle projection ${absolute}`, { cause: error });
34
+ }
35
+ finally {
36
+ await file.close();
37
+ }
38
+ }
39
+ /** Decodes one checked projection at an in-process or file boundary. */
40
+ export function decodeBundleProjection(value) {
41
+ if (!validateBundleProjection(value)) {
42
+ throw invalidProjection();
43
+ }
44
+ return value;
45
+ }
46
+ async function readBoundedProjection(file) {
47
+ const bytes = Buffer.allocUnsafe(MAX_PROJECTION_BYTES + 1);
48
+ let length = 0;
49
+ while (length < bytes.length) {
50
+ const { bytesRead } = await file.read(bytes, length, bytes.length - length, length);
51
+ if (bytesRead === 0) {
52
+ break;
53
+ }
54
+ length += bytesRead;
55
+ }
56
+ if (length > MAX_PROJECTION_BYTES) {
57
+ throw new BundleProjectionError(`bundle projection exceeds the ${MAX_PROJECTION_BYTES}-byte limit`);
58
+ }
59
+ return bytes.subarray(0, length);
60
+ }
61
+ function invalidProjection() {
62
+ const error = validateBundleProjection.errors?.[0];
63
+ const path = error?.instancePath ?? "";
64
+ const detail = error?.message ?? "does not match its schema";
65
+ return new BundleProjectionError(`bundle projection${path} ${detail}`.trim());
66
+ }
@@ -3,21 +3,23 @@
3
3
  import process from "node:process";
4
4
  import { parseArgs } from "node:util";
5
5
  import { BundleError, bundlePresentation, } from "./presentation.js";
6
+ import { BundleProjectionError, readBundleProjection, } from "./bundle_projection.js";
6
7
  import { BUNDLE_FRAME_BEHAVIORS, BUNDLE_TEMPORAL_CAPABILITIES, BUNDLE_VISUAL_CAPABILITIES, } from "./generated/bundle-manifest.js";
7
8
  const USAGE = [
8
9
  "Usage: onmark-bundle",
9
10
  " --html <path>",
10
11
  " [--resolve-directory <directory>]",
12
+ " [--projection <path> # required for randomAccess]",
11
13
  " --output <directory>",
12
14
  " --max-output-bytes <bytes>",
13
15
  " --frame-behavior <perFrame|placementBounded>",
14
16
  " --temporal-capability <sequential|randomAccess>",
15
- " --visual-capability <browserComposite|separableOverlay>",
17
+ " [--visual-capability <browserComposite|separableBackdrop|separableOverlay>]",
16
18
  "",
17
19
  ].join("\n");
18
20
  // ── Execution ──
19
21
  try {
20
- const command = parseArguments(process.argv.slice(2));
22
+ const command = await parseArguments(process.argv.slice(2));
21
23
  if (command.kind === "help") {
22
24
  process.stdout.write(USAGE);
23
25
  }
@@ -31,7 +33,7 @@ catch (error) {
31
33
  process.exitCode = failure.kind === "configuration" ? 2 : 1;
32
34
  }
33
35
  // ── Arguments ──
34
- function parseArguments(arguments_) {
36
+ async function parseArguments(arguments_) {
35
37
  const values = commandValues(arguments_);
36
38
  if (values.help !== undefined) {
37
39
  if (arguments_.length !== 1) {
@@ -43,21 +45,30 @@ function parseArguments(arguments_) {
43
45
  const maxOutputBytes = parseByteLimit(oneValue(values["max-output-bytes"], "--max-output-bytes"));
44
46
  const frameBehavior = parseFrameBehavior(oneValue(values["frame-behavior"], "--frame-behavior"));
45
47
  const temporalCapability = parseTemporalCapability(oneValue(values["temporal-capability"], "--temporal-capability"));
46
- const visualCapability = parseVisualCapability(oneValue(values["visual-capability"], "--visual-capability"));
48
+ const visualCapability = optionalVisualCapability(values["visual-capability"]);
47
49
  const controls = {
48
50
  frameBehavior,
49
51
  maxOutputBytes,
50
52
  outputDirectory,
51
- temporalCapability,
52
- visualCapability,
53
+ ...visualCapability,
53
54
  };
55
+ const common = {
56
+ ...controls,
57
+ document: oneValue(values.html, "--html"),
58
+ ...optionalResolveDirectory(values["resolve-directory"]),
59
+ };
60
+ if (temporalCapability === "sequential") {
61
+ rejectValue(values.projection, "--projection requires randomAccess");
62
+ return {
63
+ kind: "bundle",
64
+ options: { ...common, temporalCapability },
65
+ };
66
+ }
67
+ const projectionPath = oneValue(values.projection, "--projection");
68
+ const projection = await readBundleProjection(projectionPath);
54
69
  return {
55
70
  kind: "bundle",
56
- options: {
57
- ...controls,
58
- document: oneValue(values.html, "--html"),
59
- ...optionalResolveDirectory(values["resolve-directory"]),
60
- },
71
+ options: { ...common, projection, temporalCapability },
61
72
  };
62
73
  }
63
74
  function commandValues(arguments_) {
@@ -71,6 +82,7 @@ function commandValues(arguments_) {
71
82
  html: { type: "string", multiple: true },
72
83
  "max-output-bytes": { type: "string", multiple: true },
73
84
  output: { type: "string", multiple: true },
85
+ projection: { type: "string", multiple: true },
74
86
  "resolve-directory": { type: "string", multiple: true },
75
87
  "temporal-capability": { type: "string", multiple: true },
76
88
  "visual-capability": { type: "string", multiple: true },
@@ -95,12 +107,16 @@ function parseTemporalCapability(value) {
95
107
  }
96
108
  throw configuration("--temporal-capability must be sequential or randomAccess");
97
109
  }
98
- function parseVisualCapability(value) {
110
+ function optionalVisualCapability(values) {
111
+ if (values === undefined) {
112
+ return {};
113
+ }
114
+ const value = oneValue(values, "--visual-capability");
99
115
  const capability = BUNDLE_VISUAL_CAPABILITIES.find((candidate) => candidate === value);
100
116
  if (capability !== undefined) {
101
- return capability;
117
+ return { visualCapability: capability };
102
118
  }
103
- throw configuration("--visual-capability must be browserComposite or separableOverlay");
119
+ throw configuration("--visual-capability must be browserComposite, separableBackdrop, or separableOverlay");
104
120
  }
105
121
  function parseFrameBehavior(value) {
106
122
  const behavior = BUNDLE_FRAME_BEHAVIORS.find((candidate) => candidate === value);
@@ -119,6 +135,11 @@ function oneValue(values, name) {
119
135
  }
120
136
  return value;
121
137
  }
138
+ function rejectValue(values, message) {
139
+ if (values !== undefined) {
140
+ throw configuration(message);
141
+ }
142
+ }
122
143
  function parseByteLimit(value) {
123
144
  const bytes = Number(value);
124
145
  if (!Number.isSafeInteger(bytes) || bytes <= 0) {
@@ -134,5 +155,8 @@ function commandFailure(error) {
134
155
  if (error instanceof BundleError) {
135
156
  return error;
136
157
  }
158
+ if (error instanceof BundleProjectionError) {
159
+ return configuration(error.message, error);
160
+ }
137
161
  return new BundleError("output", "unexpected bundler failure", error);
138
162
  }