@opencraw/studio 0.0.7 → 0.0.9

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 (62) hide show
  1. package/README.md +31 -5
  2. package/dist/index.esm.js +3114 -694
  3. package/dist/src/browser-provisioning/ensure-browser-launch.use-case.d.ts +12 -0
  4. package/dist/src/browser-provisioning/index.d.ts +2 -0
  5. package/dist/src/browser-provisioning/install-chromium.use-case.d.ts +2 -0
  6. package/dist/src/browser-provisioning/missing-browser.policy.d.ts +3 -0
  7. package/dist/src/document-view/deck-view.mapper.d.ts +70 -0
  8. package/dist/src/document-view/index.d.ts +11 -0
  9. package/dist/src/document-view/pdf-view.mapper.d.ts +47 -0
  10. package/dist/src/document-view/table-preview.use-case.d.ts +152 -0
  11. package/dist/src/document-view/tree-view.mapper.d.ts +47 -0
  12. package/dist/src/document-view/workbook-view.mapper.d.ts +51 -0
  13. package/dist/src/flow-recording/action-to-step.mapper.d.ts +16 -0
  14. package/dist/src/flow-recording/index.d.ts +13 -0
  15. package/dist/src/flow-recording/next-link.policy.d.ts +27 -0
  16. package/dist/src/flow-recording/recorded-action.contract.d.ts +114 -0
  17. package/dist/src/flow-recording/recorder-script.client.d.ts +48 -0
  18. package/dist/src/flow-recording/recorder-session.use-case.d.ts +67 -0
  19. package/dist/src/flow-recording/secret-field.policy.d.ts +33 -0
  20. package/dist/src/flow-recording/wait-insertion.policy.d.ts +26 -0
  21. package/dist/src/index.d.ts +2 -2
  22. package/dist/src/page-inspector/dom-tree.mapper.d.ts +32 -0
  23. package/dist/src/page-inspector/index.d.ts +7 -0
  24. package/dist/src/page-inspector/page-data.algorithm.d.ts +39 -0
  25. package/dist/src/page-inspector/responses-seen.use-case.d.ts +36 -0
  26. package/dist/src/page-snapshot/index.d.ts +3 -0
  27. package/dist/src/page-snapshot/pdf-bytes.use-case.d.ts +16 -0
  28. package/dist/src/page-snapshot/reread-csv.use-case.d.ts +22 -0
  29. package/dist/src/page-snapshot/take-snapshot.use-case.d.ts +26 -1
  30. package/dist/src/studio-api/command.contract.d.ts +300 -0
  31. package/dist/src/studio-api/deck-preview-view.contract.d.ts +28 -0
  32. package/dist/src/studio-api/deck-view.contract.d.ts +127 -0
  33. package/dist/src/studio-api/document-tree-view.contract.d.ts +26 -0
  34. package/dist/src/studio-api/event.contract.d.ts +45 -0
  35. package/dist/src/studio-api/grid-preview-view.contract.d.ts +26 -0
  36. package/dist/src/studio-api/grid-view.contract.d.ts +75 -0
  37. package/dist/src/studio-api/index.d.ts +22 -4
  38. package/dist/src/studio-api/inspect-view.contract.d.ts +62 -0
  39. package/dist/src/studio-api/pdf-view.contract.d.ts +66 -0
  40. package/dist/src/studio-api/responses-seen-view.contract.d.ts +20 -0
  41. package/dist/src/studio-api/snapshot-view.contract.d.ts +22 -1
  42. package/dist/src/studio-api/table-preview-view.contract.d.ts +65 -0
  43. package/dist/src/studio-server/deck-preview.handler.d.ts +17 -0
  44. package/dist/src/studio-server/deck-view.handler.d.ts +15 -0
  45. package/dist/src/studio-server/document-tree.handler.d.ts +14 -0
  46. package/dist/src/studio-server/grid-preview.handler.d.ts +17 -0
  47. package/dist/src/studio-server/grid-view.handler.d.ts +20 -0
  48. package/dist/src/studio-server/inspect-page.handler.d.ts +18 -0
  49. package/dist/src/studio-server/pdf-bytes.handler.d.ts +17 -0
  50. package/dist/src/studio-server/pdf-view.handler.d.ts +14 -0
  51. package/dist/src/studio-server/recording.handler.d.ts +33 -0
  52. package/dist/src/studio-server/responses-seen.handler.d.ts +16 -0
  53. package/dist/src/studio-server/studio-http.use-case.d.ts +3 -1
  54. package/dist/src/studio-server/table-preview.handler.d.ts +16 -0
  55. package/dist/src/studio-server/workspace.store.d.ts +6 -2
  56. package/dist/ui/assets/index-CiZ4i-q5.js +34 -0
  57. package/dist/ui/assets/pdf-De9LBx2x.js +56 -0
  58. package/dist/ui/assets/pdf.worker.min-BmVo14Nb.mjs +28 -0
  59. package/dist/ui/assets/pdf.worker.min-D7i8o3wR.js +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +3 -2
  62. package/dist/ui/assets/index-DV8i4Zza.js +0 -13
package/dist/index.esm.js CHANGED
@@ -1,11 +1,16 @@
1
- import { normalize, sep, join, extname, dirname, resolve } from 'node:path';
1
+ import { join, dirname, normalize, sep, extname, resolve } from 'node:path';
2
2
  import { createServer } from 'node:http';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'zod';
5
+ import { BODY_KINDS, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, rowsOfCells, parseXml, findDeckTables, findGridTables, analyzeTables, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, isWorkbookDocument, countMatches, matchesOf, tryParseJson, RecipeBindingError, RecipeValidationError, isPdfDocument, BrowserProfiles } from '@opencraw/core';
5
6
  import { randomBytes, createHash } from 'node:crypto';
6
- import { readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, countMatches, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
7
+ import { execFile } from 'node:child_process';
8
+ import { createRequire } from 'node:module';
9
+ import { promisify } from 'node:util';
7
10
  import { load } from 'cheerio';
8
- import { writeFile } from 'node:fs/promises';
11
+ import { readFile, writeFile } from 'node:fs/promises';
12
+ import { describeJson } from '@opencraw/probe';
13
+ import { tmpdir } from 'node:os';
9
14
  import { existsSync, createReadStream } from 'node:fs';
10
15
 
11
16
  const sentencePartSchema = z.object({
@@ -119,6 +124,151 @@ const inferSelectorCommandSchema = z.object({
119
124
  path: z.string().min(1),
120
125
  nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
121
126
  });
127
+ /**
128
+ * The Inspect panel's DOM tree and data-in-the-page findings, off the same
129
+ * cached snapshot `take-snapshot`/`infer-selector` already use (`page-inspector`,
130
+ * issue #93). Call `take-snapshot` first, same as `infer-selector`.
131
+ */
132
+ const inspectPageCommandSchema = z.object({
133
+ type: z.literal('inspect-page'),
134
+ recipeId: z.string().min(1),
135
+ path: z.string().min(1)
136
+ });
137
+ /**
138
+ * The JSON responses seen while a recipe's start page rendered
139
+ * (`page-inspector`'s `responses-seen.use-case.ts`, issue #93), for the
140
+ * Inspect panel's "responses seen" list. Opens its own browser session
141
+ * (see that use-case's doc comment for why); slower than `inspect-page`.
142
+ */
143
+ const responsesSeenCommandSchema = z.object({
144
+ type: z.literal('responses-seen'),
145
+ recipeId: z.string().min(1),
146
+ path: z.string().min(1)
147
+ });
148
+ /**
149
+ * The document tree for the content pane's tree canvas (`document-view`'s
150
+ * `tree-view.mapper.ts`, studio plan §3.4, issue #94's 5a), off the same
151
+ * cached snapshot `take-snapshot`/`inspect-page` already use — call
152
+ * `take-snapshot` first, same as `inspect-page`. Only a JSON/YAML/XML
153
+ * snapshot has one; PDF, workbook and deck documents get their own canvases
154
+ * (5b/5c/5d).
155
+ */
156
+ const documentTreeCommandSchema = z.object({
157
+ type: z.literal('document-tree'),
158
+ recipeId: z.string().min(1),
159
+ path: z.string().min(1)
160
+ });
161
+ /**
162
+ * The PDF canvas's cells and rows (`document-view`'s `pdf-view.mapper.ts`,
163
+ * studio plan §3.4, issue #94's 5b), off the same cached snapshot
164
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
165
+ * Only a PDF snapshot has one; JSON/YAML/XML get the tree canvas (5a), the
166
+ * workbook and deck their own canvases (5c/5d).
167
+ */
168
+ const pdfViewCommandSchema = z.object({
169
+ type: z.literal('pdf-view'),
170
+ recipeId: z.string().min(1),
171
+ path: z.string().min(1)
172
+ });
173
+ /** A `table` extract's options, as the studio's PDF canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `TablePreviewOptions`. */
174
+ const tablePreviewOptionsSchema = z.object({
175
+ header: z.string().min(1),
176
+ until: z.string().min(1).optional(),
177
+ columns: z.record(z.string(), z.string().min(1)).optional(),
178
+ align: z.enum(['auto', 'top', 'center', 'bottom']).optional()
179
+ });
180
+ /**
181
+ * The live preview of a `table` extract's options against a cached PDF
182
+ * snapshot (`document-view`'s `table-preview.use-case.ts`, studio plan §3.4,
183
+ * issue #94's 5b): the matched rows to highlight on the page, recomputed as
184
+ * the person edits the options — call `take-snapshot` first.
185
+ */
186
+ const tablePreviewCommandSchema = z.object({
187
+ type: z.literal('table-preview'),
188
+ recipeId: z.string().min(1),
189
+ path: z.string().min(1),
190
+ options: tablePreviewOptionsSchema
191
+ });
192
+ /**
193
+ * The grid canvas's sheets and cells (`document-view`'s `workbook-view.mapper.ts`,
194
+ * studio plan §3.4, issue #94's 5c), off the same cached snapshot
195
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
196
+ * Only a CSV/spreadsheet snapshot has one; JSON/YAML/XML get the tree canvas
197
+ * (5a), PDF the PDF canvas (5b), the deck its own canvas (5d).
198
+ *
199
+ * `delimiter`/`encoding` override a CSV's auto-detected format (issue #94's
200
+ * 5c: "the detected delimiter and encoding shown and overridable"); the
201
+ * server re-reads the file with the override applied and keeps it cached, so
202
+ * a later `grid-preview` sees the same reading. Both are rejected on a
203
+ * spreadsheet snapshot (there is nothing to detect).
204
+ */
205
+ const gridViewCommandSchema = z.object({
206
+ type: z.literal('grid-view'),
207
+ recipeId: z.string().min(1),
208
+ path: z.string().min(1),
209
+ delimiter: z.string().min(1).optional(),
210
+ encoding: z.string().min(1).optional()
211
+ });
212
+ /** A `table` extract's options for a workbook, as the studio's grid canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `GridTablePreviewOptions`. */
213
+ const gridPreviewOptionsSchema = z.object({
214
+ sheet: z.string().min(1).optional(),
215
+ header: z.string().min(1),
216
+ until: z.string().min(1).optional(),
217
+ columns: z.record(z.string(), z.string().min(1)).optional(),
218
+ headerRows: z.int().min(1).optional(),
219
+ fillDown: z.array(z.string().min(1)).min(1).optional(),
220
+ includeHidden: z.boolean().optional()
221
+ });
222
+ /**
223
+ * The live preview of a `table` extract's options against a cached CSV/
224
+ * spreadsheet snapshot (`document-view`'s `table-preview.use-case.ts`'s
225
+ * `previewGridTable`, studio plan §3.4, issue #94's 5c): the matched
226
+ * table(s), recomputed as the person edits the options — call `take-snapshot`
227
+ * and `grid-view` first.
228
+ */
229
+ const gridPreviewCommandSchema = z.object({
230
+ type: z.literal('grid-preview'),
231
+ recipeId: z.string().min(1),
232
+ path: z.string().min(1),
233
+ options: gridPreviewOptionsSchema
234
+ });
235
+ /**
236
+ * The deck canvas's slides, shapes, tables, charts and notes for a deck
237
+ * (PowerPoint) snapshot (`document-view`'s `deck-view.mapper.ts`, studio plan
238
+ * §3.4, issue #94's 5d), off the same cached snapshot
239
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
240
+ * Only a `.pptx` snapshot has one; JSON/YAML/XML get the tree canvas (5a),
241
+ * PDF the PDF canvas (5b), the workbook the grid canvas (5c).
242
+ */
243
+ const deckViewCommandSchema = z.object({
244
+ type: z.literal('deck-view'),
245
+ recipeId: z.string().min(1),
246
+ path: z.string().min(1)
247
+ });
248
+ /** A `table` extract's options for a deck, as the studio's deck canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `DeckTablePreviewOptions`. */
249
+ const deckPreviewOptionsSchema = z.object({
250
+ slide: z.string().min(1).optional(),
251
+ shapes: z.boolean().optional(),
252
+ align: z.enum(['auto', 'top', 'center', 'bottom']).optional(),
253
+ header: z.string().min(1),
254
+ until: z.string().min(1).optional(),
255
+ columns: z.record(z.string(), z.string().min(1)).optional(),
256
+ headerRows: z.int().min(1).optional(),
257
+ fillDown: z.array(z.string().min(1)).min(1).optional(),
258
+ includeHidden: z.boolean().optional()
259
+ });
260
+ /**
261
+ * The live preview of a `table` extract's options against a cached deck
262
+ * snapshot (`document-view`'s `table-preview.use-case.ts`'s `previewDeckTable`,
263
+ * studio plan §3.4, issue #94's 5d): the matched table(s), recomputed as the
264
+ * person edits the options — call `take-snapshot` and `deck-view` first.
265
+ */
266
+ const deckPreviewCommandSchema = z.object({
267
+ type: z.literal('deck-preview'),
268
+ recipeId: z.string().min(1),
269
+ path: z.string().min(1),
270
+ options: deckPreviewOptionsSchema
271
+ });
122
272
  /** A missing value in an emitted record, by its position in the last sample run's records (the order they streamed in, matching the UI's own `records` list). */
123
273
  const missingWhyTargetSchema = z.object({
124
274
  kind: z.literal('missing'),
@@ -144,13 +294,40 @@ const explainWhyCommandSchema = z.object({
144
294
  type: z.literal('explain-why'),
145
295
  target: whyTargetSchema
146
296
  });
147
- const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema]);
297
+ /**
298
+ * Opens the studio's own headed browser window and starts recording user
299
+ * actions on it (issue #95, phase 6): clicks, fills, selects, key presses
300
+ * and scrolls stream back as `recording-card` events, each already a
301
+ * verified step. `recipeId`'s start point is where the window opens.
302
+ */
303
+ const startRecordingCommandSchema = z.object({
304
+ type: z.literal('start-recording'),
305
+ recipeId: z.string().min(1)
306
+ });
307
+ /**
308
+ * Closes the recording window, if one is open; a no-op otherwise. Answers
309
+ * with every step recorded, in order, so the caller can offer "make this
310
+ * the login" (writes them under `session.bootstrap`) or "keep as steps"
311
+ * (appends them to the recipe's own `steps`) without a second round trip.
312
+ */
313
+ const stopRecordingCommandSchema = z.object({
314
+ type: z.literal('stop-recording')
315
+ });
316
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, gridViewCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
148
317
 
149
- /** What `take-snapshot` answers with: the rewritten document ready for the content pane's sandboxed iframe (studio plan §3.1, issue #91). */
318
+ /**
319
+ * What `take-snapshot` answers with: the rewritten document ready for the
320
+ * content pane's sandboxed iframe (studio plan §3.1, issue #91). `format`
321
+ * (api mode only; absent in web mode, whose document is always HTML) is
322
+ * what the content pane uses to choose a canvas — the snapshot iframe for
323
+ * `html` (and `docx`/`markdown`, already turned into HTML), else
324
+ * `document-view`'s tree/pdf/grid/deck canvas (studio plan §3.4, issue #94).
325
+ */
150
326
  const snapshotViewSchema = z.object({
151
327
  html: z.string(),
152
328
  nodeCount: z.number(),
153
- baseUrl: z.string()
329
+ baseUrl: z.string(),
330
+ format: z.enum(BODY_KINDS).optional()
154
331
  });
155
332
 
156
333
  /**
@@ -253,7 +430,32 @@ const runFinishedEventSchema = z.object({
253
430
  const workspaceChangedEventSchema = z.object({
254
431
  type: z.literal('workspace-changed')
255
432
  });
256
- const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
433
+ /**
434
+ * One recorded action landed as a step (issue #95): the card it becomes
435
+ * (`scope-outline`'s own `OutlineCard`/`OutlineBracket` shape — the wire
436
+ * form `outline-view.contract.ts` already defines, never a second card
437
+ * format) and whether its value is a secret placeholder, for the secret
438
+ * pill styling.
439
+ */
440
+ const recordingCardEventSchema = z.object({
441
+ type: z.literal('recording-card'),
442
+ node: outlineNodeSchema,
443
+ secret: z.boolean()
444
+ });
445
+ /** A notice from the recording session that is not itself a step: a "next" link offer, or an unsupported iframe/shadow-DOM report. */
446
+ const recordingNoteEventSchema = z.object({
447
+ type: z.literal('recording-note'),
448
+ kind: z.enum(['next-link', 'unsupported', 'info']),
449
+ message: z.string()
450
+ });
451
+ /** One recorded step's raw JSON, exactly as `flow-recording` built it. */
452
+ const recordedStepSchema = z.record(z.string(), z.unknown());
453
+ /** The recording window closed — `stop-recording` completed, or the window was closed some other way. `steps` is every step recorded, in order, exactly as `stop-recording`'s own response carries them. */
454
+ const recordingStoppedEventSchema = z.object({
455
+ type: z.literal('recording-stopped'),
456
+ steps: z.array(recordedStepSchema)
457
+ });
458
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema]);
257
459
 
258
460
  /** One problem found on a recipe file, with the JSON path it lives at. */
259
461
  const recipeIssueSchema = z.object({
@@ -315,6 +517,236 @@ const whyViewSchema = z.object({
315
517
  reason: z.string().optional()
316
518
  });
317
519
 
520
+ const domTreeNodeViewSchema = z.lazy(() => z.object({
521
+ nodeId: z.string(),
522
+ tag: z.string(),
523
+ id: z.string().optional(),
524
+ classes: z.array(z.string()).optional(),
525
+ attributes: z.record(z.string(), z.string()),
526
+ text: z.string().optional(),
527
+ hidden: z.boolean(),
528
+ children: z.array(domTreeNodeViewSchema),
529
+ repeatCount: z.number().int().optional(),
530
+ repeatNodeIds: z.array(z.string()).optional()
531
+ }));
532
+ const pageDataKindSchema = z.enum(['ld-json', 'json-script', 'inline-state', 'meta', 'link']);
533
+ /** One "data in the page" entry — mirrors `page-inspector/page-data.algorithm.ts`'s `PageDataFinding`. */
534
+ const pageDataFindingSchema = z.object({
535
+ kind: pageDataKindSchema,
536
+ label: z.string(),
537
+ selector: z.string(),
538
+ selectorKind: z.literal('css'),
539
+ matches: z.number(),
540
+ keys: z.array(z.string()).optional(),
541
+ attribute: z.string().optional()
542
+ });
543
+ /** What `inspect-page` answers with (studio plan §3.3, issue #93): the DOM tree and the data-in-the-page findings, in one round trip — both come from the same cached snapshot/raw capture, so there is no reason to ask twice. */
544
+ const inspectViewSchema = z.object({
545
+ tree: domTreeNodeViewSchema,
546
+ pageData: z.array(pageDataFindingSchema)
547
+ });
548
+
549
+ /** One JSON response seen — mirrors `page-inspector/responses-seen.use-case.ts`'s `ObservedResponse`. */
550
+ const observedResponseSchema = z.object({
551
+ url: z.string(),
552
+ status: z.number(),
553
+ size: z.number(),
554
+ shape: z.string()
555
+ });
556
+ /** What `responses-seen` answers with (studio plan §3.3, issue #93): the JSON responses the page fetched while it rendered, for the panel's "responses seen" list. */
557
+ const responsesSeenViewSchema = z.object({
558
+ responses: z.array(observedResponseSchema)
559
+ });
560
+
561
+ const documentTreeNodeViewSchema = z.lazy(() => z.object({
562
+ id: z.string(),
563
+ label: z.string(),
564
+ valueType: z.enum(['object', 'array', 'string', 'number', 'boolean', 'null', 'element']),
565
+ preview: z.string().optional(),
566
+ jsonpath: z.string().optional(),
567
+ xpath: z.string().optional(),
568
+ listPath: z.string().optional(),
569
+ listCount: z.number().optional(),
570
+ attributes: z.record(z.string(), z.string()).optional(),
571
+ children: z.array(documentTreeNodeViewSchema)
572
+ }));
573
+ /** What `document-tree` answers with (studio plan §3.4, issue #94's 5a): the parsed JSON/XML document as a tree, and, for XML, the namespace prefixes a pick's `extract` can use in `namespaces`. */
574
+ const documentTreeViewSchema = z.object({
575
+ format: z.enum(['json', 'xml']),
576
+ root: documentTreeNodeViewSchema,
577
+ namespaces: z.record(z.string(), z.string()).optional()
578
+ });
579
+
580
+ /** One text cell, in PDF points — mirrors `document-view/pdf-view.mapper.ts`'s `PdfCellView`, the wire shape for the UI (studio plan §3.4, issue #94's 5b). */
581
+ const pdfCellViewSchema = z.object({
582
+ x: z.number(),
583
+ y: z.number(),
584
+ width: z.number(),
585
+ height: z.number(),
586
+ text: z.string()
587
+ });
588
+ const pdfRowViewSchema = z.object({
589
+ top: z.number(),
590
+ bottom: z.number(),
591
+ cells: z.array(pdfCellViewSchema)
592
+ });
593
+ const pdfPageViewSchema = z.object({
594
+ number: z.number().int(),
595
+ width: z.number(),
596
+ height: z.number(),
597
+ rows: z.array(pdfRowViewSchema),
598
+ rowCount: z.number().int(),
599
+ cellCount: z.number().int(),
600
+ hasTextLayer: z.boolean()
601
+ });
602
+ /** What `pdf-view` answers with (studio plan §3.4, issue #94's 5b): every page's cells and rows, for the PDF canvas to draw over its own `pdf.js` rendering. */
603
+ const pdfDocumentViewSchema = z.object({
604
+ pages: z.array(pdfPageViewSchema)
605
+ });
606
+
607
+ const tablePreviewRowSchema = z.record(z.string(), z.string());
608
+ /** One table matched by a `table` extract's options — mirrors `@opencraw/core`'s `PdfTable`. */
609
+ const tablePreviewTableSchema = z.object({
610
+ page: z.number().int(),
611
+ title: z.string(),
612
+ header: z.array(z.string()),
613
+ rows: z.array(tablePreviewRowSchema)
614
+ });
615
+ /** One column band a matched table's body clustered into — mirrors `@opencraw/core`'s `TableBandDiagnostics`. */
616
+ const tablePreviewBandSchema = z.object({
617
+ start: z.number(),
618
+ end: z.number(),
619
+ column: z.number().int(),
620
+ name: z.string()
621
+ });
622
+ /** One matched table, with the exact page rows to highlight — mirrors `document-view/table-preview.use-case.ts`'s `TablePreviewMatch`. */
623
+ const tablePreviewMatchSchema = z.object({
624
+ page: z.number().int(),
625
+ headerRowIndex: z.number().int(),
626
+ matchedRowIndices: z.array(z.number().int()),
627
+ bands: z.array(tablePreviewBandSchema),
628
+ bandTolerance: z.number(),
629
+ table: tablePreviewTableSchema
630
+ });
631
+ /**
632
+ * What `table-preview` answers with (studio plan §3.4, issue #94's 5b: the
633
+ * live preview): every match, or `error` when a pattern in the options does
634
+ * not compile — expected while the person is still typing it, not thrown.
635
+ */
636
+ const tablePreviewViewSchema = z.object({
637
+ matches: z.array(tablePreviewMatchSchema),
638
+ error: z.string().optional()
639
+ });
640
+
641
+ /** One cell's value, and the type the grid shows it as — mirrors `document-view/workbook-view.mapper.ts`'s `GridCellView`, the wire shape for the UI (studio plan §3.4, issue #94's 5c). */
642
+ const gridCellViewSchema = z.object({
643
+ value: z.union([z.string(), z.number(), z.boolean()]),
644
+ type: z.enum(['string', 'number', 'boolean', 'date'])
645
+ });
646
+ /** One merged range, 0-based and inclusive. */
647
+ const gridMergeViewSchema = z.object({
648
+ ref: z.string(),
649
+ top: z.number().int(),
650
+ left: z.number().int(),
651
+ bottom: z.number().int(),
652
+ right: z.number().int()
653
+ });
654
+ const gridSheetViewSchema = z.object({
655
+ name: z.string(),
656
+ hidden: z.boolean(),
657
+ hiddenRows: z.array(z.number().int()),
658
+ columnCount: z.number().int(),
659
+ rows: z.array(z.array(gridCellViewSchema)),
660
+ merges: z.array(gridMergeViewSchema)
661
+ });
662
+ const csvFormatViewSchema = z.object({
663
+ encoding: z.string(),
664
+ delimiter: z.string()
665
+ });
666
+ /** What `grid-view` answers with (studio plan §3.4, issue #94's 5c): every sheet's cells, typed, with hidden sheets/rows marked and merged ranges resolved, for the grid canvas to draw. */
667
+ const workbookDocumentViewSchema = z.object({
668
+ sheets: z.array(gridSheetViewSchema),
669
+ csv: csvFormatViewSchema.optional()
670
+ });
671
+
672
+ const gridPreviewRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
673
+ /** One table matched by a `table` extract's options against a workbook — mirrors `@opencraw/core`'s `GridTable`. */
674
+ const gridTablePreviewMatchSchema = z.object({
675
+ sheet: z.string(),
676
+ title: z.string(),
677
+ header: z.array(z.string()),
678
+ rows: z.array(gridPreviewRowSchema)
679
+ });
680
+ /**
681
+ * What `grid-preview` answers with (studio plan §3.4, issue #94's 5c: the
682
+ * live preview): every matched table, or `error` when a pattern in the
683
+ * options does not compile — expected while the person is still typing it,
684
+ * not thrown.
685
+ */
686
+ const gridTablePreviewViewSchema = z.object({
687
+ matches: z.array(gridTablePreviewMatchSchema),
688
+ error: z.string().optional()
689
+ });
690
+
691
+ /** One text box on a slide, in points — mirrors `document-view/deck-view.mapper.ts`'s `DeckShapeView`, the wire shape for the UI (studio plan §3.4, issue #94's 5d). */
692
+ const deckShapeViewSchema = z.object({
693
+ x: z.number(),
694
+ y: z.number(),
695
+ width: z.number(),
696
+ height: z.number(),
697
+ text: z.string(),
698
+ placeholder: z.string().optional()
699
+ });
700
+ const deckChartValueSchema = z.union([z.number(), z.null()]);
701
+ const deckChartSeriesViewSchema = z.object({
702
+ name: z.string(),
703
+ categories: z.array(z.string()),
704
+ values: z.array(deckChartValueSchema)
705
+ });
706
+ const deckChartViewSchema = z.object({
707
+ type: z.string(),
708
+ title: z.string().optional(),
709
+ series: z.array(deckChartSeriesViewSchema)
710
+ });
711
+ const deckShapeRowSchema = z.array(z.number().int());
712
+ const deckSlideViewSchema = z.object({
713
+ number: z.number().int(),
714
+ title: z.string().optional(),
715
+ hidden: z.boolean(),
716
+ shapes: z.array(deckShapeViewSchema),
717
+ /** Indices into `shapes`, grouped into visual rows — see `deck-view.mapper.ts`'s own doc comment. */
718
+ shapeRows: z.array(deckShapeRowSchema),
719
+ tables: z.array(gridSheetViewSchema),
720
+ charts: z.array(deckChartViewSchema),
721
+ notes: z.string()
722
+ });
723
+ /** What `deck-view` answers with (studio plan §3.4, issue #94's 5d): the slide size in points and every slide's shapes, tables, charts and notes, for the deck canvas to draw and pick from. */
724
+ const deckDocumentViewSchema = z.object({
725
+ width: z.number(),
726
+ height: z.number(),
727
+ slides: z.array(deckSlideViewSchema)
728
+ });
729
+
730
+ const deckPreviewRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
731
+ /** One table matched by a `table` extract's options against a deck — mirrors `@opencraw/core`'s `DeckTable`. */
732
+ const deckTablePreviewMatchSchema = z.object({
733
+ slide: z.number().int(),
734
+ slideTitle: z.string(),
735
+ title: z.string(),
736
+ header: z.array(z.string()),
737
+ rows: z.array(deckPreviewRowSchema)
738
+ });
739
+ /**
740
+ * What `deck-preview` answers with (studio plan §3.4, issue #94's 5d: the
741
+ * live preview): every matched table, or `error` when a pattern in the
742
+ * options does not compile — expected while the person is still typing it,
743
+ * not thrown.
744
+ */
745
+ const deckTablePreviewViewSchema = z.object({
746
+ matches: z.array(deckTablePreviewMatchSchema),
747
+ error: z.string().optional()
748
+ });
749
+
318
750
  /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
319
751
  const TOKEN_COOKIE = 'opencraw_token';
320
752
  /** A random token, generated once at server startup and required on every request. */
@@ -357,128 +789,1113 @@ function cookieToken(header) {
357
789
  return undefined;
358
790
  }
359
791
 
792
+ const execFileAsync = promisify(execFile);
793
+ /** `playwright`'s own `exports` map doesn't whitelist a `./cli` subpath, so its CLI is resolved off `playwright/package.json`'s real location — the same technique `@opencraw/captcha-tesseract`'s `bundledLangPath()` uses for a bundled data path. */
794
+ function playwrightCliPath() {
795
+ const require = createRequire(import.meta.url);
796
+ return join(dirname(require.resolve('playwright/package.json')), 'cli.js');
797
+ }
798
+ async function runPlaywrightInstall() {
799
+ await execFileAsync(process.execPath, [playwrightCliPath(), 'install', 'chromium']);
800
+ }
801
+ let installing;
360
802
  /**
361
- * Finds and parses one input recipe of a workspace folder, and the output
362
- * recipe it names.
363
- *
364
- * @param folder - The workspace folder.
365
- * @param recipeId - The input recipe's id.
366
- * @returns The parsed pair.
367
- * @throws RecipeValidationError when either recipe does not parse.
368
- * @throws Error when the input or its output is not in the folder.
803
+ * Runs `playwright install chromium` at most once per process. A failed install clears the memo,
804
+ * so the next browser launch attempt retries instead of remembering failure forever.
369
805
  */
370
- async function loadRecipePair(folder, recipeId) {
371
- const documents = await readRecipeSource(folder);
372
- const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
373
- if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
374
- const input = parseInputRecipe(inputDocument.content, inputDocument.source);
375
- const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
376
- if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
377
- return {
378
- input,
379
- output: parseOutputRecipe(outputDocument.content, outputDocument.source)
380
- };
806
+ async function runOnce(run) {
807
+ try {
808
+ await run();
809
+ console.log('@opencraw/studio: Chromium installed.');
810
+ } catch (error) {
811
+ installing = undefined;
812
+ console.log('@opencraw/studio: "playwright install chromium" failed.');
813
+ throw error;
814
+ }
381
815
  }
382
- function kindOf(content) {
383
- return content?.kind;
816
+ function installChromiumOnce(run = runPlaywrightInstall) {
817
+ if (installing === undefined) {
818
+ console.log('@opencraw/studio: Playwright\'s Chromium is not downloaded yet — running "playwright install chromium" once...');
819
+ installing = runOnce(run);
820
+ }
821
+ return installing;
384
822
  }
385
- function idOf(content) {
386
- return content?.id;
823
+
824
+ /** Recognises Playwright's own "no browser downloaded" failure so a launch can be retried after installing one, instead of surfacing Playwright's raw ASCII-art error. */
825
+ function isMissingBrowserError(error) {
826
+ return error instanceof Error && /Executable doesn't exist at/.test(error.message);
387
827
  }
388
828
 
389
829
  /**
390
- * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
391
- * each `record:emit` carries its scope snapshot and mapping trace, and each
392
- * `record:reject` its scope), an in-memory sink, and `budget` as the
393
- * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
394
- * engine event becomes a trace line with the same `traceLine` formatter the
395
- * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
396
- * it happens, not only in the final result. Step events (`step:start`,
397
- * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
398
- * `explain-why` (#92) can later say whether the step that bound a mapping's
399
- * source id ran, was skipped, or retried — without a second run.
830
+ * Runs a browser-launching function, and — only when it fails with Playwright's own "no browser
831
+ * downloaded" error, only for a default chromium launch with no explicit binary configured — installs
832
+ * Chromium once and retries, instead of letting Playwright's raw error reach the studio's caller.
400
833
  *
401
- * @param folder - The workspace folder the recipe lives in.
402
- * @param recipeId - The input recipe to run.
403
- * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
404
- * @param callbacks - Where trace lines and records go as the run proceeds.
405
- * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
406
- * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
407
- * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
834
+ * An explicit `executablePath` or `OPENCRAW_CHROMIUM` means the person (or this repo's own sandbox)
835
+ * already chose a specific binary; a missing-browser failure there is their setup to fix, not
836
+ * something to silently install over.
408
837
  */
409
- async function runSample(folder, recipeId, budget, callbacks, browser) {
410
- const {
411
- input,
412
- output
413
- } = await loadRecipePair(folder, recipeId);
414
- const set = bindRecipeSet(output, [input]);
415
- const sink = memorySink();
416
- const records = [];
417
- const rejectedRecords = [];
418
- const steps = new Map();
419
- const crawler = createCrawler({
420
- sink,
421
- debug: true,
422
- sample: budget,
423
- browser,
424
- onEvent: event => {
425
- const line = traceLine(event);
426
- if (line !== undefined) callbacks.onTraceLine(line);
427
- trackStep(steps, event);
428
- if (event.type === 'record:emit') {
429
- const record = {
430
- key: event.key,
431
- data: event.data,
432
- scope: event.scope,
433
- mapping: event.mapping
434
- };
435
- records.push(record);
436
- callbacks.onRecord(record);
437
- } else if (event.type === 'record:reject') {
438
- const rejected = {
439
- field: event.field,
440
- reason: event.reason,
441
- url: event.url,
442
- scope: event.scope
443
- };
444
- rejectedRecords.push(rejected);
445
- callbacks.onRejected?.(rejected);
446
- }
838
+ async function ensureBrowserLaunch(browser, launch, installChromium = installChromiumOnce) {
839
+ try {
840
+ return await launch();
841
+ } catch (error) {
842
+ if (!isMissingBrowserError(error)) throw error;
843
+ if (browser?.executablePath !== undefined || process.env.OPENCRAW_CHROMIUM !== undefined) {
844
+ throw new Error('@opencraw/studio: no browser found at the configured executable path. Check it, or unset it and let the studio install Chromium itself.', {
845
+ cause: error
846
+ });
447
847
  }
448
- });
449
- const result = runToResult(crawler, set, records, rejectedRecords, steps);
450
- return {
451
- stop: () => crawler.close(),
452
- result
453
- };
848
+ if ((browser?.browserType ?? 'chromium') !== 'chromium') {
849
+ throw new Error(`@opencraw/studio: no ${browser?.browserType} browser found. Run "npx playwright install ${browser?.browserType}" and try again.`, {
850
+ cause: error
851
+ });
852
+ }
853
+ try {
854
+ await installChromium();
855
+ } catch (installError) {
856
+ throw new Error('@opencraw/studio: could not install Chromium automatically. Run "npx playwright install chromium" yourself and try again.', {
857
+ cause: installError
858
+ });
859
+ }
860
+ try {
861
+ return await launch();
862
+ } catch (retryError) {
863
+ if (!isMissingBrowserError(retryError)) throw retryError;
864
+ throw new Error('@opencraw/studio: Chromium was installed but is still not found. Run "npx playwright install chromium" yourself and try again.', {
865
+ cause: retryError
866
+ });
867
+ }
868
+ }
454
869
  }
870
+
455
871
  /**
456
- * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
457
- * earlier `ran`/`retried` outcome for the same path (a step skipped even
458
- * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
459
- * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
460
- * runs its body once per item, so the same path folds repeatedly; this is
461
- * deliberately a summary across the whole sample, not a per-record trace.
872
+ * Fetches an input recipe's start page as the engine would see it: a fresh
873
+ * Playwright page in web mode, core's HTTP client in api mode. Returns plain
874
+ * text: the page's HTML, or a readable rendering of a non-HTML api response.
875
+ * No rewriting, no script stripping, no node ids yet: that is phase 2's
876
+ * `page-snapshot` proper (studio plan, #91). Provisional for phase 0 in two
877
+ * ways, both worth knowing before relying on it: it opens a bare page
878
+ * without running the recipe's own steps first, so a start point behind a
879
+ * `session.bootstrap` login shows what an anonymous visitor sees; and it
880
+ * launches a fresh browser or HTTP context per call, one page then closes
881
+ * it, rather than reusing whatever the last sample run had open.
882
+ *
883
+ * @param input - The input recipe; only `mode` and the first `start` point are used.
884
+ * @param browser - Browser launch settings for web mode (an executable path override, headless…).
885
+ * @returns The page's content as plain text.
886
+ * @throws Error when the recipe has no start point.
462
887
  */
463
- function trackStep(steps, event) {
464
- if (event.type === 'step:start') {
465
- if (!steps.has(event.path)) steps.set(event.path, {
466
- path: event.path,
467
- stepType: event.stepType,
468
- stepId: event.stepId,
469
- outcome: 'ran'
470
- });
471
- return;
472
- }
473
- if (event.type === 'step:retry') {
474
- steps.set(event.path, {
475
- path: event.path,
476
- stepType: event.stepType,
477
- stepId: event.stepId,
478
- outcome: 'retried',
479
- error: event.error
480
- });
481
- return;
888
+ async function fetchStartPage(input, browser) {
889
+ const point = input.start[0];
890
+ if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
891
+ return input.mode === 'web' ? fetchWeb(point.url, browser) : fetchApi(point.url);
892
+ }
893
+ async function fetchWeb(url, browser) {
894
+ const client = await ensureBrowserLaunch(browser, () => BrowserClient.launch(browser));
895
+ try {
896
+ const session = await client.newSession();
897
+ try {
898
+ await session.page.goto(url);
899
+ return await session.page.content();
900
+ } finally {
901
+ await session.close();
902
+ }
903
+ } finally {
904
+ await client.close();
905
+ }
906
+ }
907
+ async function fetchApi(url) {
908
+ const client = await HttpClient.open({});
909
+ try {
910
+ const response = await client.send({
911
+ url
912
+ });
913
+ return textOf$2(response.body);
914
+ } finally {
915
+ await client.dispose();
916
+ }
917
+ }
918
+ /** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
919
+ function textOf$2(body) {
920
+ if (body.kind === 'html') return body.html;
921
+ if (body.kind === 'text') return body.text;
922
+ if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
923
+ return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
924
+ }
925
+
926
+ /** The attribute `hiddenMarksScript` stamps on an element hidden on the live page. */
927
+ const HIDDEN_ATTRIBUTE = 'data-oc-hidden';
928
+ /**
929
+ * The in-page script `take-snapshot.use-case.ts` runs with `page.evaluate`
930
+ * *before* `page.content()`, so a `display: none`/`visibility: hidden`/
931
+ * zero-size element carries `data-oc-hidden="1"` into the captured markup
932
+ * (studio plan §3.1, issue #91) — the only way to know an element was
933
+ * hidden on the live page, since a static parse of the HTML afterwards has
934
+ * no computed style to ask.
935
+ *
936
+ * Kept as a plain string, not a TypeScript function passed to
937
+ * `page.evaluate`, because it references `window`/`document` as globals: `AGENTS.md`'s
938
+ * "things that bit before" calls out that in-page functions must not do
939
+ * that (the repo's `isolated-functions` lint rule), and every existing
940
+ * `page.evaluate` call with real DOM logic in this codebase (see
941
+ * `packages/core/src/captcha/resolve-captcha.use-case.ts`) already uses
942
+ * this same string-template shape.
943
+ *
944
+ * @param attribute - The attribute to stamp; defaults to `HIDDEN_ATTRIBUTE` (parameterised for tests and for a future caller that wants a different name).
945
+ * @returns A self-invoking script, ready for `page.evaluate(hiddenMarksScript())`.
946
+ */
947
+ function hiddenMarksScript(attribute = HIDDEN_ATTRIBUTE) {
948
+ return `(() => {
949
+ var attribute = ${JSON.stringify(attribute)}
950
+ var elements = document.querySelectorAll('*')
951
+ for (var i = 0; i < elements.length; i++) {
952
+ var element = elements[i]
953
+ var style = window.getComputedStyle(element)
954
+ var rect = element.getBoundingClientRect()
955
+ var hidden = style.display === 'none' || style.visibility === 'hidden' || (rect.width === 0 && rect.height === 0)
956
+ if (hidden) element.setAttribute(attribute, '1')
957
+ }
958
+ })()`;
959
+ }
960
+
961
+ /** Attributes that carry a URL and are rewritten to absolute; `srcset` is handled separately (it carries several). */
962
+ const URL_ATTRIBUTES = ['href', 'src', 'action', 'poster'];
963
+ /** Schemes left untouched: they are not a location `new URL(value, base)` should resolve, or already carry their own meaning. */
964
+ const SKIP_SCHEME = /^(?:javascript|mailto|tel|data|about):/i;
965
+ /**
966
+ * Rewrites captured HTML into what the content pane's sandboxed iframe shows
967
+ * (studio plan §3.1, issue #91): every URL made absolute against `baseUrl`,
968
+ * a `<base>` injected, every `<script>` removed, forms disabled (`onsubmit`
969
+ * stripped, fields made read-only), `<meta http-equiv="refresh">` stripped
970
+ * (it would otherwise navigate the sandboxed frame on its own), and every
971
+ * element given a stable `data-oc-node` id, in document order, for the
972
+ * picker overlay and `selector-inference` to address specific nodes by.
973
+ *
974
+ * Purely a markup transform: it does not know whether an element is hidden
975
+ * on the live page (`display: none`, `visibility: hidden`, zero size) — in
976
+ * web mode that is `hidden-marks.algorithm.ts`'s job, run against the live
977
+ * page *before* this function ever sees the markup, so a `data-oc-hidden`
978
+ * attribute it wrote just survives serialisation untouched here.
979
+ *
980
+ * @param html - The captured document (`page.content()` in web mode, the fetched body in api mode).
981
+ * @param baseUrl - The page's own URL, for resolving relative links and as the `<base>`.
982
+ * @returns The rewritten document and how many elements it stamped.
983
+ */
984
+ function rewriteDocument(html, baseUrl) {
985
+ const $ = load(html);
986
+ if ($('head').length === 0) $('html').prepend('<head></head>');
987
+ const head = $('head').first();
988
+ head.find('base').remove();
989
+ head.prepend(`<base href="${escapeAttribute(baseUrl)}">`);
990
+ $('meta').each((_index, element) => {
991
+ if (($(element).attr('http-equiv') ?? '').toLowerCase() === 'refresh') $(element).remove();
992
+ });
993
+ $('script').remove();
994
+ $('form').removeAttr('onsubmit');
995
+ $('input, textarea').attr('readonly', 'readonly');
996
+ $('select, button, input[type="submit"], input[type="button"]').attr('disabled', 'disabled');
997
+ $('*').each((_index, element) => {
998
+ const node = $(element);
999
+ for (const attribute of URL_ATTRIBUTES) {
1000
+ const value = node.attr(attribute);
1001
+ if (value !== undefined) node.attr(attribute, resolveUrl(value, baseUrl) ?? value);
1002
+ }
1003
+ const srcset = node.attr('srcset');
1004
+ if (srcset !== undefined) node.attr('srcset', rewriteSrcset(srcset, baseUrl));
1005
+ });
1006
+ let nodeCount = 0;
1007
+ $('*').each((_index, element) => {
1008
+ $(element).attr(NODE_ID_ATTRIBUTE, `n${nodeCount}`);
1009
+ nodeCount += 1;
1010
+ });
1011
+ return {
1012
+ html: $.html(),
1013
+ nodeCount
1014
+ };
1015
+ }
1016
+ /** Resolves one URL against `baseUrl`; a scheme that is not a location (`javascript:`, `data:`…), or a value `new URL` cannot parse, is left as it was. */
1017
+ function resolveUrl(value, baseUrl) {
1018
+ const trimmed = value.trim();
1019
+ if (trimmed === '' || SKIP_SCHEME.test(trimmed)) return undefined;
1020
+ try {
1021
+ return new URL(trimmed, baseUrl).href;
1022
+ } catch {
1023
+ return undefined;
1024
+ }
1025
+ }
1026
+ /** `srcset`'s own micro-syntax: comma-separated `url descriptor?` pairs. Malformed entries are passed through unresolved rather than dropped. */
1027
+ function rewriteSrcset(value, baseUrl) {
1028
+ return value.split(',').map(candidate => {
1029
+ const trimmed = candidate.trim();
1030
+ const spaceAt = trimmed.indexOf(' ');
1031
+ const url = spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt);
1032
+ const descriptor = spaceAt === -1 ? '' : trimmed.slice(spaceAt);
1033
+ return `${resolveUrl(url, baseUrl) ?? url}${descriptor}`;
1034
+ }).join(', ');
1035
+ }
1036
+ function escapeAttribute(value) {
1037
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
1038
+ }
1039
+
1040
+ /**
1041
+ * Captures the snapshot the content pane shows (studio plan §3.1, issue
1042
+ * #91): runs the recipe up to `stepPath`, serialises the document, marks
1043
+ * hidden elements in web mode, and hands it to `rewrite-document.mapper.ts`
1044
+ * for display.
1045
+ *
1046
+ * `stepPath` is accepted (and cached against, by `snapshot.store.ts`) for
1047
+ * the API the UI needs — one snapshot per selected step — but v1 only
1048
+ * actually *captures* `"start"`: the first `start` point, before any step
1049
+ * of the recipe has run, the same reach `fetch-start-page.use-case.ts` (phase
1050
+ * 0) already had. Snapshotting mid-recipe (after an arbitrary later step)
1051
+ * needs a "stop the crawl after step X and hand back the live page/document"
1052
+ * hook in `crawl-execution` that does not exist yet; building one was out of
1053
+ * this phase's budget (see this phase's final report for the tradeoff this
1054
+ * was weighed against). Any other `stepPath` throws, clearly, rather than
1055
+ * silently returning the start page under a misleading label.
1056
+ *
1057
+ * @param input - The input recipe; only `mode` and the first `start` point are used.
1058
+ * @param stepPath - The step to snapshot after, e.g. `"start"` or `"steps.2"` (`scope-outline`'s path format).
1059
+ * @param browser - Browser launch settings for web mode.
1060
+ * @returns The rewritten snapshot.
1061
+ * @throws Error when the recipe has no start point, or `stepPath` is not `"start"`.
1062
+ */
1063
+ async function takeSnapshot(input, stepPath, browser) {
1064
+ if (stepPath !== 'start') throw new Error(`takeSnapshot: "${stepPath}" is not supported yet — only "start" (the first start point, before any step runs) is; see this function's doc comment`);
1065
+ const point = input.start[0];
1066
+ if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
1067
+ return input.mode === 'web' ? snapshotWeb(point.url, browser) : snapshotApi(point.url);
1068
+ }
1069
+ async function snapshotWeb(url, browser) {
1070
+ const client = await ensureBrowserLaunch(browser, () => BrowserClient.launch(browser));
1071
+ try {
1072
+ const session = await client.newSession();
1073
+ try {
1074
+ await session.page.goto(url);
1075
+ await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
1076
+ const html = await session.page.content();
1077
+ const baseUrl = session.page.url();
1078
+ const rewritten = rewriteDocument(html, baseUrl);
1079
+ return {
1080
+ ...rewritten,
1081
+ baseUrl,
1082
+ rawHtml: html
1083
+ };
1084
+ } finally {
1085
+ await session.close();
1086
+ }
1087
+ } finally {
1088
+ await client.close();
1089
+ }
1090
+ }
1091
+ async function snapshotApi(url) {
1092
+ const client = await HttpClient.open({});
1093
+ try {
1094
+ const response = await client.send({
1095
+ url
1096
+ });
1097
+ const text = textOf$1(response.body);
1098
+ const rewritten = rewriteDocument(text, url);
1099
+ return {
1100
+ ...rewritten,
1101
+ baseUrl: url,
1102
+ rawHtml: text,
1103
+ format: response.format,
1104
+ body: response.body
1105
+ };
1106
+ } finally {
1107
+ await client.dispose();
1108
+ }
1109
+ }
1110
+ /** Every body kind the studio can show as text today; others get a placeholder rather than a crash (mirrors `fetch-start-page.use-case.ts`'s own `textOf`). */
1111
+ function textOf$1(body) {
1112
+ if (body.kind === 'html') return body.html;
1113
+ if (body.kind === 'text') return body.text;
1114
+ if (body.kind === 'json') return `<pre>${JSON.stringify(body.data, null, 2)}</pre>`;
1115
+ return `<p>[${body.kind} document: the content pane shows HTML and JSON responses for now]</p>`;
1116
+ }
1117
+
1118
+ /** @returns An empty cache, for a freshly opened workspace. */
1119
+ function createSnapshotCache() {
1120
+ return {
1121
+ entries: new Map()
1122
+ };
1123
+ }
1124
+ /** @returns The cached snapshot for `recipeId`/`path`, or `undefined` when none was taken yet. */
1125
+ function cachedSnapshot(cache, recipeId, path) {
1126
+ return cache.entries.get(keyOf(recipeId, path));
1127
+ }
1128
+ /** Records a freshly taken snapshot, replacing whatever was cached for the same recipe and step path. */
1129
+ function putSnapshot(cache, recipeId, path, snapshot) {
1130
+ cache.entries.set(keyOf(recipeId, path), snapshot);
1131
+ }
1132
+ function keyOf(recipeId, path) {
1133
+ return `${recipeId}:${path}`;
1134
+ }
1135
+
1136
+ /**
1137
+ * Fetches a PDF's raw bytes for the PDF canvas's `pdf.js` to render in the
1138
+ * browser (studio plan §3.4, issue #94's 5b: "the PDF bytes served by the
1139
+ * studio"). `take-snapshot` already read this same document with
1140
+ * `@opencraw/core`'s `readPdf` for the cells/rows `document-view` maps, but
1141
+ * `HttpClient`/`readPdf` parse the bytes and keep only the result — pdf.js
1142
+ * needs the file itself, so this re-fetches it from the same URL a cached
1143
+ * snapshot's own `SnapshotResult.baseUrl` gives, the way `HttpClient`'s own
1144
+ * `file:` branch (`readLocalFile`) and its HTTP fetch do.
1145
+ *
1146
+ * @param url - The document's URL — a `file:` URL or an `http(s):` URL.
1147
+ * @returns The raw bytes.
1148
+ * @throws Error when a `file:` URL cannot be read, or the fetch does not succeed.
1149
+ */
1150
+ async function pdfBytes(url) {
1151
+ if (url.startsWith('file:')) return new Uint8Array(await readFile(fileURLToPath(url)));
1152
+ const response = await fetch(url);
1153
+ if (!response.ok) throw new Error(`pdfBytes: ${String(response.status)} fetching ${url}`);
1154
+ return new Uint8Array(await response.arrayBuffer());
1155
+ }
1156
+
1157
+ /**
1158
+ * Re-reads a CSV document with an overridden delimiter and/or encoding
1159
+ * (studio plan §3.4, issue #94's 5c). `take-snapshot` already read this same
1160
+ * document once with auto-detection (`http.client.ts`'s own `csvWorkbook`
1161
+ * call); it keeps only the parsed result, not the raw bytes, so re-reading
1162
+ * the file/URL is the only way to apply a different choice — the same
1163
+ * "the studio re-fetches, the reader keeps no bytes around" tradeoff
1164
+ * `pdf-bytes.use-case.ts` documents for pdf.js.
1165
+ *
1166
+ * @param url - The document's URL — a `file:` URL or an `http(s):` URL (`page-snapshot`'s cached `SnapshotResult.baseUrl`).
1167
+ * @param override - The delimiter and/or encoding to force; `HttpClient` still detects whichever one is left out.
1168
+ * @returns The freshly read workbook.
1169
+ * @throws Error when the URL cannot be read, or it no longer reads back as a workbook.
1170
+ */
1171
+ async function rereadCsv(url, override) {
1172
+ const client = await HttpClient.open({});
1173
+ try {
1174
+ const response = await client.send({
1175
+ url,
1176
+ as: 'csv',
1177
+ delimiter: override.delimiter,
1178
+ encoding: override.encoding
1179
+ });
1180
+ if (response.body.kind !== 'workbook') throw new Error(`rereadCsv: ${url} did not read back as a workbook`);
1181
+ return response.body;
1182
+ } finally {
1183
+ await client.dispose();
1184
+ }
1185
+ }
1186
+
1187
+ /** A cell reads as an ISO date (`2026-06-01`), an ISO moment (`2026-06-01T09:30:00`) or a bare time of day (`09:30:00`) — the three shapes `workbook-document`'s `read-xlsx.client.ts` writes for a spreadsheet date. */
1188
+ const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
1189
+ /**
1190
+ * Builds the grid canvas's view model (studio plan §3.4, issue #94's 5c) from
1191
+ * an already-read `WorkbookDocument` (`take-snapshot`'s cached body — no
1192
+ * second read of the file): every sheet's cells, typed, with hidden sheets
1193
+ * and rows marked, and merged ranges resolved to bounds so the grid draws
1194
+ * them as one cell. Table-specific diagnostics (the matched rows of the
1195
+ * current `table` extract's options) are not here — they depend on the
1196
+ * picks so far, so they come from `table-preview.use-case.ts`'s
1197
+ * `previewGridTable` instead, computed live as those options change.
1198
+ *
1199
+ * @param document - The read workbook (a spreadsheet or a CSV).
1200
+ * @returns The view.
1201
+ */
1202
+ function workbookDocumentView(document) {
1203
+ return {
1204
+ sheets: document.sheets.map(sheet => sheetView(sheet)),
1205
+ ...(document.csv !== undefined && {
1206
+ csv: document.csv
1207
+ })
1208
+ };
1209
+ }
1210
+ function sheetView(sheet) {
1211
+ return {
1212
+ name: sheet.name,
1213
+ hidden: sheet.hidden === true,
1214
+ hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
1215
+ columnCount: Math.max(0, ...sheet.rows.map(row => row.length)),
1216
+ rows: sheet.rows.map(row => row.map(cell => cellView(cell))),
1217
+ merges: (sheet.merges ?? []).flatMap(reference => {
1218
+ const range = rangeOf(reference);
1219
+ return range === undefined ? [] : [{
1220
+ ref: reference,
1221
+ ...range
1222
+ }];
1223
+ })
1224
+ };
1225
+ }
1226
+ function cellView(cell) {
1227
+ return {
1228
+ value: cell,
1229
+ type: cellType(cell)
1230
+ };
1231
+ }
1232
+ function cellType(cell) {
1233
+ if (typeof cell === 'number') return 'number';
1234
+ if (typeof cell === 'boolean') return 'boolean';
1235
+ return ISO_DATE_OR_TIME.test(cell) ? 'date' : 'string';
1236
+ }
1237
+ /** `B10:B13` (or a single cell, `A1`) as 0-based inclusive bounds; `undefined` for anything else — mirrors `@opencraw/core`'s own (unexported) `rangeOf`/`cellOf` in `grid-table.algorithm.ts`: a small pure parse, kept here rather than reached for across the package boundary (a sibling slice's non-`index.ts` file is never imported, and this is `@opencraw/core`'s own private helper, not even on its barrel). */
1238
+ function rangeOf(reference) {
1239
+ const [from, to = from] = reference.split(':', 2);
1240
+ const start = cellOf(from);
1241
+ const end = cellOf(to);
1242
+ if (start === undefined || end === undefined) return undefined;
1243
+ return {
1244
+ top: Math.min(start.row, end.row),
1245
+ left: Math.min(start.column, end.column),
1246
+ bottom: Math.max(start.row, end.row),
1247
+ right: Math.max(start.column, end.column)
1248
+ };
1249
+ }
1250
+ function cellOf(reference) {
1251
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
1252
+ if (match === null) return undefined;
1253
+ const letters = match[1].toUpperCase();
1254
+ let column = 0;
1255
+ for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
1256
+ return {
1257
+ row: Number(match[2]) - 1,
1258
+ column: column - 1
1259
+ };
1260
+ }
1261
+
1262
+ /**
1263
+ * Builds the deck canvas's view model (studio plan §3.4, issue #94's 5d)
1264
+ * from an already-read `DeckDocument` (`take-snapshot`'s cached body — no
1265
+ * second read of the file): every slide's shapes (with their visual-row
1266
+ * grouping, for the text-box-grid pick), native tables (through
1267
+ * `workbook-view.mapper.ts`'s own sheet view — a deck table *is* a sheet),
1268
+ * charts and notes. Table-specific diagnostics for a `table` extract's own
1269
+ * options (the live preview) are not here — they come from
1270
+ * `table-preview.use-case.ts`'s `previewDeckTable` instead, computed live as
1271
+ * those options change, the same split `pdf-view.mapper.ts`/`workbook-view.mapper.ts`
1272
+ * already use.
1273
+ *
1274
+ * @param document - The read deck.
1275
+ * @returns The view.
1276
+ */
1277
+ function deckDocumentView(document) {
1278
+ return {
1279
+ width: document.width,
1280
+ height: document.height,
1281
+ slides: document.slides.map(slide => slideView(slide, document.height))
1282
+ };
1283
+ }
1284
+ function slideView(slide, slideHeight) {
1285
+ return {
1286
+ number: slide.number,
1287
+ ...(slide.title !== undefined && {
1288
+ title: slide.title
1289
+ }),
1290
+ hidden: slide.hidden,
1291
+ shapes: slide.shapes.map(shape => shapeView(shape)),
1292
+ shapeRows: shapeRowsOf(slide.shapes, slideHeight),
1293
+ tables: workbookDocumentView({
1294
+ sheets: slide.tables
1295
+ }).sheets,
1296
+ charts: slide.charts.map(chart => chartView(chart)),
1297
+ notes: slide.notes
1298
+ };
1299
+ }
1300
+ function shapeView(shape) {
1301
+ return {
1302
+ x: shape.x,
1303
+ y: shape.y,
1304
+ width: shape.width,
1305
+ height: shape.height,
1306
+ text: shape.text,
1307
+ ...(shape.placeholder !== undefined && {
1308
+ placeholder: shape.placeholder
1309
+ })
1310
+ };
1311
+ }
1312
+ function chartView(chart) {
1313
+ return {
1314
+ type: chart.type,
1315
+ ...(chart.title !== undefined && {
1316
+ title: chart.title
1317
+ }),
1318
+ series: chart.series.map(series => ({
1319
+ name: series.name,
1320
+ categories: [...series.categories],
1321
+ values: [...series.values]
1322
+ }))
1323
+ };
1324
+ }
1325
+ /**
1326
+ * Groups a slide's shapes into visual rows the same way `deck-table.algorithm.ts`'s
1327
+ * own (core-internal) `shapeTables` does: each shape is one cell, `y`
1328
+ * flipped to the PDF-style bottom-up convention `rowsOfCells` (and the whole
1329
+ * table-reading pipeline it feeds) works in, so the grouping this returns is
1330
+ * byte-for-byte what `findDeckTables(document, { shapes: true })` groups at
1331
+ * run time — a pick built from it matches for real, not just visually.
1332
+ *
1333
+ * @param shapes - The slide's shapes, in reading order.
1334
+ * @param slideHeight - The deck's slide height, in points (`DeckDocument.height`).
1335
+ * @returns Each row's shape indices, left to right; a blank shape (whitespace-only text) is never part of a row.
1336
+ */
1337
+ function shapeRowsOf(shapes, slideHeight) {
1338
+ const indexOf = new Map();
1339
+ const cells = shapes.flatMap((shape, index) => {
1340
+ const text = shape.text.replaceAll(/\s+/g, ' ').trim();
1341
+ if (text === '') return [];
1342
+ const cell = {
1343
+ x: shape.x,
1344
+ y: slideHeight - shape.y - shape.height,
1345
+ width: shape.width,
1346
+ height: shape.height,
1347
+ text
1348
+ };
1349
+ indexOf.set(cell, index);
1350
+ return [cell];
1351
+ });
1352
+ return rowsOfCells(cells).map(row => row.cells.map(cell => indexOf.get(cell)));
1353
+ }
1354
+
1355
+ const TEXT_LIMIT$1 = 120;
1356
+ /**
1357
+ * Builds the document tree for `document-tree` (studio plan §3.4, issue
1358
+ * #94's 5a) from the body `page-snapshot`'s `take-snapshot.use-case.ts`
1359
+ * already fetched — no second read of the document.
1360
+ *
1361
+ * @param body - The parsed body (`HttpBody`); YAML and JSON Lines both
1362
+ * arrive here already normalised to `{ kind: 'json' }` by `http.client.ts`.
1363
+ * @returns The tree.
1364
+ * @throws Error when `body`'s kind is not `json` or `xml` (PDF, workbook and
1365
+ * deck documents get their own canvases — 5b/5c/5d, not this mapper).
1366
+ */
1367
+ function documentTreeView(body) {
1368
+ if (body.kind === 'json') return {
1369
+ format: 'json',
1370
+ root: jsonNode(body.data, '$', 'root')
1371
+ };
1372
+ if (body.kind === 'xml') return xmlTreeView(body.xml);
1373
+ throw new Error(`document-tree: "${body.kind}" has no tree canvas (only "json" and "xml" bodies do)`);
1374
+ }
1375
+ function listFields(path, context) {
1376
+ if (context === undefined) return {};
1377
+ return {
1378
+ listPath: context.generalPrefix + path.slice(context.itemPrefix.length),
1379
+ listCount: context.count
1380
+ };
1381
+ }
1382
+ function jsonNode(value, path, label, context) {
1383
+ if (Array.isArray(value)) {
1384
+ const children = value.map((item, index) => {
1385
+ const itemPath = `${path}[${index}]`;
1386
+ return jsonNode(item, itemPath, String(index), {
1387
+ itemPrefix: itemPath,
1388
+ generalPrefix: `${path}[*]`,
1389
+ count: value.length
1390
+ });
1391
+ });
1392
+ return {
1393
+ id: path,
1394
+ label,
1395
+ valueType: 'array',
1396
+ jsonpath: path,
1397
+ ...listFields(path, context),
1398
+ children
1399
+ };
1400
+ }
1401
+ if (value !== null && typeof value === 'object') {
1402
+ const entries = Object.entries(value);
1403
+ // A plain object passes its own context through unchanged: only an array (or, in XML, a repeated element) starts a new one.
1404
+ const children = entries.map(([key, item]) => jsonNode(item, `${path}${propertyAccess(key)}`, key, context));
1405
+ return {
1406
+ id: path,
1407
+ label,
1408
+ valueType: 'object',
1409
+ jsonpath: path,
1410
+ ...listFields(path, context),
1411
+ children
1412
+ };
1413
+ }
1414
+ return {
1415
+ id: path,
1416
+ label,
1417
+ valueType: leafType(value),
1418
+ jsonpath: path,
1419
+ preview: previewOf(value),
1420
+ ...listFields(path, context),
1421
+ children: []
1422
+ };
1423
+ }
1424
+ function leafType(value) {
1425
+ if (value === null) return 'null';
1426
+ if (typeof value === 'string') return 'string';
1427
+ if (typeof value === 'number') return 'number';
1428
+ if (typeof value === 'boolean') return 'boolean';
1429
+ return 'string';
1430
+ }
1431
+ function previewOf(value) {
1432
+ const text = value === null ? 'null' : typeof value === 'string' ? value : JSON.stringify(value);
1433
+ return text.length > TEXT_LIMIT$1 ? `${text.slice(0, TEXT_LIMIT$1)}…` : text;
1434
+ }
1435
+ /** A JSONPath property step: dot notation for a plain identifier, bracket notation (quoted) otherwise — jsonpath-plus accepts both, but only dot notation round-trips through a person reading the card. */
1436
+ function propertyAccess(key) {
1437
+ return /^[A-Z_]\w*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
1438
+ }
1439
+ // --- XML ---
1440
+ const DEFAULT_NS_PREFIX = 'ns';
1441
+ function xmlTreeView(xml) {
1442
+ const document = parseXml(xml, 'document.xml');
1443
+ const root = document.documentElement;
1444
+ if (root === null) throw new Error('document-tree: the XML document has no root element');
1445
+ const namespaces = declaredNamespaces(root);
1446
+ const tree = xmlNode(root, '', namespaces, undefined);
1447
+ return {
1448
+ format: 'xml',
1449
+ root: tree,
1450
+ ...(Object.keys(namespaces).length > 0 && {
1451
+ namespaces
1452
+ })
1453
+ };
1454
+ }
1455
+ /** Every `xmlns:*` the root declares, plus a synthesised prefix (`ns`, `ns2`, …) for a bare default `xmlns="…"` — XPath 1.0 has no default namespace, so a query needs a prefix even when the markup does not use one (mirrors `xpath.algorithm.ts`'s own `declaredPrefixes`, extended for the default case that function leaves out on purpose — it is resolved automatically there, but a *picked* card still needs to name it explicitly in `namespaces`). */
1456
+ function declaredNamespaces(root) {
1457
+ const namespaces = {};
1458
+ let synthetic = 0;
1459
+ for (const attribute of root.attributes) {
1460
+ if (attribute.prefix === 'xmlns') namespaces[attribute.localName] = attribute.value;else if (attribute.name === 'xmlns') {
1461
+ synthetic += 1;
1462
+ namespaces[synthetic === 1 ? DEFAULT_NS_PREFIX : `${DEFAULT_NS_PREFIX}${synthetic}`] = attribute.value;
1463
+ }
1464
+ }
1465
+ return namespaces;
1466
+ }
1467
+ function prefixFor(element, namespaces) {
1468
+ if (element.namespaceURI === null || element.namespaceURI === undefined) return undefined;
1469
+ if (element.prefix !== null && element.prefix !== undefined && element.prefix !== '') return element.prefix;
1470
+ return Object.entries(namespaces).find(([, uri]) => uri === element.namespaceURI)?.[0];
1471
+ }
1472
+ function qualifiedName(element, namespaces) {
1473
+ const prefix = prefixFor(element, namespaces);
1474
+ return prefix === undefined ? element.localName ?? element.tagName : `${prefix}:${element.localName ?? element.tagName}`;
1475
+ }
1476
+ function xmlNode(element, parentPath, namespaces, context) {
1477
+ const name = qualifiedName(element, namespaces);
1478
+ const siblingIndex = elementSiblingIndex(element);
1479
+ const siblingCount = elementSiblingCount(element);
1480
+ const step = siblingCount > 1 ? `${name}[${siblingIndex}]` : name;
1481
+ const path = `${parentPath}/${step}`;
1482
+ // I am one of several same-named siblings: my own listPath generalises myself, and my children inherit *my* context (innermost repetition wins) instead of whatever ancestor context I received.
1483
+ const selfContext = siblingCount > 1 ? {
1484
+ itemPrefix: path,
1485
+ generalPrefix: `${parentPath}/${name}`,
1486
+ count: siblingCount
1487
+ } : context;
1488
+ const childContext = siblingCount > 1 ? selfContext : context;
1489
+ const attributes = elementAttributes(element);
1490
+ const childElements = [...element.childNodes].filter(node => node.nodeType === 1);
1491
+ const children = [...Object.entries(attributes).map(([key, value]) => attributeNode(key, value, path, childContext)), ...childElements.map(child => xmlNode(child, path, namespaces, childContext))];
1492
+ const text = childElements.length === 0 ? textOf(element) : undefined;
1493
+ return {
1494
+ id: path,
1495
+ label: name,
1496
+ valueType: 'element',
1497
+ xpath: path,
1498
+ ...listFields(path, selfContext),
1499
+ ...(Object.keys(attributes).length > 0 && {
1500
+ attributes
1501
+ }),
1502
+ ...(text !== undefined && {
1503
+ preview: text,
1504
+ valueType: childElements.length === 0 ? 'string' : 'element'
1505
+ }),
1506
+ children
1507
+ };
1508
+ }
1509
+ function attributeNode(name, value, elementPath, context) {
1510
+ const path = `${elementPath}/@${name}`;
1511
+ return {
1512
+ id: path,
1513
+ label: `@${name}`,
1514
+ valueType: 'string',
1515
+ xpath: path,
1516
+ preview: previewOf(value),
1517
+ ...listFields(path, context),
1518
+ children: []
1519
+ };
1520
+ }
1521
+ function elementAttributes(element) {
1522
+ return Object.fromEntries([...element.attributes].filter(attribute => attribute.prefix !== 'xmlns' && attribute.name !== 'xmlns').map(attribute => [attribute.name, attribute.value]));
1523
+ }
1524
+ function elementSiblingIndex(element) {
1525
+ let index = 1;
1526
+ let sibling = element.previousSibling;
1527
+ while (sibling !== null) {
1528
+ if (sibling.nodeType === 1 && sibling.tagName === element.tagName) index += 1;
1529
+ sibling = sibling.previousSibling;
1530
+ }
1531
+ return index;
1532
+ }
1533
+ function elementSiblingCount(element) {
1534
+ const parent = element.parentNode;
1535
+ if (parent === null) return 1;
1536
+ return [...parent.childNodes].filter(node => node.nodeType === 1 && node.tagName === element.tagName).length;
1537
+ }
1538
+ function textOf(element) {
1539
+ const text = (element.textContent ?? '').trim().replaceAll(/\s+/g, ' ');
1540
+ return text === '' ? undefined : previewOf(text);
1541
+ }
1542
+
1543
+ /**
1544
+ * Builds the PDF canvas's view model (studio plan §3.4, issue #94's 5b) from
1545
+ * an already-read `PdfDocument` (`take-snapshot`'s cached body — no second
1546
+ * read of the file): every cell and row `readPdf` found, in PDF points, plus
1547
+ * per-page counts and whether the page has a text layer at all. Table-specific
1548
+ * diagnostics (the header row, the column bands, the wrapped-row regrouping)
1549
+ * are not here — they depend on a `table` extract's own options, so they come
1550
+ * from `table-preview.use-case.ts` instead, computed live as those options
1551
+ * change.
1552
+ *
1553
+ * @param document - The read PDF.
1554
+ * @returns The view.
1555
+ */
1556
+ function pdfDocumentView(document) {
1557
+ return {
1558
+ pages: document.pages.map(page => ({
1559
+ number: page.number,
1560
+ width: page.width,
1561
+ height: page.height,
1562
+ rows: page.rows.map(row => ({
1563
+ top: row.top,
1564
+ bottom: row.bottom,
1565
+ cells: row.cells.map(cell => ({
1566
+ x: cell.x,
1567
+ y: cell.y,
1568
+ width: cell.width,
1569
+ height: cell.height,
1570
+ text: cell.text
1571
+ }))
1572
+ })),
1573
+ rowCount: page.rows.length,
1574
+ cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
1575
+ hasTextLayer: page.rows.length > 0
1576
+ }))
1577
+ };
1578
+ }
1579
+
1580
+ /**
1581
+ * Runs a `table` extract's options against an already-read PDF (studio plan
1582
+ * §3.4, issue #94's 5b: the live preview) — a pure function of the document
1583
+ * and the options, so re-running it on every keystroke as the person edits
1584
+ * `selector`/`until`/`columns` costs nothing: no re-fetch, no re-parse
1585
+ * (`readPdf` already ran once, when `take-snapshot` captured the document).
1586
+ *
1587
+ * The workbook grid's live preview (issue #94's 5c) is `previewGridTable`,
1588
+ * below: the same idea (a pure re-run of the options against the cached
1589
+ * document), but its own result shape — a workbook table has no page, no
1590
+ * column bands, no wrapped-row regrouping, so folding it into
1591
+ * `TablePreviewMatch`/`TablePreviewResult` would leave most of those fields
1592
+ * meaningless for a grid pick; a `GridTablePreviewMatch`/`GridTablePreviewResult`
1593
+ * sibling reads honestly instead.
1594
+ *
1595
+ * @param document - The already-read PDF.
1596
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1597
+ * @returns Every match, each with the exact row indices to highlight — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
1598
+ */
1599
+ function previewPdfTable(document, options) {
1600
+ let query;
1601
+ try {
1602
+ query = {
1603
+ header: compile(options.header, 'selector'),
1604
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1605
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1606
+ align: options.align
1607
+ };
1608
+ } catch (error) {
1609
+ return {
1610
+ matches: [],
1611
+ error: error instanceof Error ? error.message : String(error)
1612
+ };
1613
+ }
1614
+ const analyses = analyzeTables(document, query);
1615
+ return {
1616
+ matches: analyses.map(analysis => ({
1617
+ page: analysis.page,
1618
+ headerRowIndex: analysis.headerRowIndex,
1619
+ matchedRowIndices: rowIndicesOf(analysis),
1620
+ bands: analysis.bands,
1621
+ bandTolerance: analysis.bandTolerance,
1622
+ table: analysis.table
1623
+ }))
1624
+ };
1625
+ }
1626
+ function rowIndicesOf(analysis) {
1627
+ const indices = new Set([analysis.headerRowIndex, ...analysis.rowGroups.flat()]);
1628
+ if (analysis.untilRowIndex !== undefined) indices.add(analysis.untilRowIndex);
1629
+ return [...indices].sort((a, b) => a - b);
1630
+ }
1631
+ function compile(source, where) {
1632
+ try {
1633
+ return new RegExp(source, 'i');
1634
+ } catch (error) {
1635
+ throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
1636
+ cause: error
1637
+ });
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Runs a `table` extract's options against an already-read workbook (studio
1642
+ * plan §3.4, issue #94's 5c: the live preview) — a pure function of the
1643
+ * document and the options, exactly like `previewPdfTable` above: no
1644
+ * re-fetch, no re-parse (`csvWorkbook`/`readXlsxWorkbook` already ran once,
1645
+ * when `take-snapshot` captured the document).
1646
+ *
1647
+ * @param document - The already-read workbook.
1648
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1649
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
1650
+ */
1651
+ function previewGridTable(document, options) {
1652
+ let query;
1653
+ try {
1654
+ query = {
1655
+ header: compile(options.header, 'selector'),
1656
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1657
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1658
+ sheet: options.sheet === undefined ? undefined : compile(options.sheet, 'sheet'),
1659
+ headerRows: options.headerRows,
1660
+ fillDown: options.fillDown,
1661
+ includeHidden: options.includeHidden
1662
+ };
1663
+ } catch (error) {
1664
+ return {
1665
+ matches: [],
1666
+ error: error instanceof Error ? error.message : String(error)
1667
+ };
1668
+ }
1669
+ const tables = findGridTables(document, query);
1670
+ return {
1671
+ matches: tables.map(table => ({
1672
+ sheet: table.sheet,
1673
+ title: table.title,
1674
+ header: table.header,
1675
+ rows: table.rows
1676
+ }))
1677
+ };
1678
+ }
1679
+ /**
1680
+ * Runs a `table` extract's options against an already-read deck (studio plan
1681
+ * §3.4, issue #94's 5d: the live preview) — a pure function of the document
1682
+ * and the options, exactly like `previewPdfTable`/`previewGridTable` above:
1683
+ * no re-fetch, no re-parse (`readPptxDeck` already ran once, when
1684
+ * `take-snapshot` captured the document).
1685
+ *
1686
+ * @param document - The already-read deck.
1687
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1688
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
1689
+ */
1690
+ function previewDeckTable(document, options) {
1691
+ let query;
1692
+ try {
1693
+ query = {
1694
+ slide: options.slide === undefined ? undefined : compile(options.slide, 'slide'),
1695
+ shapes: options.shapes,
1696
+ align: options.align,
1697
+ header: compile(options.header, 'selector'),
1698
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1699
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1700
+ headerRows: options.headerRows,
1701
+ fillDown: options.fillDown,
1702
+ includeHidden: options.includeHidden
1703
+ };
1704
+ } catch (error) {
1705
+ return {
1706
+ matches: [],
1707
+ error: error instanceof Error ? error.message : String(error)
1708
+ };
1709
+ }
1710
+ const tables = findDeckTables(document, query);
1711
+ return {
1712
+ matches: tables.map(table => ({
1713
+ slide: table.slide,
1714
+ slideTitle: table.slideTitle,
1715
+ title: table.title,
1716
+ header: table.header,
1717
+ rows: table.rows
1718
+ }))
1719
+ };
1720
+ }
1721
+
1722
+ /**
1723
+ * Handles `deck-preview`: the deck canvas's live preview of a `table`
1724
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
1725
+ * `previewDeckTable`, studio plan §3.4, issue #94's 5d), off the same cached
1726
+ * snapshot `take-snapshot`/`deck-view` already captured — a pure computation
1727
+ * over it, so this answers instantly on every option change, no re-fetch or
1728
+ * re-parse.
1729
+ *
1730
+ * @param state - The server state.
1731
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1732
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
1733
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
1734
+ */
1735
+ function handleDeckPreview(state, command) {
1736
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1737
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1738
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: deck-preview only reads a read PowerPoint document`);
1739
+ return previewDeckTable(snapshot.body, command.options);
1740
+ }
1741
+
1742
+ /**
1743
+ * Handles `deck-view`: the deck canvas's slides, shapes, tables, charts and
1744
+ * notes (`document-view`'s `deck-view.mapper.ts`, studio plan §3.4, issue
1745
+ * #94's 5d), off the same cached snapshot `take-snapshot`/`document-tree`
1746
+ * already use.
1747
+ *
1748
+ * @param state - The server state.
1749
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1750
+ * @returns The document's view.
1751
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read deck.
1752
+ */
1753
+ function handleDeckView(state, command) {
1754
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1755
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1756
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: the deck canvas only reads a read PowerPoint document`);
1757
+ return deckDocumentView(snapshot.body);
1758
+ }
1759
+
1760
+ /**
1761
+ * Handles `document-tree`: the content pane's tree canvas for a JSON, YAML
1762
+ * or XML document (`document-view`, studio plan §3.4, issue #94's 5a), off
1763
+ * the same cached snapshot `take-snapshot`/`inspect-page` already use.
1764
+ *
1765
+ * @param state - The server state.
1766
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1767
+ * @returns The tree.
1768
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not JSON/YAML/XML (no parsed body at all in web mode; PDF/workbook/deck in api mode — see `documentTreeView`'s own doc comment).
1769
+ */
1770
+ function handleDocumentTree(state, command) {
1771
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1772
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1773
+ if (snapshot.body === undefined) throw new Error(`"${command.recipeId}" at "${command.path}" is a web-mode (HTML) snapshot: the tree canvas only reads a JSON/YAML/XML document`);
1774
+ return documentTreeView(snapshot.body);
1775
+ }
1776
+
1777
+ /**
1778
+ * Finds and parses one input recipe of a workspace folder, and the output
1779
+ * recipe it names.
1780
+ *
1781
+ * @param folder - The workspace folder.
1782
+ * @param recipeId - The input recipe's id.
1783
+ * @returns The parsed pair.
1784
+ * @throws RecipeValidationError when either recipe does not parse.
1785
+ * @throws Error when the input or its output is not in the folder.
1786
+ */
1787
+ async function loadRecipePair(folder, recipeId) {
1788
+ const documents = await readRecipeSource(folder);
1789
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
1790
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
1791
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
1792
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
1793
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
1794
+ return {
1795
+ input,
1796
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
1797
+ };
1798
+ }
1799
+ function kindOf(content) {
1800
+ return content?.kind;
1801
+ }
1802
+ function idOf(content) {
1803
+ return content?.id;
1804
+ }
1805
+
1806
+ /**
1807
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
1808
+ * each `record:emit` carries its scope snapshot and mapping trace, and each
1809
+ * `record:reject` its scope), an in-memory sink, and `budget` as the
1810
+ * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
1811
+ * engine event becomes a trace line with the same `traceLine` formatter the
1812
+ * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
1813
+ * it happens, not only in the final result. Step events (`step:start`,
1814
+ * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
1815
+ * `explain-why` (#92) can later say whether the step that bound a mapping's
1816
+ * source id ran, was skipped, or retried — without a second run.
1817
+ *
1818
+ * @param folder - The workspace folder the recipe lives in.
1819
+ * @param recipeId - The input recipe to run.
1820
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
1821
+ * @param callbacks - Where trace lines and records go as the run proceeds.
1822
+ * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
1823
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
1824
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
1825
+ */
1826
+ async function runSample(folder, recipeId, budget, callbacks, browser) {
1827
+ const {
1828
+ input,
1829
+ output
1830
+ } = await loadRecipePair(folder, recipeId);
1831
+ const set = bindRecipeSet(output, [input]);
1832
+ const sink = memorySink();
1833
+ const records = [];
1834
+ const rejectedRecords = [];
1835
+ const steps = new Map();
1836
+ const crawler = createCrawler({
1837
+ sink,
1838
+ debug: true,
1839
+ sample: budget,
1840
+ browser,
1841
+ onEvent: event => {
1842
+ const line = traceLine(event);
1843
+ if (line !== undefined) callbacks.onTraceLine(line);
1844
+ trackStep(steps, event);
1845
+ if (event.type === 'record:emit') {
1846
+ const record = {
1847
+ key: event.key,
1848
+ data: event.data,
1849
+ scope: event.scope,
1850
+ mapping: event.mapping
1851
+ };
1852
+ records.push(record);
1853
+ callbacks.onRecord(record);
1854
+ } else if (event.type === 'record:reject') {
1855
+ const rejected = {
1856
+ field: event.field,
1857
+ reason: event.reason,
1858
+ url: event.url,
1859
+ scope: event.scope
1860
+ };
1861
+ rejectedRecords.push(rejected);
1862
+ callbacks.onRejected?.(rejected);
1863
+ }
1864
+ }
1865
+ });
1866
+ const result = runToResult(crawler, set, records, rejectedRecords, steps);
1867
+ return {
1868
+ stop: () => crawler.close(),
1869
+ result
1870
+ };
1871
+ }
1872
+ /**
1873
+ * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
1874
+ * earlier `ran`/`retried` outcome for the same path (a step skipped even
1875
+ * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
1876
+ * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
1877
+ * runs its body once per item, so the same path folds repeatedly; this is
1878
+ * deliberately a summary across the whole sample, not a per-record trace.
1879
+ */
1880
+ function trackStep(steps, event) {
1881
+ if (event.type === 'step:start') {
1882
+ if (!steps.has(event.path)) steps.set(event.path, {
1883
+ path: event.path,
1884
+ stepType: event.stepType,
1885
+ stepId: event.stepId,
1886
+ outcome: 'ran'
1887
+ });
1888
+ return;
1889
+ }
1890
+ if (event.type === 'step:retry') {
1891
+ steps.set(event.path, {
1892
+ path: event.path,
1893
+ stepType: event.stepType,
1894
+ stepId: event.stepId,
1895
+ outcome: 'retried',
1896
+ error: event.error
1897
+ });
1898
+ return;
482
1899
  }
483
1900
  if (event.type === 'step:skip') {
484
1901
  steps.set(event.path, {
@@ -730,342 +2147,82 @@ function ruleFor(mapping, path) {
730
2147
  const parent = mapping[match[1]];
731
2148
  return parent !== undefined && 'each' in parent ? parent.fields[match[2]] : undefined;
732
2149
  }
733
- /** The output field spec at one dotted path (`stock.inStock`, `variants[1].size`), walking `object`/`array` members the same way `map-record.use-case.ts` builds them. */
734
- function fieldFor(fields, path) {
735
- let current = fields;
736
- let field;
737
- for (const token of path.split('.')) {
738
- const match = /^([^[]+)(\[\d+\])?$/.exec(token);
739
- if (match === null || current === undefined) return undefined;
740
- field = current[match[1]];
741
- if (field === undefined) return undefined;
742
- current = match[2] === undefined ? field.fields : field.items?.fields;
743
- }
744
- return field;
745
- }
746
- /** The leading id a `from` rule reads (`item` of `item.description`, `price` of `price`); `undefined` for an `each` rule, a `.` (self), or a rule this run found nothing to say about. */
747
- function boundIdOf(rule) {
748
- if (rule === undefined || !('from' in rule)) return undefined;
749
- const first = Array.isArray(rule.from) ? rule.from[0] : rule.from;
750
- const match = /^([A-Z_][\w-]*)/i.exec(first);
751
- return match?.[1];
752
- }
753
- /**
754
- * Finds the step of the recipe whose `id` or `as` (a `forEach`'s loop
755
- * variable) is `boundId`, walking into `forEach`/`paginate`/`if` bodies the
756
- * same way `scope-outline`'s outline does, and reports how it went over the
757
- * whole sample run from `stepSummaries` (`sample-run`'s per-path summary).
758
- *
759
- * @returns `undefined` only when nothing in `steps` looks like a step at all; a `boundId` that names no real step still gets a fact, with `outcome: 'not-found'`.
760
- */
761
- function findStep(steps, boundId, stepSummaries) {
762
- const found = search(steps, boundId, 'steps');
763
- if (found === undefined) return {
764
- path: '',
765
- stepType: '',
766
- outcome: 'not-found'
767
- };
768
- const summary = stepSummaries.find(entry => entry.path === found.path);
769
- return {
770
- path: found.path,
771
- stepType: found.step.type ?? 'unknown',
772
- stepId: found.step.id,
773
- outcome: summary?.outcome ?? 'ran',
774
- error: summary?.error
775
- };
776
- }
777
- function search(steps, boundId, prefix) {
778
- for (const [index, step] of steps.entries()) {
779
- const path = `${prefix}.${index}`;
780
- if (step.id === boundId || step.as === boundId) return {
781
- path,
782
- step
783
- };
784
- if (step.steps !== undefined) {
785
- const nested = search(step.steps, boundId, `${path}.steps`);
786
- if (nested !== undefined) return nested;
787
- }
788
- if (step.else !== undefined) {
789
- const nested = search(step.else, boundId, `${path}.else`);
790
- if (nested !== undefined) return nested;
791
- }
792
- }
793
- return undefined;
794
- }
795
-
796
- /**
797
- * Handles `explain-why` (issue #92's Why? tab): builds the sentence for a
798
- * missing or rejected value from the recipe's last sample run, kept in
799
- * `state.lastRuns` by `handleRunSample`.
800
- *
801
- * @param state - The server state: `folder` must already be set (`open-workspace` first).
802
- * @param command - The command.
803
- * @returns The explanation.
804
- * @throws Error when no workspace is open, no sample run has finished for the target's recipe, or the target does not name a real record/field.
805
- */
806
- async function handleExplainWhy(state, command) {
807
- if (state.folder === undefined) throw new Error('open a workspace first');
808
- return explainWhy(state.folder, state.lastRuns, loadRecipePair, command.target);
809
- }
810
-
811
- /**
812
- * Fetches an input recipe's start page as the engine would see it: a fresh
813
- * Playwright page in web mode, core's HTTP client in api mode. Returns plain
814
- * text: the page's HTML, or a readable rendering of a non-HTML api response.
815
- * No rewriting, no script stripping, no node ids yet: that is phase 2's
816
- * `page-snapshot` proper (studio plan, #91). Provisional for phase 0 in two
817
- * ways, both worth knowing before relying on it: it opens a bare page
818
- * without running the recipe's own steps first, so a start point behind a
819
- * `session.bootstrap` login shows what an anonymous visitor sees; and it
820
- * launches a fresh browser or HTTP context per call, one page then closes
821
- * it, rather than reusing whatever the last sample run had open.
822
- *
823
- * @param input - The input recipe; only `mode` and the first `start` point are used.
824
- * @param browser - Browser launch settings for web mode (an executable path override, headless…).
825
- * @returns The page's content as plain text.
826
- * @throws Error when the recipe has no start point.
827
- */
828
- async function fetchStartPage(input, browser) {
829
- const point = input.start[0];
830
- if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
831
- return input.mode === 'web' ? fetchWeb(point.url, browser) : fetchApi(point.url);
832
- }
833
- async function fetchWeb(url, browser) {
834
- const client = await BrowserClient.launch(browser);
835
- try {
836
- const session = await client.newSession();
837
- try {
838
- await session.page.goto(url);
839
- return await session.page.content();
840
- } finally {
841
- await session.close();
842
- }
843
- } finally {
844
- await client.close();
845
- }
846
- }
847
- async function fetchApi(url) {
848
- const client = await HttpClient.open({});
849
- try {
850
- const response = await client.send({
851
- url
852
- });
853
- return textOf$1(response.body);
854
- } finally {
855
- await client.dispose();
856
- }
857
- }
858
- /** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
859
- function textOf$1(body) {
860
- if (body.kind === 'html') return body.html;
861
- if (body.kind === 'text') return body.text;
862
- if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
863
- return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
864
- }
865
-
866
- /** The attribute `hiddenMarksScript` stamps on an element hidden on the live page. */
867
- const HIDDEN_ATTRIBUTE = 'data-oc-hidden';
868
- /**
869
- * The in-page script `take-snapshot.use-case.ts` runs with `page.evaluate`
870
- * *before* `page.content()`, so a `display: none`/`visibility: hidden`/
871
- * zero-size element carries `data-oc-hidden="1"` into the captured markup
872
- * (studio plan §3.1, issue #91) — the only way to know an element was
873
- * hidden on the live page, since a static parse of the HTML afterwards has
874
- * no computed style to ask.
875
- *
876
- * Kept as a plain string, not a TypeScript function passed to
877
- * `page.evaluate`, because it references `window`/`document` as globals: `AGENTS.md`'s
878
- * "things that bit before" calls out that in-page functions must not do
879
- * that (the repo's `isolated-functions` lint rule), and every existing
880
- * `page.evaluate` call with real DOM logic in this codebase (see
881
- * `packages/core/src/captcha/resolve-captcha.use-case.ts`) already uses
882
- * this same string-template shape.
883
- *
884
- * @param attribute - The attribute to stamp; defaults to `HIDDEN_ATTRIBUTE` (parameterised for tests and for a future caller that wants a different name).
885
- * @returns A self-invoking script, ready for `page.evaluate(hiddenMarksScript())`.
886
- */
887
- function hiddenMarksScript(attribute = HIDDEN_ATTRIBUTE) {
888
- return `(() => {
889
- var attribute = ${JSON.stringify(attribute)}
890
- var elements = document.querySelectorAll('*')
891
- for (var i = 0; i < elements.length; i++) {
892
- var element = elements[i]
893
- var style = window.getComputedStyle(element)
894
- var rect = element.getBoundingClientRect()
895
- var hidden = style.display === 'none' || style.visibility === 'hidden' || (rect.width === 0 && rect.height === 0)
896
- if (hidden) element.setAttribute(attribute, '1')
897
- }
898
- })()`;
899
- }
900
-
901
- /** Attributes that carry a URL and are rewritten to absolute; `srcset` is handled separately (it carries several). */
902
- const URL_ATTRIBUTES = ['href', 'src', 'action', 'poster'];
903
- /** Schemes left untouched: they are not a location `new URL(value, base)` should resolve, or already carry their own meaning. */
904
- const SKIP_SCHEME = /^(?:javascript|mailto|tel|data|about):/i;
905
- /**
906
- * Rewrites captured HTML into what the content pane's sandboxed iframe shows
907
- * (studio plan §3.1, issue #91): every URL made absolute against `baseUrl`,
908
- * a `<base>` injected, every `<script>` removed, forms disabled (`onsubmit`
909
- * stripped, fields made read-only), `<meta http-equiv="refresh">` stripped
910
- * (it would otherwise navigate the sandboxed frame on its own), and every
911
- * element given a stable `data-oc-node` id, in document order, for the
912
- * picker overlay and `selector-inference` to address specific nodes by.
913
- *
914
- * Purely a markup transform: it does not know whether an element is hidden
915
- * on the live page (`display: none`, `visibility: hidden`, zero size) — in
916
- * web mode that is `hidden-marks.algorithm.ts`'s job, run against the live
917
- * page *before* this function ever sees the markup, so a `data-oc-hidden`
918
- * attribute it wrote just survives serialisation untouched here.
919
- *
920
- * @param html - The captured document (`page.content()` in web mode, the fetched body in api mode).
921
- * @param baseUrl - The page's own URL, for resolving relative links and as the `<base>`.
922
- * @returns The rewritten document and how many elements it stamped.
923
- */
924
- function rewriteDocument(html, baseUrl) {
925
- const $ = load(html);
926
- if ($('head').length === 0) $('html').prepend('<head></head>');
927
- const head = $('head').first();
928
- head.find('base').remove();
929
- head.prepend(`<base href="${escapeAttribute(baseUrl)}">`);
930
- $('meta').each((_index, element) => {
931
- if (($(element).attr('http-equiv') ?? '').toLowerCase() === 'refresh') $(element).remove();
932
- });
933
- $('script').remove();
934
- $('form').removeAttr('onsubmit');
935
- $('input, textarea').attr('readonly', 'readonly');
936
- $('select, button, input[type="submit"], input[type="button"]').attr('disabled', 'disabled');
937
- $('*').each((_index, element) => {
938
- const node = $(element);
939
- for (const attribute of URL_ATTRIBUTES) {
940
- const value = node.attr(attribute);
941
- if (value !== undefined) node.attr(attribute, resolveUrl(value, baseUrl) ?? value);
942
- }
943
- const srcset = node.attr('srcset');
944
- if (srcset !== undefined) node.attr('srcset', rewriteSrcset(srcset, baseUrl));
945
- });
946
- let nodeCount = 0;
947
- $('*').each((_index, element) => {
948
- $(element).attr(NODE_ID_ATTRIBUTE, `n${nodeCount}`);
949
- nodeCount += 1;
950
- });
951
- return {
952
- html: $.html(),
953
- nodeCount
954
- };
955
- }
956
- /** Resolves one URL against `baseUrl`; a scheme that is not a location (`javascript:`, `data:`…), or a value `new URL` cannot parse, is left as it was. */
957
- function resolveUrl(value, baseUrl) {
958
- const trimmed = value.trim();
959
- if (trimmed === '' || SKIP_SCHEME.test(trimmed)) return undefined;
960
- try {
961
- return new URL(trimmed, baseUrl).href;
962
- } catch {
963
- return undefined;
964
- }
965
- }
966
- /** `srcset`'s own micro-syntax: comma-separated `url descriptor?` pairs. Malformed entries are passed through unresolved rather than dropped. */
967
- function rewriteSrcset(value, baseUrl) {
968
- return value.split(',').map(candidate => {
969
- const trimmed = candidate.trim();
970
- const spaceAt = trimmed.indexOf(' ');
971
- const url = spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt);
972
- const descriptor = spaceAt === -1 ? '' : trimmed.slice(spaceAt);
973
- return `${resolveUrl(url, baseUrl) ?? url}${descriptor}`;
974
- }).join(', ');
2150
+ /** The output field spec at one dotted path (`stock.inStock`, `variants[1].size`), walking `object`/`array` members the same way `map-record.use-case.ts` builds them. */
2151
+ function fieldFor(fields, path) {
2152
+ let current = fields;
2153
+ let field;
2154
+ for (const token of path.split('.')) {
2155
+ const match = /^([^[]+)(\[\d+\])?$/.exec(token);
2156
+ if (match === null || current === undefined) return undefined;
2157
+ field = current[match[1]];
2158
+ if (field === undefined) return undefined;
2159
+ current = match[2] === undefined ? field.fields : field.items?.fields;
2160
+ }
2161
+ return field;
975
2162
  }
976
- function escapeAttribute(value) {
977
- return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
2163
+ /** The leading id a `from` rule reads (`item` of `item.description`, `price` of `price`); `undefined` for an `each` rule, a `.` (self), or a rule this run found nothing to say about. */
2164
+ function boundIdOf(rule) {
2165
+ if (rule === undefined || !('from' in rule)) return undefined;
2166
+ const first = Array.isArray(rule.from) ? rule.from[0] : rule.from;
2167
+ const match = /^([A-Z_][\w-]*)/i.exec(first);
2168
+ return match?.[1];
978
2169
  }
979
-
980
2170
  /**
981
- * Captures the snapshot the content pane shows (studio plan §3.1, issue
982
- * #91): runs the recipe up to `stepPath`, serialises the document, marks
983
- * hidden elements in web mode, and hands it to `rewrite-document.mapper.ts`
984
- * for display.
985
- *
986
- * `stepPath` is accepted (and cached against, by `snapshot.store.ts`) for
987
- * the API the UI needs — one snapshot per selected step — but v1 only
988
- * actually *captures* `"start"`: the first `start` point, before any step
989
- * of the recipe has run, the same reach `fetch-start-page.use-case.ts` (phase
990
- * 0) already had. Snapshotting mid-recipe (after an arbitrary later step)
991
- * needs a "stop the crawl after step X and hand back the live page/document"
992
- * hook in `crawl-execution` that does not exist yet; building one was out of
993
- * this phase's budget (see this phase's final report for the tradeoff this
994
- * was weighed against). Any other `stepPath` throws, clearly, rather than
995
- * silently returning the start page under a misleading label.
2171
+ * Finds the step of the recipe whose `id` or `as` (a `forEach`'s loop
2172
+ * variable) is `boundId`, walking into `forEach`/`paginate`/`if` bodies the
2173
+ * same way `scope-outline`'s outline does, and reports how it went over the
2174
+ * whole sample run from `stepSummaries` (`sample-run`'s per-path summary).
996
2175
  *
997
- * @param input - The input recipe; only `mode` and the first `start` point are used.
998
- * @param stepPath - The step to snapshot after, e.g. `"start"` or `"steps.2"` (`scope-outline`'s path format).
999
- * @param browser - Browser launch settings for web mode.
1000
- * @returns The rewritten snapshot.
1001
- * @throws Error when the recipe has no start point, or `stepPath` is not `"start"`.
2176
+ * @returns `undefined` only when nothing in `steps` looks like a step at all; a `boundId` that names no real step still gets a fact, with `outcome: 'not-found'`.
1002
2177
  */
1003
- async function takeSnapshot(input, stepPath, browser) {
1004
- if (stepPath !== 'start') throw new Error(`takeSnapshot: "${stepPath}" is not supported yet — only "start" (the first start point, before any step runs) is; see this function's doc comment`);
1005
- const point = input.start[0];
1006
- if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
1007
- return input.mode === 'web' ? snapshotWeb(point.url, browser) : snapshotApi(point.url);
1008
- }
1009
- async function snapshotWeb(url, browser) {
1010
- const client = await BrowserClient.launch(browser);
1011
- try {
1012
- const session = await client.newSession();
1013
- try {
1014
- await session.page.goto(url);
1015
- await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
1016
- const html = await session.page.content();
1017
- const baseUrl = session.page.url();
1018
- const rewritten = rewriteDocument(html, baseUrl);
1019
- return {
1020
- ...rewritten,
1021
- baseUrl
1022
- };
1023
- } finally {
1024
- await session.close();
1025
- }
1026
- } finally {
1027
- await client.close();
1028
- }
2178
+ function findStep(steps, boundId, stepSummaries) {
2179
+ const found = search(steps, boundId, 'steps');
2180
+ if (found === undefined) return {
2181
+ path: '',
2182
+ stepType: '',
2183
+ outcome: 'not-found'
2184
+ };
2185
+ const summary = stepSummaries.find(entry => entry.path === found.path);
2186
+ return {
2187
+ path: found.path,
2188
+ stepType: found.step.type ?? 'unknown',
2189
+ stepId: found.step.id,
2190
+ outcome: summary?.outcome ?? 'ran',
2191
+ error: summary?.error
2192
+ };
1029
2193
  }
1030
- async function snapshotApi(url) {
1031
- const client = await HttpClient.open({});
1032
- try {
1033
- const response = await client.send({
1034
- url
1035
- });
1036
- const rewritten = rewriteDocument(textOf(response.body), url);
1037
- return {
1038
- ...rewritten,
1039
- baseUrl: url
2194
+ function search(steps, boundId, prefix) {
2195
+ for (const [index, step] of steps.entries()) {
2196
+ const path = `${prefix}.${index}`;
2197
+ if (step.id === boundId || step.as === boundId) return {
2198
+ path,
2199
+ step
1040
2200
  };
1041
- } finally {
1042
- await client.dispose();
2201
+ if (step.steps !== undefined) {
2202
+ const nested = search(step.steps, boundId, `${path}.steps`);
2203
+ if (nested !== undefined) return nested;
2204
+ }
2205
+ if (step.else !== undefined) {
2206
+ const nested = search(step.else, boundId, `${path}.else`);
2207
+ if (nested !== undefined) return nested;
2208
+ }
1043
2209
  }
1044
- }
1045
- /** Every body kind the studio can show as text today; others get a placeholder rather than a crash (mirrors `fetch-start-page.use-case.ts`'s own `textOf`). */
1046
- function textOf(body) {
1047
- if (body.kind === 'html') return body.html;
1048
- if (body.kind === 'text') return body.text;
1049
- if (body.kind === 'json') return `<pre>${JSON.stringify(body.data, null, 2)}</pre>`;
1050
- return `<p>[${body.kind} document: the content pane shows HTML and JSON responses for now]</p>`;
2210
+ return undefined;
1051
2211
  }
1052
2212
 
1053
- /** @returns An empty cache, for a freshly opened workspace. */
1054
- function createSnapshotCache() {
1055
- return {
1056
- entries: new Map()
1057
- };
1058
- }
1059
- /** @returns The cached snapshot for `recipeId`/`path`, or `undefined` when none was taken yet. */
1060
- function cachedSnapshot(cache, recipeId, path) {
1061
- return cache.entries.get(keyOf(recipeId, path));
1062
- }
1063
- /** Records a freshly taken snapshot, replacing whatever was cached for the same recipe and step path. */
1064
- function putSnapshot(cache, recipeId, path, snapshot) {
1065
- cache.entries.set(keyOf(recipeId, path), snapshot);
1066
- }
1067
- function keyOf(recipeId, path) {
1068
- return `${recipeId}:${path}`;
2213
+ /**
2214
+ * Handles `explain-why` (issue #92's Why? tab): builds the sentence for a
2215
+ * missing or rejected value from the recipe's last sample run, kept in
2216
+ * `state.lastRuns` by `handleRunSample`.
2217
+ *
2218
+ * @param state - The server state: `folder` must already be set (`open-workspace` first).
2219
+ * @param command - The command.
2220
+ * @returns The explanation.
2221
+ * @throws Error when no workspace is open, no sample run has finished for the target's recipe, or the target does not name a real record/field.
2222
+ */
2223
+ async function handleExplainWhy(state, command) {
2224
+ if (state.folder === undefined) throw new Error('open a workspace first');
2225
+ return explainWhy(state.folder, state.lastRuns, loadRecipePair, command.target);
1069
2226
  }
1070
2227
 
1071
2228
  /**
@@ -1089,6 +2246,62 @@ async function handleFetchStartPage(state, command) {
1089
2246
  };
1090
2247
  }
1091
2248
 
2249
+ /**
2250
+ * Handles `grid-preview`: the grid canvas's live preview of a `table`
2251
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
2252
+ * `previewGridTable`, studio plan §3.4, issue #94's 5c), off the same
2253
+ * cached snapshot `take-snapshot`/`grid-view` already captured — a pure
2254
+ * computation over it, so this answers instantly on every option change, no
2255
+ * re-fetch or re-parse.
2256
+ *
2257
+ * @param state - The server state.
2258
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2259
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
2260
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
2261
+ */
2262
+ function handleGridPreview(state, command) {
2263
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2264
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2265
+ if (!isWorkbookDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a workbook snapshot: grid-preview only reads a read CSV/spreadsheet document`);
2266
+ return previewGridTable(snapshot.body, command.options);
2267
+ }
2268
+
2269
+ /**
2270
+ * Handles `grid-view`: the grid canvas's sheets and cells (`document-view`'s
2271
+ * `workbook-view.mapper.ts`, studio plan §3.4, issue #94's 5c), off the same
2272
+ * cached snapshot `take-snapshot`/`document-tree`/`pdf-view` already use.
2273
+ *
2274
+ * `delimiter`/`encoding` re-read a CSV with the override applied
2275
+ * (`page-snapshot`'s `rereadCsv`) and replace the cached snapshot's body with
2276
+ * the fresh reading, so a following `grid-preview` (and another `grid-view`
2277
+ * with no override) sees the same one — the override sticks until
2278
+ * `take-snapshot` recaptures.
2279
+ *
2280
+ * @param state - The server state.
2281
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2282
+ * @returns The document's view.
2283
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, an override is given for a snapshot that is not a CSV, or the snapshot's document is not a read workbook.
2284
+ */
2285
+ async function handleGridView(state, command) {
2286
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2287
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2288
+ const overriding = command.delimiter !== undefined || command.encoding !== undefined;
2289
+ if (!overriding) {
2290
+ if (!isWorkbookDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a workbook snapshot: the grid canvas only reads a read CSV/spreadsheet document`);
2291
+ return workbookDocumentView(snapshot.body);
2292
+ }
2293
+ if (snapshot.format !== 'csv') throw new Error(`"${command.recipeId}" at "${command.path}" is not a CSV snapshot: "delimiter"/"encoding" only override a CSV reading`);
2294
+ const body = await rereadCsv(snapshot.baseUrl, {
2295
+ delimiter: command.delimiter,
2296
+ encoding: command.encoding
2297
+ });
2298
+ putSnapshot(state.snapshots, command.recipeId, command.path, {
2299
+ ...snapshot,
2300
+ body
2301
+ });
2302
+ return workbookDocumentView(body);
2303
+ }
2304
+
1092
2305
  const GENERATED_ID = /^(?:react-|radix-|:r|ember|css-|mui-|headlessui-)/i;
1093
2306
  /**
1094
2307
  * The path from the document root to `node`, one level per ancestor. Two
@@ -1402,7 +2615,7 @@ const TIER_ORDER = {
1402
2615
  function rankCandidates(html, candidates, targetNodeId) {
1403
2616
  const ranked = candidates.map(candidate => {
1404
2617
  const matches = countMatches(html, candidate.selector);
1405
- const hitsTarget = targetNodeId === undefined ;
2618
+ const hitsTarget = targetNodeId === undefined || matches === 0 ? targetNodeId === undefined : matchesOf(html, candidate.selector).includes(targetNodeId);
1406
2619
  return {
1407
2620
  ...candidate,
1408
2621
  matches,
@@ -1581,6 +2794,333 @@ function fieldPick(html, clicked, rankSource = clicked) {
1581
2794
  };
1582
2795
  }
1583
2796
 
2797
+ /** A leaf element's own text longer than this is cut, with an ellipsis. */
2798
+ const TEXT_LIMIT = 80;
2799
+ /** Consecutive siblings whose subtree is identical (ids aside) collapse into one row once there are at least this many — a real page can have tens of thousands of `<li>`s, and showing all of them is not "inspecting" it (issue #93). */
2800
+ const MIN_REPEAT_RUN = 3;
2801
+ /**
2802
+ * Builds the Inspect panel's DOM tree from a captured snapshot (studio plan
2803
+ * §3.3, issue #93): the same rewritten, marked-up document the content pane's
2804
+ * iframe shows (`page-snapshot`'s `take-snapshot.use-case.ts`), so a tree
2805
+ * row's `nodeId` addresses exactly the element the iframe would highlight —
2806
+ * no separate capture, per issue #93's scope note to reuse phase 2's
2807
+ * snapshot rather than recapture.
2808
+ *
2809
+ * @param snapshotHtml - The cached, rewritten snapshot (`SnapshotResult.html`).
2810
+ * @returns The tree's root (the document's `<html>` element).
2811
+ * @throws Error when the document has no `<html>` element (should not happen: `rewriteDocument` always produces one).
2812
+ */
2813
+ function domTree(snapshotHtml) {
2814
+ const $ = load(snapshotHtml);
2815
+ const html = $('html').get(0);
2816
+ if (html === undefined) throw new Error('domTree: the snapshot has no <html> element');
2817
+ return buildNode($, html);
2818
+ }
2819
+ function buildNode($, element) {
2820
+ const $el = $(element);
2821
+ const attributes = {
2822
+ ...element.attribs
2823
+ };
2824
+ const nodeId = attributes[NODE_ID_ATTRIBUTE] ?? '';
2825
+ const hidden = attributes[HIDDEN_ATTRIBUTE] === '1';
2826
+ delete attributes[NODE_ID_ATTRIBUTE];
2827
+ delete attributes[HIDDEN_ATTRIBUTE];
2828
+ const id = attributes.id;
2829
+ const classes = (attributes.class ?? '').split(/\s+/).filter(token => token !== '');
2830
+ const childElements = $el.children().toArray();
2831
+ const children = collapseRepeats(childElements.map(child => buildNode($, child)));
2832
+ const text = childElements.length === 0 ? abbreviate($el.text()) : undefined;
2833
+ return {
2834
+ nodeId,
2835
+ tag: element.tagName.toLowerCase(),
2836
+ ...(id !== undefined && id !== '' && {
2837
+ id
2838
+ }),
2839
+ ...(classes.length > 0 && {
2840
+ classes
2841
+ }),
2842
+ attributes,
2843
+ ...(text !== undefined && text !== '' && {
2844
+ text
2845
+ }),
2846
+ hidden,
2847
+ children
2848
+ };
2849
+ }
2850
+ /**
2851
+ * Merges runs of `MIN_REPEAT_RUN` or more consecutive siblings whose subtree
2852
+ * is identical (their own `nodeId`s aside) into one representative row, the
2853
+ * issue's `×20` — real pages have tens of thousands of nodes, and this is
2854
+ * what makes the tree usable rather than merely correct.
2855
+ *
2856
+ * @param nodes - Already-built sibling rows, in document order.
2857
+ * @returns The same rows, with each long-enough identical run replaced by one.
2858
+ */
2859
+ function collapseRepeats(nodes) {
2860
+ const result = [];
2861
+ let at = 0;
2862
+ while (at < nodes.length) {
2863
+ const signature = signatureOf(nodes[at]);
2864
+ let end = at + 1;
2865
+ while (end < nodes.length && signatureOf(nodes[end]) === signature) end += 1;
2866
+ const run = nodes.slice(at, end);
2867
+ if (run.length >= MIN_REPEAT_RUN) {
2868
+ result.push({
2869
+ ...run[0],
2870
+ repeatCount: run.length,
2871
+ repeatNodeIds: run.map(node => node.nodeId)
2872
+ });
2873
+ } else {
2874
+ result.push(...run);
2875
+ }
2876
+ at = end;
2877
+ }
2878
+ return result;
2879
+ }
2880
+ /** A run member's identity for collapsing: everything but its own `nodeId` (every instance has a different one by construction). */
2881
+ function signatureOf(node) {
2882
+ return JSON.stringify({
2883
+ ...node,
2884
+ nodeId: undefined,
2885
+ children: node.children.map(child => signatureOf(child))
2886
+ });
2887
+ }
2888
+ function abbreviate(text) {
2889
+ const trimmed = text.trim().replaceAll(/\s+/g, ' ');
2890
+ return trimmed.length > TEXT_LIMIT ? `${trimmed.slice(0, TEXT_LIMIT)}…` : trimmed;
2891
+ }
2892
+
2893
+ /** The global names sites hydrate state into, that `describeHtml`'s own JSON-LD/table scan does not cover (issue #93's "inline state assignments"). */
2894
+ const INLINE_STATE_NAMES = ['__NEXT_DATA__', '__NUXT__', '__INITIAL_STATE__', '__APOLLO_STATE__', '__PRELOADED_STATE__', '__STATE__'];
2895
+ const INLINE_STATE_PATTERN = new RegExp(String.raw`(?:window\.)?(${INLINE_STATE_NAMES.join('|')})\s*=`);
2896
+ /**
2897
+ * Finds the data-in-the-page findings the Inspect panel lists apart from the
2898
+ * DOM tree (studio plan §3.3, issue #93): JSON-LD and `application/json`
2899
+ * script blocks, inline state assignments, `<meta>` name/property values and
2900
+ * `<link rel>`s — reusing `@opencraw/probe`'s `describeJson` for a found
2901
+ * value's shape (its own `find-data.algorithm.ts`/`html-findings.mapper.ts`
2902
+ * scan a page's raw text for *reporting*, with no element to point a pick at;
2903
+ * this function's own job — locating each finding's element so a pick can
2904
+ * reach it — is new).
2905
+ *
2906
+ * Takes the **raw** captured document, not the display snapshot
2907
+ * (`page-snapshot`'s `SnapshotResult.html`): `rewriteDocument` strips every
2908
+ * `<script>` for the sandboxed iframe, which would make JSON-LD, inline
2909
+ * state and other script-tag data invisible here. `<meta>`/`<link>` survive
2910
+ * rewriting untouched, so using the raw document for every kind (rather than
2911
+ * mixing two documents) keeps this function simple and its selectors
2912
+ * verified against what the engine will actually crawl.
2913
+ *
2914
+ * @param rawHtml - The document as fetched/rendered, before `rewriteDocument` strips scripts.
2915
+ * @returns The findings, in document order.
2916
+ */
2917
+ function pageData(rawHtml) {
2918
+ const $ = load(rawHtml);
2919
+ const findings = [];
2920
+ $('script[type="application/ld+json"]').each((_index, element) => {
2921
+ findings.push(...scriptFinding($, element, rawHtml, 'ld-json', ldJsonLabel));
2922
+ });
2923
+ $('script[type="application/json"]').each((_index, element) => {
2924
+ findings.push(...scriptFinding($, element, rawHtml, 'json-script', () => `json: ${structuralSelector($, element)}`));
2925
+ });
2926
+ $('script').each((_index, element) => {
2927
+ const type = $(element).attr('type');
2928
+ if (type !== undefined && type !== 'text/javascript' && type !== 'module') return;
2929
+ const text = $(element).text();
2930
+ const match = INLINE_STATE_PATTERN.exec(text);
2931
+ if (match === null) return;
2932
+ const value = tryParseJson(text);
2933
+ const selector = structuralSelector($, element);
2934
+ findings.push({
2935
+ kind: 'inline-state',
2936
+ label: match[1],
2937
+ selector,
2938
+ selectorKind: 'css',
2939
+ matches: countMatches(rawHtml, selector),
2940
+ keys: keysOf(value)
2941
+ });
2942
+ });
2943
+ $('meta[name], meta[property]').each((_index, element) => {
2944
+ const name = $(element).attr('name') ?? $(element).attr('property') ?? '';
2945
+ const selector = structuralSelector($, element);
2946
+ findings.push({
2947
+ kind: 'meta',
2948
+ label: `meta: ${name}`,
2949
+ selector,
2950
+ selectorKind: 'css',
2951
+ matches: countMatches(rawHtml, selector),
2952
+ attribute: 'content'
2953
+ });
2954
+ });
2955
+ $('link[rel]').each((_index, element) => {
2956
+ const rel = $(element).attr('rel') ?? '';
2957
+ const selector = structuralSelector($, element);
2958
+ findings.push({
2959
+ kind: 'link',
2960
+ label: `link: ${rel}`,
2961
+ selector,
2962
+ selectorKind: 'css',
2963
+ matches: countMatches(rawHtml, selector),
2964
+ attribute: 'href'
2965
+ });
2966
+ });
2967
+ return findings;
2968
+ }
2969
+ function scriptFinding($, element, rawHtml, kind, labelOf) {
2970
+ const value = tryParseJson($(element).text());
2971
+ if (value === undefined) return [];
2972
+ const selector = structuralSelector($, element);
2973
+ return [{
2974
+ kind,
2975
+ label: labelOf(value),
2976
+ selector,
2977
+ selectorKind: 'css',
2978
+ matches: countMatches(rawHtml, selector),
2979
+ keys: keysOf(value)
2980
+ }];
2981
+ }
2982
+ function ldJsonLabel(value) {
2983
+ if (Array.isArray(value)) return `ld+json: array (${value.length})`;
2984
+ if (typeof value === 'object' && value !== null) {
2985
+ const type = value['@type'];
2986
+ if (typeof type === 'string') return `ld+json: ${type}`;
2987
+ }
2988
+ return `ld+json: ${describeJson(value, 'json').type}`;
2989
+ }
2990
+ function keysOf(value) {
2991
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.keys(value) : undefined;
2992
+ }
2993
+ /**
2994
+ * A unique CSS path for an element with no stable attribute of its own
2995
+ * (a `<script>` tag has no id or class to speak of): `<ancestor> > tag`
2996
+ * from `<html>` down, `:nth-of-type` added only where a tag has siblings of
2997
+ * the same name under the same parent. Always resolves to exactly one
2998
+ * element, on the raw document this ran against and on the real page (a
2999
+ * purely structural selector, unaffected by the snapshot's own rewriting).
3000
+ *
3001
+ * @param $ - The document.
3002
+ * @param element - The element to address.
3003
+ * @returns The selector.
3004
+ */
3005
+ function structuralSelector($, element) {
3006
+ const segments = [];
3007
+ let $current = $(element);
3008
+ while ($current.length > 0) {
3009
+ const tag = $current.prop('tagName')?.toLowerCase();
3010
+ if (tag === undefined) break;
3011
+ const $parent = $current.parent();
3012
+ const siblingsOfType = $parent.length > 0 ? $parent.children(tag) : $current;
3013
+ const index = siblingsOfType.toArray().indexOf($current.get(0)) + 1;
3014
+ segments.unshift(siblingsOfType.length > 1 ? `${tag}:nth-of-type(${index})` : tag);
3015
+ if (tag === 'html' || $parent.length === 0) break;
3016
+ $current = $parent;
3017
+ }
3018
+ return segments.join(' > ');
3019
+ }
3020
+
3021
+ /** How long the browser is given to settle after `goto`, watching for JSON responses (mirrors `packages/cli`'s `probe --browser`, `observeBrowserJson`). */
3022
+ const OBSERVE_MS = 4000;
3023
+ /** Responses kept, largest/most-recent-first ordering left to the caller; just a sane cap on what the panel lists. */
3024
+ const RESPONSE_LIMIT = 15;
3025
+ /**
3026
+ * Records the JSON responses seen while an input recipe's start page
3027
+ * rendered (studio plan §3.3, issue #93): the same technique `packages/cli`'s
3028
+ * `probe --browser` uses (`observeBrowserJson`) — render the page, listen for
3029
+ * `response` events whose content type is JSON, give it a few seconds to
3030
+ * settle. Kept as its own capture, independent of `page-snapshot`'s
3031
+ * `take-snapshot.use-case.ts`: that capture reads `page.content()` right
3032
+ * after `goto` resolves, with no observation window, and changing its shape
3033
+ * to also collect responses was a larger, riskier change to already-shipped,
3034
+ * tested code than this phase's budget allowed (see this phase's final
3035
+ * report) — so this opens its own short-lived browser session instead of
3036
+ * piggy-backing on the cached snapshot.
3037
+ *
3038
+ * Only `"start"` is supported, mirroring `take-snapshot.use-case.ts`'s own
3039
+ * reach; api-mode recipes have no page load to observe, so they always
3040
+ * answer with an empty list rather than an error (there is nothing wrong
3041
+ * with asking, the answer is just "none").
3042
+ *
3043
+ * @param input - The input recipe; only `mode` and the first `start` point are used.
3044
+ * @param stepPath - The step to observe after, e.g. `"start"`.
3045
+ * @param browser - Browser launch settings, shared with `take-snapshot`/`verify-selector`/`run-sample`.
3046
+ * @returns The distinct JSON responses observed, at most `RESPONSE_LIMIT`.
3047
+ * @throws Error when the recipe has no start point, or `stepPath` is not `"start"`.
3048
+ */
3049
+ async function responsesSeen(input, stepPath, browser) {
3050
+ if (stepPath !== 'start') throw new Error(`responsesSeen: "${stepPath}" is not supported yet — only "start" (the first start point, before any step ran) is`);
3051
+ if (input.mode !== 'web') return [];
3052
+ const point = input.start[0];
3053
+ if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
3054
+ const client = await ensureBrowserLaunch(browser, () => BrowserClient.launch(browser));
3055
+ try {
3056
+ const session = await client.newSession();
3057
+ try {
3058
+ const responses = [];
3059
+ const seen = new Set();
3060
+ const pending = [];
3061
+ session.page.on('response', response => {
3062
+ if (!(response.headers()['content-type'] ?? '').includes('json')) return;
3063
+ const key = `${response.status()} ${response.url()}`;
3064
+ if (seen.has(key)) return;
3065
+ seen.add(key);
3066
+ pending.push(recordResponse(response, responses));
3067
+ });
3068
+ try {
3069
+ await session.page.goto(point.url, {
3070
+ waitUntil: 'networkidle'
3071
+ });
3072
+ } catch {
3073
+ // a page that never idles (long-poll, websocket) still yields whatever was observed by the timeout below
3074
+ }
3075
+ await session.page.waitForTimeout(OBSERVE_MS);
3076
+ await Promise.all(pending);
3077
+ return responses.slice(0, RESPONSE_LIMIT);
3078
+ } finally {
3079
+ await session.close();
3080
+ }
3081
+ } finally {
3082
+ await client.close();
3083
+ }
3084
+ }
3085
+ /** Reads one response's body, when it still can be (not aborted, not redirected away); a response that cannot be read is silently left out rather than failing the whole capture. */
3086
+ async function recordResponse(response, into) {
3087
+ try {
3088
+ const body = await response.text();
3089
+ const value = tryParseJson(body);
3090
+ into.push({
3091
+ url: response.url(),
3092
+ status: response.status(),
3093
+ size: body.length,
3094
+ shape: value === undefined ? 'text' : describeJson(value, 'json').type
3095
+ });
3096
+ } catch {
3097
+ // response body unavailable (aborted, redirected, the page navigated away) — skip it
3098
+ }
3099
+ }
3100
+
3101
+ /**
3102
+ * Handles `inspect-page`: the Inspect panel's DOM tree and data-in-the-page
3103
+ * findings, off the same cached snapshot `take-snapshot`/`infer-selector`
3104
+ * already use (`page-inspector`, issue #93). `domTree` reads the cached,
3105
+ * rewritten snapshot (the same markup the content pane's iframe shows, so a
3106
+ * tree row's `nodeId` lines up with it exactly); `pageData` reads the raw
3107
+ * capture (`SnapshotResult.rawHtml`), since the display snapshot has had its
3108
+ * `<script>` tags stripped for the sandboxed iframe.
3109
+ *
3110
+ * @param state - The server state.
3111
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3112
+ * @returns The tree and the data-in-the-page findings.
3113
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
3114
+ */
3115
+ function handleInspectPage(state, command) {
3116
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3117
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3118
+ return {
3119
+ tree: domTree(snapshot.html),
3120
+ pageData: pageData(snapshot.rawHtml)
3121
+ };
3122
+ }
3123
+
1584
3124
  /**
1585
3125
  * The outline model: an `InputRecipe`'s `steps` (and only `steps`; the rest
1586
3126
  * of the recipe passes through untouched, see `recipe-to-outline.mapper.ts`)
@@ -1705,306 +3245,1078 @@ function selectChoice(step) {
1705
3245
  if (Array.isArray(step.values)) return code(step.values.join(', '));
1706
3246
  return code('');
1707
3247
  }
1708
- function waitFor(step) {
1709
- if (typeof step.selector === 'string') return code(step.selector);
1710
- if (typeof step.ms === 'number') return code(`${step.ms}ms`);
1711
- if (typeof step.state === 'string') return code(step.state);
1712
- return code('');
3248
+ function waitFor(step) {
3249
+ if (typeof step.selector === 'string') return code(step.selector);
3250
+ if (typeof step.ms === 'number') return code(`${step.ms}ms`);
3251
+ if (typeof step.state === 'string') return code(step.state);
3252
+ return code('');
3253
+ }
3254
+ function forEachSentence(step) {
3255
+ const parts = [word('For each'), pillField(step, 'as', '(item)'), word('in'), typeof step.over === 'string' ? pill(step.over) : targetField(step)];
3256
+ if (step.emit !== undefined) parts.push(word('→'), word('one record'));
3257
+ return parts;
3258
+ }
3259
+ function paginateSentence(step) {
3260
+ const parts = [word('For every page')];
3261
+ const next = step.next;
3262
+ if (next !== null && typeof next === 'object') {
3263
+ const record = next;
3264
+ if (typeof record.selector === 'string') parts.push(word('via'), code(record.selector));else if (typeof record.url === 'string') parts.push(word('via'), code(record.url));else if (typeof record.jsonpath === 'string') parts.push(word('via'), code(record.jsonpath));
3265
+ }
3266
+ if (typeof step.until === 'string') parts.push(word('until'), code(step.until));
3267
+ return parts;
3268
+ }
3269
+ function emitSentence(step) {
3270
+ if (typeof step.output === 'string') return [word('Emit'), word('to'), pill(step.output)];
3271
+ return [word('Emit')];
3272
+ }
3273
+
3274
+ /**
3275
+ * Reads a recipe's `steps` as an outline. Works on whatever JSON parsed
3276
+ * (not a validated `InputRecipe`): a recipe mid-edit in the JSON tab may be
3277
+ * missing fields or have the wrong shape, and the outline still has to
3278
+ * render something rather than crash the tab. Anything that is not a
3279
+ * well-formed step becomes a custom card holding what was there; anything
3280
+ * that is not an object at the top becomes an outline with no steps.
3281
+ *
3282
+ * @param content - The recipe's parsed JSON (the JSON tab's own `JSON.parse` of its text).
3283
+ * @returns The outline: the untouched top-level record plus the step tree.
3284
+ */
3285
+ function recipeToOutline(content) {
3286
+ const recipe = isRecord(content) ? content : {};
3287
+ const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
3288
+ return {
3289
+ recipe,
3290
+ steps: stepsToNodes(steps, 'steps')
3291
+ };
3292
+ }
3293
+ function stepsToNodes(steps, pathPrefix) {
3294
+ return steps.map((step, index) => stepToNode(step, `${pathPrefix}.${index}`));
3295
+ }
3296
+ function stepToNode(step, path) {
3297
+ if (!isRecord(step)) {
3298
+ return {
3299
+ kind: 'card',
3300
+ path,
3301
+ stepType: 'unknown',
3302
+ sentence: [{
3303
+ kind: 'word',
3304
+ text: 'custom step'
3305
+ }],
3306
+ step: {},
3307
+ custom: true
3308
+ };
3309
+ }
3310
+ const stepType = typeof step.type === 'string' ? step.type : 'unknown';
3311
+ const {
3312
+ parts,
3313
+ custom
3314
+ } = cardSentence(step);
3315
+ if (!isBracketStepType(stepType)) return {
3316
+ kind: 'card',
3317
+ path,
3318
+ stepType,
3319
+ sentence: parts,
3320
+ step,
3321
+ custom
3322
+ };
3323
+ const children = Array.isArray(step.steps) ? stepsToNodes(step.steps, `${path}.steps`) : [];
3324
+ const elseChildren = stepType === 'if' && Array.isArray(step.else) ? stepsToNodes(step.else, `${path}.else`) : undefined;
3325
+ return {
3326
+ kind: 'bracket',
3327
+ path,
3328
+ stepType,
3329
+ sentence: parts,
3330
+ step,
3331
+ children,
3332
+ ...(elseChildren !== undefined && {
3333
+ elseChildren
3334
+ })
3335
+ };
3336
+ }
3337
+ function isRecord(value) {
3338
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3339
+ }
3340
+
3341
+ /**
3342
+ * Rebuilds a recipe from its outline: `recipe`'s keys, in their original
3343
+ * order, with `steps` (when the recipe had one) replaced by the outline's
3344
+ * (possibly edited) step tree. A card's own raw `step` is used as-is; a
3345
+ * bracket's `step` supplies every field but `steps`/`else`, which come from
3346
+ * `children`/`elseChildren` instead, put back at the position `step` itself
3347
+ * held that key at (or appended, for a bracket a `+` menu just added, whose
3348
+ * `step` never had one) — this, together with `recipe-to-outline.mapper.ts`
3349
+ * always keeping the step's whole original object, is what makes
3350
+ * `recipe → outline → recipe` preserve every key and its order.
3351
+ *
3352
+ * @param outline - An outline, from `recipe-to-outline.mapper.ts` or edited by the UI.
3353
+ * @returns The recipe's plain JSON, ready for `save-recipe`.
3354
+ */
3355
+ function outlineToRecipe(outline) {
3356
+ if (!('steps' in outline.recipe) && outline.steps.length === 0) return {
3357
+ ...outline.recipe
3358
+ };
3359
+ return {
3360
+ ...outline.recipe,
3361
+ steps: nodesToSteps(outline.steps)
3362
+ };
3363
+ }
3364
+ function nodesToSteps(nodes) {
3365
+ return nodes.map(node => nodeToStep(node));
3366
+ }
3367
+ function nodeToStep(node) {
3368
+ if (!isBracket(node)) return node.step;
3369
+ const keys = Object.keys(node.step);
3370
+ const step = {};
3371
+ for (const key of keys) {
3372
+ if (key === 'steps') step.steps = nodesToSteps(node.children);else if (key === 'else' && node.stepType === 'if') step.else = nodesToSteps(node.elseChildren ?? []);else step[key] = node.step[key];
3373
+ }
3374
+ if (!keys.includes('steps')) step.steps = nodesToSteps(node.children);
3375
+ if (node.stepType === 'if' && node.elseChildren !== undefined && !keys.includes('else')) step.else = nodesToSteps(node.elseChildren);
3376
+ return step;
3377
+ }
3378
+
3379
+ /**
3380
+ * Opens a recipe folder: every `.json`/`.jsonl` recipe directly inside it
3381
+ * (core's own directory reading, so relative `file:` URLs resolve the same
3382
+ * way a real run would), parsed and, for each output recipe found, bound to
3383
+ * the input recipes that name it. Nothing throws for a bad recipe or a
3384
+ * recipe that does not bind: every problem is reported on its file with the
3385
+ * JSON path core already computes, exactly as `opencraw validate` prints it.
3386
+ *
3387
+ * @param folder - The workspace folder.
3388
+ * @returns Every recipe file found, each with its validation and binding issues.
3389
+ * @throws Error when the folder does not exist or cannot be read.
3390
+ */
3391
+ async function openWorkspace(folder) {
3392
+ const documents = await readRecipeSource(folder);
3393
+ const entries = documents.map(document => parseDocument(document));
3394
+ const outputs = entries.filter(isParsed('output'));
3395
+ const inputs = entries.filter(isParsed('input'));
3396
+ for (const output of outputs) bindOutput(output, inputs);
3397
+ for (const input of inputs) {
3398
+ if (outputs.every(output => output.recipe.id !== input.recipe.output)) {
3399
+ input.issues.push({
3400
+ path: 'output',
3401
+ message: `no output recipe "${input.recipe.output}" in this workspace`,
3402
+ kind: 'binding'
3403
+ });
3404
+ }
3405
+ }
3406
+ return {
3407
+ folder,
3408
+ recipes: entries.map(({
3409
+ file,
3410
+ kind,
3411
+ id,
3412
+ issues,
3413
+ content
3414
+ }) => ({
3415
+ file,
3416
+ kind,
3417
+ id,
3418
+ issues,
3419
+ text: `${JSON.stringify(content, null, 2)}\n`,
3420
+ // The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
3421
+ // throws, so this covers a file that fails validation too (its outline just has "custom" cards).
3422
+ outline: kind === 'input' ? recipeToOutline(content) : undefined
3423
+ }))
3424
+ };
3425
+ }
3426
+ /** Binds one output recipe to the inputs that name it, filing every binding issue back on the recipe it names. */
3427
+ function bindOutput(output, inputs) {
3428
+ const matching = inputs.filter(input => input.recipe.output === output.recipe.id);
3429
+ try {
3430
+ bindRecipeSet(output.recipe, matching.map(input => input.recipe));
3431
+ } catch (error) {
3432
+ if (!(error instanceof RecipeBindingError)) throw error;
3433
+ for (const issue of error.issues) {
3434
+ const target = issue.recipeId === output.recipe.id ? output : matching.find(input => input.recipe.id === issue.recipeId);
3435
+ target?.issues.push({
3436
+ path: issue.path,
3437
+ message: issue.message,
3438
+ kind: 'binding'
3439
+ });
3440
+ }
3441
+ }
3442
+ }
3443
+ function isParsed(kind) {
3444
+ return entry => entry.kind === kind && entry.recipe !== undefined;
3445
+ }
3446
+ function parseDocument(document) {
3447
+ const kind = document.content?.kind;
3448
+ if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
3449
+ if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
3450
+ return {
3451
+ file: document.source,
3452
+ kind: 'unknown',
3453
+ content: document.content,
3454
+ issues: [{
3455
+ path: 'kind',
3456
+ message: '"kind" is missing or not "input"/"output"',
3457
+ kind: 'validation'
3458
+ }]
3459
+ };
3460
+ }
3461
+ function parseOne(file, content, kind, parse) {
3462
+ try {
3463
+ const recipe = parse(content, file);
3464
+ return {
3465
+ file,
3466
+ kind,
3467
+ content,
3468
+ id: recipe.id,
3469
+ issues: [],
3470
+ recipe
3471
+ };
3472
+ } catch (error) {
3473
+ if (!(error instanceof RecipeValidationError)) throw error;
3474
+ return {
3475
+ file,
3476
+ kind,
3477
+ content,
3478
+ issues: error.issues.map(issue => ({
3479
+ path: issue.path === '' ? '(root)' : issue.path,
3480
+ message: issue.message,
3481
+ kind: 'validation'
3482
+ }))
3483
+ };
3484
+ }
3485
+ }
3486
+
3487
+ /**
3488
+ * Writes a recipe back to its file: pretty JSON, a 2-space indent, a
3489
+ * trailing newline. Phase 0 takes the JSON tab's whole parsed object rather
3490
+ * than a patch, so this is a full replacement, not a splice: loading a file
3491
+ * the studio has not touched and saving it again keeps every key, value and
3492
+ * key order (`JSON.parse` preserves insertion order, and `JSON.stringify`
3493
+ * writes it back), but not necessarily the file's exact original formatting
3494
+ * (alignment, how arrays wrap, a trailing comment). Byte-identical
3495
+ * round-trip for a hand-formatted file is future work; see this file's test
3496
+ * for what is guaranteed today.
3497
+ *
3498
+ * @param path - The recipe file to write.
3499
+ * @param recipe - The whole recipe object.
3500
+ */
3501
+ async function saveRecipe(path, recipe) {
3502
+ await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
3503
+ }
3504
+
3505
+ /** Where the studio's own recorder profile lives when `StudioServerOptions.recorderProfileDir` is not given: never a crawl's own `session.browserProfile` directory, never the person's default browser profile. */
3506
+ function defaultRecorderProfileDir() {
3507
+ return join(tmpdir(), 'opencraw-studio', 'recorder-profile');
1713
3508
  }
1714
- function forEachSentence(step) {
1715
- const parts = [word('For each'), pillField(step, 'as', '(item)'), word('in'), typeof step.over === 'string' ? pill(step.over) : targetField(step)];
1716
- if (step.emit !== undefined) parts.push(word('→'), word('one record'));
1717
- return parts;
3509
+ /** @returns Fresh state for a new server instance: no workspace open, no run, no recording, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser, the default recorder profile directory. */
3510
+ function createStudioState() {
3511
+ return {
3512
+ sockets: new Set(),
3513
+ snapshots: createSnapshotCache(),
3514
+ lastRuns: createLastRunCache(),
3515
+ recorderProfileDir: defaultRecorderProfileDir()
3516
+ };
1718
3517
  }
1719
- function paginateSentence(step) {
1720
- const parts = [word('For every page')];
1721
- const next = step.next;
1722
- if (next !== null && typeof next === 'object') {
1723
- const record = next;
1724
- if (typeof record.selector === 'string') parts.push(word('via'), code(record.selector));else if (typeof record.url === 'string') parts.push(word('via'), code(record.url));else if (typeof record.jsonpath === 'string') parts.push(word('via'), code(record.jsonpath));
3518
+ /**
3519
+ * Sends one event to every connected client. A socket that errors while
3520
+ * sending (gone away without a clean close yet reaching us) is dropped
3521
+ * rather than left to fail every later broadcast too.
3522
+ *
3523
+ * @param state - The server state holding the connected sockets.
3524
+ * @param event - The event to send.
3525
+ */
3526
+ function broadcast(state, event) {
3527
+ const text = JSON.stringify(event);
3528
+ for (const socket of state.sockets) {
3529
+ try {
3530
+ socket.send(text);
3531
+ } catch {
3532
+ state.sockets.delete(socket);
3533
+ }
1725
3534
  }
1726
- if (typeof step.until === 'string') parts.push(word('until'), code(step.until));
1727
- return parts;
1728
3535
  }
1729
- function emitSentence(step) {
1730
- if (typeof step.output === 'string') return [word('Emit'), word('to'), pill(step.output)];
1731
- return [word('Emit')];
3536
+
3537
+ /**
3538
+ * Handles `open-workspace`: reads the folder, remembers it as the server's
3539
+ * current workspace (every later command that names a recipe resolves it
3540
+ * against this folder), and tells every connected client the workspace changed.
3541
+ *
3542
+ * @param state - The server state to update.
3543
+ * @param command - The command.
3544
+ * @returns The workspace's recipes and their issues.
3545
+ */
3546
+ async function handleOpenWorkspace(state, command) {
3547
+ const view = await openWorkspace(command.folder);
3548
+ state.folder = command.folder;
3549
+ broadcast(state, {
3550
+ type: 'workspace-changed'
3551
+ });
3552
+ return view;
1732
3553
  }
1733
3554
 
1734
3555
  /**
1735
- * Reads a recipe's `steps` as an outline. Works on whatever JSON parsed
1736
- * (not a validated `InputRecipe`): a recipe mid-edit in the JSON tab may be
1737
- * missing fields or have the wrong shape, and the outline still has to
1738
- * render something rather than crash the tab. Anything that is not a
1739
- * well-formed step becomes a custom card holding what was there; anything
1740
- * that is not an object at the top becomes an outline with no steps.
3556
+ * Handles `GET /api/pdf-bytes`: the raw bytes of a cached PDF snapshot, for
3557
+ * the PDF canvas's `pdf.js` to render in the browser (studio plan §3.4,
3558
+ * issue #94's 5b). Not a `studio-api` command — the other commands all
3559
+ * answer JSON, and pdf.js wants the file itself — so this is its own route,
3560
+ * read directly by `studio-http.use-case.ts` (mirrors `static-file.handler.ts`'s
3561
+ * own reach outside the command dispatch).
1741
3562
  *
1742
- * @param content - The recipe's parsed JSON (the JSON tab's own `JSON.parse` of its text).
1743
- * @returns The outline: the untouched top-level record plus the step tree.
3563
+ * @param state - The server state.
3564
+ * @param recipeId - The recipe.
3565
+ * @param path - The step path; must already have a cached PDF snapshot (call `take-snapshot` first).
3566
+ * @returns The raw PDF bytes.
3567
+ * @throws Error when `path` has no cached snapshot yet, or the snapshot's document is not a PDF.
1744
3568
  */
1745
- function recipeToOutline(content) {
1746
- const recipe = isRecord(content) ? content : {};
1747
- const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
1748
- return {
1749
- recipe,
1750
- steps: stepsToNodes(steps, 'steps')
1751
- };
3569
+ async function handlePdfBytes(state, recipeId, path) {
3570
+ const snapshot = cachedSnapshot(state.snapshots, recipeId, path);
3571
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${recipeId}" at "${path}": call take-snapshot first`);
3572
+ if (snapshot.format !== 'pdf') throw new Error(`"${recipeId}" at "${path}" is not a PDF snapshot`);
3573
+ return pdfBytes(snapshot.baseUrl);
1752
3574
  }
1753
- function stepsToNodes(steps, pathPrefix) {
1754
- return steps.map((step, index) => stepToNode(step, `${pathPrefix}.${index}`));
3575
+
3576
+ /**
3577
+ * Handles `pdf-view`: the PDF canvas's cells and rows (`document-view`'s
3578
+ * `pdf-view.mapper.ts`, studio plan §3.4, issue #94's 5b), off the same
3579
+ * cached snapshot `take-snapshot`/`document-tree` already use.
3580
+ *
3581
+ * @param state - The server state.
3582
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3583
+ * @returns The document's view.
3584
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read PDF.
3585
+ */
3586
+ function handlePdfView(state, command) {
3587
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3588
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3589
+ if (!isPdfDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a PDF snapshot: the PDF canvas only reads a read PDF document`);
3590
+ return pdfDocumentView(snapshot.body);
1755
3591
  }
1756
- function stepToNode(step, path) {
1757
- if (!isRecord(step)) {
1758
- return {
1759
- kind: 'card',
1760
- path,
1761
- stepType: 'unknown',
1762
- sentence: [{
1763
- kind: 'word',
1764
- text: 'custom step'
1765
- }],
1766
- step: {},
1767
- custom: true
1768
- };
3592
+
3593
+ /**
3594
+ * Turns one resolved recorded action into the recipe step it reads as
3595
+ * (issue #95's card sentences): `Click <selector>`, `Fill <selector> with
3596
+ * <value>`, `Select <selector> = <value>`, `Press <key>`, `Scroll to
3597
+ * bottom`. Pure: `recorder-session.use-case.ts` has already resolved the
3598
+ * selector (`selector-inference`) and, for a secret field, already replaced
3599
+ * `value` with its `{{env.NAME}}` placeholder (`secret-field.policy.ts`) —
3600
+ * this mapper never sees, and so can never leak, a real secret value.
3601
+ *
3602
+ * @param action - The resolved action.
3603
+ * @returns The step it becomes, or `undefined` for an action this mapper cannot place (a click/fill/select with no verified selector).
3604
+ */
3605
+ function actionToStep(action) {
3606
+ switch (action.kind) {
3607
+ case 'click':
3608
+ {
3609
+ if (action.selector === undefined) return undefined;
3610
+ const step = {
3611
+ type: 'click',
3612
+ selector: action.selector
3613
+ };
3614
+ return step;
3615
+ }
3616
+ case 'fill':
3617
+ {
3618
+ if (action.selector === undefined) return undefined;
3619
+ const step = {
3620
+ type: 'fill',
3621
+ selector: action.selector,
3622
+ value: action.value ?? ''
3623
+ };
3624
+ return step;
3625
+ }
3626
+ case 'select':
3627
+ {
3628
+ if (action.selector === undefined) return undefined;
3629
+ const step = {
3630
+ type: 'select',
3631
+ selector: action.selector,
3632
+ value: action.value
3633
+ };
3634
+ return step;
3635
+ }
3636
+ case 'keypress':
3637
+ {
3638
+ const key = action.key ?? 'Enter';
3639
+ const step = action.selector === undefined ? {
3640
+ type: 'press',
3641
+ key
3642
+ } : {
3643
+ type: 'press',
3644
+ selector: action.selector,
3645
+ key
3646
+ };
3647
+ return step;
3648
+ }
3649
+ case 'scroll':
3650
+ {
3651
+ const step = {
3652
+ type: 'scroll',
3653
+ to: 'bottom'
3654
+ };
3655
+ return step;
3656
+ }
1769
3657
  }
1770
- const stepType = typeof step.type === 'string' ? step.type : 'unknown';
1771
- const {
1772
- parts,
1773
- custom
1774
- } = cardSentence(step);
1775
- if (!isBracketStepType(stepType)) return {
1776
- kind: 'card',
1777
- path,
1778
- stepType,
1779
- sentence: parts,
1780
- step,
1781
- custom
1782
- };
1783
- const children = Array.isArray(step.steps) ? stepsToNodes(step.steps, `${path}.steps`) : [];
1784
- const elseChildren = stepType === 'if' && Array.isArray(step.else) ? stepsToNodes(step.else, `${path}.else`) : undefined;
1785
- return {
1786
- kind: 'bracket',
1787
- path,
1788
- stepType,
1789
- sentence: parts,
1790
- step,
1791
- children,
1792
- ...(elseChildren !== undefined && {
1793
- elseChildren
1794
- })
1795
- };
1796
3658
  }
1797
- function isRecord(value) {
1798
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3659
+
3660
+ /** A `rel` token that means "the next page" on an `<a>`/`<link>`. */
3661
+ const REL_NEXT = /(?:^|\s)next(?:\s|$)/i;
3662
+ /** Link text that reads as "next", loosely: "Next", "Next »", "Next page", punctuation and arrows ignored. */
3663
+ const TEXT_NEXT = /^\s*next(?:\s*page)?\s*(?:[›»>]\s*)?$/i;
3664
+ /** The selector itself names the pattern (`li.next a`, `a[aria-label=Next]`) even when the reported text/rel do not. */
3665
+ const SELECTOR_NEXT = /(?:^|[\s>.])li\.next\b|\[aria-label\s*=\s*['"]?next['"]?\]/i;
3666
+ /**
3667
+ * Whether a clicked, navigating element looks like a "next" link (issue
3668
+ * #95's exact heuristics): `rel="next"`, link text matching "next", or the
3669
+ * resolved selector itself carrying `li.next a`/`a[aria-label=Next]`. Only
3670
+ * meaningful for a click that navigated — the caller checks that first.
3671
+ *
3672
+ * @param link - The anchor's own `rel`/text/`aria-label`, as reported by `recorder-script.client.ts`.
3673
+ * @param selector - The action's resolved selector (`selector-inference`'s own candidate).
3674
+ * @returns Whether the studio should offer to turn this click into a `paginate` step.
3675
+ */
3676
+ function looksLikeNextLink(link, selector) {
3677
+ if (link?.rel !== undefined && REL_NEXT.test(link.rel)) return true;
3678
+ if (link?.ariaLabel !== undefined && /^next$/i.test(link.ariaLabel.trim())) return true;
3679
+ if (link?.text !== undefined && TEXT_NEXT.test(link.text)) return true;
3680
+ if (selector !== undefined && SELECTOR_NEXT.test(selector)) return true;
3681
+ return false;
1799
3682
  }
1800
3683
 
3684
+ /** Why a report was refused before it became an action: out of this phase's scope (issue #95). */
3685
+ const UNSUPPORTED_REASONS = ['iframe', 'shadow-dom'];
1801
3686
  /**
1802
- * Rebuilds a recipe from its outline: `recipe`'s keys, in their original
1803
- * order, with `steps` (when the recipe had one) replaced by the outline's
1804
- * (possibly edited) step tree. A card's own raw `step` is used as-is; a
1805
- * bracket's `step` supplies every field but `steps`/`else`, which come from
1806
- * `children`/`elseChildren` instead, put back at the position `step` itself
1807
- * held that key at (or appended, for a bracket a `+` menu just added, whose
1808
- * `step` never had one) — this, together with `recipe-to-outline.mapper.ts`
1809
- * always keeping the step's whole original object, is what makes
1810
- * `recipe → outline → recipe` preserve every key and its order.
3687
+ * The type/name/id of the form field a `fill`/`select` landed on, exactly as
3688
+ * the live DOM reports them — `secret-field.policy.ts`'s only input. Never
3689
+ * carries the field's value: that travels separately, on the action itself.
3690
+ */
3691
+ const recordedFieldSchema = z.object({
3692
+ type: z.string().optional(),
3693
+ name: z.string().optional(),
3694
+ id: z.string().optional()
3695
+ });
3696
+ /** What `next-link.policy.ts` needs off a clicked anchor: never the whole element. */
3697
+ const recordedLinkSchema = z.object({
3698
+ rel: z.string().optional(),
3699
+ text: z.string().optional(),
3700
+ ariaLabel: z.string().optional()
3701
+ });
3702
+ /**
3703
+ * The wire shape of one action as `recorder-script.client.ts` reports it,
3704
+ * over the Playwright binding `recorder-session.use-case.ts` exposes: the
3705
+ * `data-oc-node` id of the element involved (`selector-inference`'s own
3706
+ * marker attribute, `@opencraw/core`'s `NODE_ID_ATTRIBUTE`), never a
3707
+ * selector — the studio, not the page, decides that (studio plan §3.2,
3708
+ * issue #95). Never the field's real value when it might be a secret: the
3709
+ * script itself does not know which fields are secret (`secret-field.policy.ts`
3710
+ * runs server-side, once a `RecordedField` is known), but nothing here
3711
+ * *requires* a value to be typed out either way — `recorder-session.use-case.ts`
3712
+ * discards it the moment a secret is detected.
1811
3713
  *
1812
- * @param outline - An outline, from `recipe-to-outline.mapper.ts` or edited by the UI.
1813
- * @returns The recipe's plain JSON, ready for `save-recipe`.
3714
+ * `html` is the document's own markup, captured by the script itself at the
3715
+ * moment of the event (`document.documentElement.outerHTML`), not fetched
3716
+ * again afterwards: a click that navigates can leave the page before a
3717
+ * later `page.content()` call would resolve, reading the *next* page's
3718
+ * markup instead of the one the click actually happened on. Capturing it
3719
+ * synchronously, in the same event the node id is stamped in, means the
3720
+ * selector is always resolved against the exact document the action
3721
+ * happened on — still "verified against the page" (issue #95), just without
3722
+ * the race a second round trip would risk.
1814
3723
  */
1815
- function outlineToRecipe(outline) {
1816
- if (!('steps' in outline.recipe) && outline.steps.length === 0) return {
1817
- ...outline.recipe
1818
- };
1819
- return {
1820
- ...outline.recipe,
1821
- steps: nodesToSteps(outline.steps)
1822
- };
3724
+ const rawActionSchema = z.discriminatedUnion('kind', [z.object({
3725
+ kind: z.literal('click'),
3726
+ nodeId: z.string().min(1),
3727
+ html: z.string().min(1),
3728
+ link: recordedLinkSchema.optional()
3729
+ }), z.object({
3730
+ kind: z.literal('fill'),
3731
+ nodeId: z.string().min(1),
3732
+ html: z.string().min(1),
3733
+ value: z.string(),
3734
+ field: recordedFieldSchema.optional()
3735
+ }), z.object({
3736
+ kind: z.literal('select'),
3737
+ nodeId: z.string().min(1),
3738
+ html: z.string().min(1),
3739
+ value: z.string(),
3740
+ field: recordedFieldSchema.optional()
3741
+ }), z.object({
3742
+ kind: z.literal('keypress'),
3743
+ nodeId: z.string().min(1).optional(),
3744
+ html: z.string().optional(),
3745
+ key: z.string().min(1)
3746
+ }), z.object({
3747
+ kind: z.literal('scroll'),
3748
+ to: z.literal('bottom')
3749
+ }), z.object({
3750
+ kind: z.literal('unsupported'),
3751
+ reason: z.enum(UNSUPPORTED_REASONS)
3752
+ })]);
3753
+
3754
+ /**
3755
+ * The in-page script `recorder-session.use-case.ts` installs on every page
3756
+ * of the studio's own headed browser window (`context.addInitScript`),
3757
+ * reporting the person's actions to the binding it also exposes on the
3758
+ * context (`context.exposeBinding`) — issue #95's "a script injected into
3759
+ * every page ... reports the user's actions: click, type, select, key
3760
+ * press, scroll".
3761
+ *
3762
+ * Kept as a plain string, not a TypeScript function passed to
3763
+ * `page.evaluate`/`addInitScript`, for the same reason as
3764
+ * `page-snapshot/hidden-marks.algorithm.ts`'s `hiddenMarksScript`: it
3765
+ * references `window`/`document` as globals, which `AGENTS.md`'s "things
3766
+ * that bit before" and the repo's `isolated-functions` lint rule forbid for
3767
+ * an in-page TypeScript function.
3768
+ *
3769
+ * What it reports, and what it deliberately does not:
3770
+ *
3771
+ * - **click**: the nearest clickable ancestor (`a`, `button`,
3772
+ * `input[type=submit|button]`) of the element clicked, or the element
3773
+ * itself; for an `<a>`, also its `rel`, text and `aria-label` —
3774
+ * `next-link.policy.ts`'s only input.
3775
+ * - **fill**: on `blur` of a text-like input/textarea whose value changed
3776
+ * since it was focused (never on every keystroke) — the field's `type`,
3777
+ * `name` and `id`, and the value *as typed*; whether that value is a
3778
+ * secret, and what replaces it, is `secret-field.policy.ts`'s call,
3779
+ * server-side, not this script's.
3780
+ * - **select**: on `change` of a `<select>`.
3781
+ * - **keypress**: `Enter` only (issue #95's own example, "Press Enter");
3782
+ * every other key is noise a recipe step has no shape for.
3783
+ * - **scroll**: once per "scroll session" (debounced), when the page has
3784
+ * been scrolled within `NEAR_BOTTOM_PX` of its bottom.
3785
+ * - **unsupported**: once, the first time an event's target sits inside a
3786
+ * shadow root, or this script runs in a frame that is not the top window
3787
+ * (an iframe) — issue #95's explicit out-of-scope cases, reported instead
3788
+ * of attempted.
3789
+ *
3790
+ * Every reported element is marked with `attribute` (`data-oc-node` by
3791
+ * default, `@opencraw/core`'s `NODE_ID_ATTRIBUTE`) if it does not already
3792
+ * carry one, the same attribute `selector-inference`'s `pathToNode` reads —
3793
+ * so `recorder-session.use-case.ts` can turn a report straight into a
3794
+ * verified selector with the phase 2 generator, never inventing its own.
3795
+ *
3796
+ * @param bindingName - The name Playwright's `exposeBinding` gave the reporting function (`window[bindingName]`).
3797
+ * @param attribute - The marker attribute; defaults to `NODE_ID_ATTRIBUTE`.
3798
+ * @returns A self-invoking script, ready for `context.addInitScript({ content: recorderScript(...) })`.
3799
+ */
3800
+ function recorderScript(bindingName, attribute = NODE_ID_ATTRIBUTE) {
3801
+ return `(() => {
3802
+ if (window.top !== window.self) {
3803
+ try { window.${bindingName}({ kind: 'unsupported', reason: 'iframe' }) } catch (e) {}
3804
+ return
3805
+ }
3806
+ var attribute = ${JSON.stringify(attribute)}
3807
+ var seq = 0
3808
+ var unsupportedReported = false
3809
+ var scrollTimer = null
3810
+ var focusValues = new WeakMap()
3811
+
3812
+ function report (payload) {
3813
+ try { window.${bindingName}(payload) } catch (e) {}
3814
+ }
3815
+
3816
+ function inShadowDom (element) {
3817
+ var root = element.getRootNode ? element.getRootNode() : document
3818
+ return root !== document
3819
+ }
3820
+
3821
+ function reportUnsupportedOnce (reason) {
3822
+ if (unsupportedReported) return
3823
+ unsupportedReported = true
3824
+ report({ kind: 'unsupported', reason: reason })
3825
+ }
3826
+
3827
+ function markNode (element) {
3828
+ var id = element.getAttribute(attribute)
3829
+ if (id) return id
3830
+ seq += 1
3831
+ id = 'rec-' + Date.now().toString(36) + '-' + seq
3832
+ element.setAttribute(attribute, id)
3833
+ return id
3834
+ }
3835
+
3836
+ function clickable (element) {
3837
+ return element.closest('a, button, input[type="submit"], input[type="button"]') || element
3838
+ }
3839
+
3840
+ function isTextEntry (element) {
3841
+ var tag = element.tagName
3842
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true
3843
+ if (tag !== 'INPUT') return false
3844
+ var type = (element.getAttribute('type') || 'text').toLowerCase()
3845
+ return type !== 'submit' && type !== 'button' && type !== 'reset'
3846
+ }
3847
+
3848
+ function linkInfo (element) {
3849
+ if (element.tagName !== 'A') return undefined
3850
+ var aria = element.getAttribute('aria-label')
3851
+ return {
3852
+ rel: element.getAttribute('rel') || undefined,
3853
+ text: (element.textContent || '').trim() || undefined,
3854
+ ariaLabel: aria || undefined,
3855
+ }
3856
+ }
3857
+
3858
+ document.addEventListener('click', function (event) {
3859
+ var target = event.target
3860
+ if (!(target instanceof Element)) return
3861
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3862
+ // Clicking into a text field, a textarea or a select to focus it is not itself a recordable
3863
+ // action — the fill/select the person then does is what becomes a step, on blur/change.
3864
+ if (isTextEntry(target)) return
3865
+ var element = clickable(target)
3866
+ var id = markNode(element)
3867
+ report({ kind: 'click', nodeId: id, html: document.documentElement.outerHTML, link: linkInfo(element) })
3868
+ }, true)
3869
+
3870
+ function flushFill (target) {
3871
+ if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return
3872
+ if (!focusValues.has(target)) return
3873
+ var before = focusValues.get(target)
3874
+ focusValues.delete(target)
3875
+ if (before === target.value) return
3876
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3877
+ var id = markNode(target)
3878
+ report({
3879
+ kind: 'fill',
3880
+ nodeId: id,
3881
+ html: document.documentElement.outerHTML,
3882
+ value: target.value,
3883
+ field: { type: target.type || undefined, name: target.name || undefined, id: target.id || undefined },
3884
+ })
3885
+ }
3886
+
3887
+ document.addEventListener('focusin', function (event) {
3888
+ var target = event.target
3889
+ if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return
3890
+ focusValues.set(target, target.value)
3891
+ }, true)
3892
+
3893
+ // Losing focus (Tab, clicking elsewhere) is the ordinary way a fill is reported.
3894
+ document.addEventListener('focusout', function (event) { flushFill(event.target) }, true)
3895
+
3896
+ document.addEventListener('change', function (event) {
3897
+ var target = event.target
3898
+ if (!(target instanceof HTMLSelectElement)) return
3899
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3900
+ var id = markNode(target)
3901
+ report({
3902
+ kind: 'select',
3903
+ nodeId: id,
3904
+ html: document.documentElement.outerHTML,
3905
+ value: target.value,
3906
+ field: { type: 'select', name: target.name || undefined, id: target.id || undefined },
3907
+ })
3908
+ }, true)
3909
+
3910
+ document.addEventListener('keydown', function (event) {
3911
+ if (event.key !== 'Enter') return
3912
+ var target = event.target
3913
+ // Enter often submits the very field it was pressed in, which never blurs first — flush its
3914
+ // fill now, so typing a query then pressing Enter still reports the fill before the press.
3915
+ flushFill(target)
3916
+ var id = target instanceof Element && target !== document.body ? markNode(target) : undefined
3917
+ report({ kind: 'keypress', nodeId: id, html: id === undefined ? undefined : document.documentElement.outerHTML, key: 'Enter' })
3918
+ }, true)
3919
+
3920
+ var NEAR_BOTTOM_PX = 40
3921
+ window.addEventListener('scroll', function () {
3922
+ if (scrollTimer !== null) clearTimeout(scrollTimer)
3923
+ scrollTimer = setTimeout(function () {
3924
+ scrollTimer = null
3925
+ var atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - NEAR_BOTTOM_PX
3926
+ if (atBottom) report({ kind: 'scroll', to: 'bottom' })
3927
+ }, 300)
3928
+ }, true)
3929
+ })()`;
1823
3930
  }
1824
- function nodesToSteps(nodes) {
1825
- return nodes.map(node => nodeToStep(node));
3931
+
3932
+ /** Field names that mark a value as a secret even outside a `type="password"` input (issue #95). */
3933
+ const SECRET_NAME = /token|secret|api[-_]?key/i;
3934
+ /**
3935
+ * Whether a recorded `fill`/`select` field's typed value is a secret: a
3936
+ * `type="password"` input, or a field the person explicitly marked (`marked`,
3937
+ * a future "mark as secret" UI action — not built by this phase, but the
3938
+ * flag is honoured wherever it comes from), or a name/id matching `token`,
3939
+ * `secret` or `api[-_]?key` (issue #95's exact wording).
3940
+ *
3941
+ * @param field - The field's type/name/id, as reported by `recorder-script.client.ts`.
3942
+ * @param marked - The person explicitly marked this field as secret; default `false`.
3943
+ * @returns Whether the field's value must become `{{env.NAME}}` instead of the real text.
3944
+ */
3945
+ function isSecretField(field, marked = false) {
3946
+ if (marked) return true;
3947
+ if (field.type === 'password') return true;
3948
+ const name = field.name ?? field.id ?? '';
3949
+ return name.length > 0 && SECRET_NAME.test(name);
1826
3950
  }
1827
- function nodeToStep(node) {
1828
- if (!isBracket(node)) return node.step;
1829
- const keys = Object.keys(node.step);
1830
- const step = {};
1831
- for (const key of keys) {
1832
- if (key === 'steps') step.steps = nodesToSteps(node.children);else if (key === 'else' && node.stepType === 'if') step.else = nodesToSteps(node.elseChildren ?? []);else step[key] = node.step[key];
1833
- }
1834
- if (!keys.includes('steps')) step.steps = nodesToSteps(node.children);
1835
- if (node.stepType === 'if' && node.elseChildren !== undefined && !keys.includes('else')) step.else = nodesToSteps(node.elseChildren);
1836
- return step;
3951
+ /**
3952
+ * The `{{env.NAME}}` variable name a secret field suggests, from its own
3953
+ * name/id (falling back to `PASSWORD` for a bare `type="password"` field
3954
+ * with neither): `camelCase`/`kebab-case`/`snake_case` all become
3955
+ * `SCREAMING_SNAKE_CASE`, punctuation dropped, so `user-pass`, `userPass`
3956
+ * and `user_pass` all suggest `USER_PASS`.
3957
+ *
3958
+ * @param field - The field's type/name/id.
3959
+ * @returns A valid env-var-shaped name (letters, digits, underscores, not starting with a digit).
3960
+ */
3961
+ function secretEnvName(field) {
3962
+ const source = field.name ?? field.id ?? (field.type === 'password' ? 'password' : 'secret');
3963
+ const withBoundaries = source.replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2');
3964
+ const screaming = withBoundaries.replaceAll(/[^A-Z0-9]+/gi, '_').toUpperCase().replace(/^_+/, '').replace(/_+$/, '');
3965
+ const named = screaming.length === 0 ? 'SECRET' : screaming;
3966
+ return /^\d/.test(named) ? `_${named}` : named;
3967
+ }
3968
+ /**
3969
+ * The placeholder a secret field's value becomes in the recorded recipe:
3970
+ * never the real text, in the recipe JSON or anywhere the studio logs.
3971
+ *
3972
+ * @param field - The field's type/name/id.
3973
+ * @returns `{{env.NAME}}`.
3974
+ */
3975
+ function secretPlaceholder(field) {
3976
+ return `{{env.${secretEnvName(field)}}}`;
1837
3977
  }
1838
3978
 
1839
3979
  /**
1840
- * Opens a recipe folder: every `.json`/`.jsonl` recipe directly inside it
1841
- * (core's own directory reading, so relative `file:` URLs resolve the same
1842
- * way a real run would), parsed and, for each output recipe found, bound to
1843
- * the input recipes that name it. Nothing throws for a bad recipe or a
1844
- * recipe that does not bind: every problem is reported on its file with the
1845
- * JSON path core already computes, exactly as `opencraw validate` prints it.
3980
+ * Whether a `Wait for <selector>` step belongs between the action that just
3981
+ * happened and the next one (issue #95): after a navigation or an XHR, the
3982
+ * recorder inserts a wait for the element the person interacts with next, so
3983
+ * a replayed recipe does not race the page's own load. Nothing to wait for
3984
+ * without a next selector (a page-level key press, a scroll) — and no
3985
+ * navigation or fetch either: a recording of a page that never moves should
3986
+ * not fill up with waits nobody asked for.
3987
+ *
3988
+ * @param context - What happened since the previous action.
3989
+ * @param nextSelector - The next action's own resolved selector, when it has one.
3990
+ * @returns The wait step to insert before the next action's own step, or `undefined`.
3991
+ */
3992
+ function insertWaitStep(context, nextSelector) {
3993
+ if (nextSelector === undefined || !context.navigated && !context.xhr) return undefined;
3994
+ return {
3995
+ type: 'wait',
3996
+ selector: nextSelector
3997
+ };
3998
+ }
3999
+
4000
+ /** The name Playwright's `context.exposeBinding` gives the function `recorder-script.client.ts` calls. */
4001
+ const REPORT_BINDING = '__opencraw_recorder_report__';
4002
+ /** The studio's own recorder profile: never a crawl's own profile, never the person's default browser (issue #95). */
4003
+ const RECORDER_PROFILE_NAME = 'studio-recorder';
4004
+ /** How long a click/key press is given to start a navigation before it counts as not having navigated. */
4005
+ const NAVIGATION_GRACE_MS = 600;
4006
+ /**
4007
+ * Opens the headed browser window a flow is recorded in (issue #95, studio
4008
+ * plan §3.1's last paragraph / phase 6): a Playwright persistent context, in
4009
+ * the studio's own profile (`BrowserProfiles`, `headless: false` — the only
4010
+ * precedent this codebase already has for a headed launch,
4011
+ * `packages/core/src/browser-session/browser-profile.store.ts`), with
4012
+ * `recorder-script.client.ts` installed on every page
4013
+ * (`context.addInitScript`) and reporting through a context-wide binding
4014
+ * (`context.exposeBinding`, so a page opened mid-recording — a link with
4015
+ * `target=_blank`, say — is covered too, not only the first page).
4016
+ *
4017
+ * Each reported action is resolved into a verified selector
4018
+ * (`selector-inference`, against the page's own current markup — issue
4019
+ * #95's "verified against the page"), turned into a step
4020
+ * (`action-to-step.mapper.ts`), and broadcast as an outline card built by
4021
+ * `scope-outline`'s own `recipeToOutline` — the same sentence/bracket logic
4022
+ * every other phase's cards already go through, never a second card format.
4023
+ * A secret field's value is replaced by its `{{env.NAME}}` placeholder
4024
+ * before it reaches a step, a card, or this function's return value: the
4025
+ * real value is discarded the instant `secret-field.policy.ts` flags the
4026
+ * field, never logged, never kept.
4027
+ *
4028
+ * Recording inside an iframe or shadow DOM is out of this phase's scope
4029
+ * (issue #95): `recorder-script.client.ts` detects both and reports them as
4030
+ * `unsupported` notes instead of guessing at a selector that would not
4031
+ * verify.
1846
4032
  *
1847
- * @param folder - The workspace folder.
1848
- * @returns Every recipe file found, each with its validation and binding issues.
1849
- * @throws Error when the folder does not exist or cannot be read.
4033
+ * @param options - The start URL, the profile directory, and browser launch settings.
4034
+ * @param callbacks - Where cards and notices go as they happen.
4035
+ * @returns A handle: `stop` closes the window and returns every step recorded.
1850
4036
  */
1851
- async function openWorkspace(folder) {
1852
- const documents = await readRecipeSource(folder);
1853
- const entries = documents.map(document => parseDocument(document));
1854
- const outputs = entries.filter(isParsed('output'));
1855
- const inputs = entries.filter(isParsed('input'));
1856
- for (const output of outputs) bindOutput(output, inputs);
1857
- for (const input of inputs) {
1858
- if (outputs.every(output => output.recipe.id !== input.recipe.output)) {
1859
- input.issues.push({
1860
- path: 'output',
1861
- message: `no output recipe "${input.recipe.output}" in this workspace`,
1862
- kind: 'binding'
4037
+ async function openRecorderSession(options, callbacks) {
4038
+ const profiles = new BrowserProfiles(options.profileDir, {
4039
+ ...options.browser,
4040
+ headless: false
4041
+ });
4042
+ const session = await ensureBrowserLaunch(options.browser, () => profiles.open(RECORDER_PROFILE_NAME, {}, {}));
4043
+ const context = session.context;
4044
+ const steps = [];
4045
+ let closed = false;
4046
+ let waitPending = {
4047
+ navigated: false,
4048
+ xhr: false
4049
+ };
4050
+ let xhrSeen = false;
4051
+ context.on('response', response => {
4052
+ const type = response.request().resourceType();
4053
+ if (type === 'xhr' || type === 'fetch') xhrSeen = true;
4054
+ });
4055
+ await context.exposeBinding(REPORT_BINDING, (_source, raw) => {
4056
+ void handleRaw(raw);
4057
+ });
4058
+ await context.addInitScript({
4059
+ content: recorderScript(REPORT_BINDING)
4060
+ });
4061
+ await session.page.goto(options.startUrl);
4062
+ async function handleRaw(raw) {
4063
+ if (closed) return;
4064
+ const parsed = rawActionSchema.safeParse(raw);
4065
+ if (!parsed.success) return;
4066
+ const action = parsed.data;
4067
+ if (action.kind === 'unsupported') {
4068
+ callbacks.onNote({
4069
+ kind: 'unsupported',
4070
+ message: `recording inside ${action.reason === 'iframe' ? 'an iframe' : 'shadow DOM'} is not supported; continue outside it, or add this step by hand`
1863
4071
  });
4072
+ return;
1864
4073
  }
4074
+ await handleAction(action);
1865
4075
  }
1866
- return {
1867
- folder,
1868
- recipes: entries.map(({
1869
- file,
1870
- kind,
1871
- id,
1872
- issues,
1873
- content
1874
- }) => ({
1875
- file,
1876
- kind,
1877
- id,
1878
- issues,
1879
- text: `${JSON.stringify(content, null, 2)}\n`,
1880
- // The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
1881
- // throws, so this covers a file that fails validation too (its outline just has "custom" cards).
1882
- outline: kind === 'input' ? recipeToOutline(content) : undefined
1883
- }))
1884
- };
1885
- }
1886
- /** Binds one output recipe to the inputs that name it, filing every binding issue back on the recipe it names. */
1887
- function bindOutput(output, inputs) {
1888
- const matching = inputs.filter(input => input.recipe.output === output.recipe.id);
1889
- try {
1890
- bindRecipeSet(output.recipe, matching.map(input => input.recipe));
1891
- } catch (error) {
1892
- if (!(error instanceof RecipeBindingError)) throw error;
1893
- for (const issue of error.issues) {
1894
- const target = issue.recipeId === output.recipe.id ? output : matching.find(input => input.recipe.id === issue.recipeId);
1895
- target?.issues.push({
1896
- path: issue.path,
1897
- message: issue.message,
1898
- kind: 'binding'
4076
+ async function handleAction(action) {
4077
+ const page = session.page;
4078
+ const urlBefore = page.url();
4079
+ xhrSeen = false;
4080
+ const resolved = resolveAction(action);
4081
+ if (resolved === undefined) return;
4082
+ if (action.kind === 'click' || action.kind === 'keypress') resolved.navigated = await detectNavigation(page, urlBefore);
4083
+ if (resolved.field !== undefined && resolved.value !== undefined && isSecretField(resolved.field)) resolved.value = secretPlaceholder(resolved.field);
4084
+ const waitStep = insertWaitStep(waitPending, resolved.selector);
4085
+ if (waitStep !== undefined) pushStep(waitStep, false);
4086
+ waitPending = {
4087
+ navigated: resolved.navigated !== undefined,
4088
+ xhr: xhrSeen
4089
+ };
4090
+ const step = actionToStep(resolved);
4091
+ if (step === undefined) return;
4092
+ pushStep(step, resolved.field !== undefined && isSecretField(resolved.field));
4093
+ if (resolved.navigated !== undefined && action.kind === 'click' && looksLikeNextLink(resolved.link, resolved.selector)) {
4094
+ callbacks.onNote({
4095
+ kind: 'next-link',
4096
+ message: `this click looks like a "next" link — turn it into "For every page — click ${resolved.selector ?? ''}"?`
1899
4097
  });
1900
4098
  }
1901
4099
  }
1902
- }
1903
- function isParsed(kind) {
1904
- return entry => entry.kind === kind && entry.recipe !== undefined;
1905
- }
1906
- function parseDocument(document) {
1907
- const kind = document.content?.kind;
1908
- if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
1909
- if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
4100
+ function pushStep(step, secret) {
4101
+ steps.push(step);
4102
+ const outline = recipeToOutline({
4103
+ steps
4104
+ });
4105
+ const node = outline.steps.at(-1);
4106
+ if (node !== undefined) callbacks.onCard(node, secret);
4107
+ }
1910
4108
  return {
1911
- file: document.source,
1912
- kind: 'unknown',
1913
- content: document.content,
1914
- issues: [{
1915
- path: 'kind',
1916
- message: '"kind" is missing or not "input"/"output"',
1917
- kind: 'validation'
1918
- }]
4109
+ page: session.page,
4110
+ stop: async () => {
4111
+ if (!closed) {
4112
+ closed = true;
4113
+ await session.close();
4114
+ }
4115
+ return {
4116
+ steps: steps
4117
+ };
4118
+ }
1919
4119
  };
1920
4120
  }
1921
- function parseOne(file, content, kind, parse) {
1922
- try {
1923
- const recipe = parse(content, file);
1924
- return {
1925
- file,
1926
- kind,
1927
- content,
1928
- id: recipe.id,
1929
- issues: [],
1930
- recipe
1931
- };
1932
- } catch (error) {
1933
- if (!(error instanceof RecipeValidationError)) throw error;
1934
- return {
1935
- file,
1936
- kind,
1937
- content,
1938
- issues: error.issues.map(issue => ({
1939
- path: issue.path === '' ? '(root)' : issue.path,
1940
- message: issue.message,
1941
- kind: 'validation'
1942
- }))
1943
- };
4121
+ /**
4122
+ * Resolves one raw action's `nodeId` (when it has one — `scroll` and a
4123
+ * page-level `keypress` do not) into a verified selector, the same way
4124
+ * `infer-selector.handler.ts` does for a pick: `pathToNode` off the
4125
+ * document the action itself carries (`action.html`, captured by
4126
+ * `recorder-script.client.ts` at the moment of the event — see
4127
+ * `recorded-action.contract.ts` for why that beats a later `page.content()`),
4128
+ * `candidatesFor`/`bestCandidate` off that path.
4129
+ *
4130
+ * @param action - The raw action, `unsupported` already ruled out by the caller.
4131
+ * @returns The resolved action (selector filled in where relevant), or `undefined` when no candidate verified — the action is dropped rather than recorded with a guessed, unverified selector.
4132
+ */
4133
+ function resolveAction(action) {
4134
+ if (action.kind === 'scroll') return {
4135
+ kind: 'scroll'
4136
+ };
4137
+ if (action.kind === 'keypress' && action.nodeId === undefined) return {
4138
+ kind: 'keypress',
4139
+ key: action.key
4140
+ };
4141
+ const nodeId = action.nodeId;
4142
+ const html = action.html;
4143
+ if (nodeId === undefined || html === undefined) return undefined;
4144
+ const path = pathToNode(html, nodeId);
4145
+ if (path === undefined || crossesShadowRoot(path)) return undefined;
4146
+ const best = bestCandidate(html, candidatesFor(path), nodeId);
4147
+ if (best === undefined) return undefined;
4148
+ switch (action.kind) {
4149
+ case 'click':
4150
+ {
4151
+ return {
4152
+ kind: 'click',
4153
+ selector: best.selector,
4154
+ link: action.link
4155
+ };
4156
+ }
4157
+ case 'fill':
4158
+ {
4159
+ return {
4160
+ kind: 'fill',
4161
+ selector: best.selector,
4162
+ value: action.value,
4163
+ field: action.field
4164
+ };
4165
+ }
4166
+ case 'select':
4167
+ {
4168
+ return {
4169
+ kind: 'select',
4170
+ selector: best.selector,
4171
+ value: action.value,
4172
+ field: action.field
4173
+ };
4174
+ }
4175
+ case 'keypress':
4176
+ {
4177
+ return {
4178
+ kind: 'keypress',
4179
+ selector: best.selector,
4180
+ key: action.key
4181
+ };
4182
+ }
1944
4183
  }
1945
4184
  }
1946
-
1947
4185
  /**
1948
- * Writes a recipe back to its file: pretty JSON, a 2-space indent, a
1949
- * trailing newline. Phase 0 takes the JSON tab's whole parsed object rather
1950
- * than a patch, so this is a full replacement, not a splice: loading a file
1951
- * the studio has not touched and saving it again keeps every key, value and
1952
- * key order (`JSON.parse` preserves insertion order, and `JSON.stringify`
1953
- * writes it back), but not necessarily the file's exact original formatting
1954
- * (alignment, how arrays wrap, a trailing comment). Byte-identical
1955
- * round-trip for a hand-formatted file is future work; see this file's test
1956
- * for what is guaranteed today.
4186
+ * Whether, and where to, the page navigated within `NAVIGATION_GRACE_MS` of
4187
+ * a click or key press: polled rather than driven (the navigation, if any,
4188
+ * was started by the person in the live window, not by this function), with
4189
+ * the main frame's own navigation response (for its status) picked up
4190
+ * alongside.
1957
4191
  *
1958
- * @param path - The recipe file to write.
1959
- * @param recipe - The whole recipe object.
4192
+ * @param page - The recorder's own page.
4193
+ * @param urlBefore - `page.url()` right before the action was reported.
4194
+ * @returns The page reached and its status, or `undefined` when the URL never changed.
1960
4195
  */
1961
- async function saveRecipe(path, recipe) {
1962
- await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
4196
+ async function detectNavigation(page, urlBefore) {
4197
+ let response;
4198
+ const onResponse = candidate => {
4199
+ if (candidate.request().isNavigationRequest() && candidate.frame() === page.mainFrame()) response = candidate;
4200
+ };
4201
+ page.on('response', onResponse);
4202
+ const deadline = Date.now() + NAVIGATION_GRACE_MS;
4203
+ try {
4204
+ while (Date.now() < deadline && page.url() === urlBefore) {
4205
+ await new Promise(resolve => setTimeout(resolve, 25));
4206
+ }
4207
+ } finally {
4208
+ page.off('response', onResponse);
4209
+ }
4210
+ if (page.url() === urlBefore) return undefined;
4211
+ try {
4212
+ await page.waitForLoadState('load', {
4213
+ timeout: NAVIGATION_GRACE_MS
4214
+ });
4215
+ } catch {
4216
+ // The load event never fired within the grace period; the page still moved, so report it anyway.
4217
+ }
4218
+ return {
4219
+ url: page.url(),
4220
+ status: response?.status()
4221
+ };
1963
4222
  }
1964
4223
 
1965
- /** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser. */
1966
- function createStudioState() {
4224
+ /**
4225
+ * Handles `start-recording`: opens the studio's own headed browser window
4226
+ * on the named recipe's start point (issue #95, phase 6) and streams
4227
+ * `recording-card`/`recording-note` events as the person drives it. One
4228
+ * recording at a time, like one sample run at a time: a second
4229
+ * `start-recording` stops whatever was already open first.
4230
+ *
4231
+ * @param state - The server state: `folder` must already be set, `recorderProfileDir` names the studio's own recorder profile (never a crawl's own, never the person's default browser).
4232
+ * @param command - The command.
4233
+ * @returns `{ started: true }` once the window is open.
4234
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
4235
+ */
4236
+ async function handleStartRecording(state, command) {
4237
+ if (state.folder === undefined) throw new Error('open a workspace first');
4238
+ await state.activeRecording?.stop();
4239
+ const {
4240
+ input
4241
+ } = await loadRecipePair(state.folder, command.recipeId);
4242
+ const point = input.start[0];
4243
+ if (point === undefined) throw new Error(`recipe "${command.recipeId}" has no start point`);
4244
+ const handle = await openRecorderSession({
4245
+ startUrl: point.url,
4246
+ profileDir: state.recorderProfileDir,
4247
+ browser: state.browser
4248
+ }, {
4249
+ onCard: (node, secret) => {
4250
+ broadcast(state, {
4251
+ type: 'recording-card',
4252
+ node,
4253
+ secret
4254
+ });
4255
+ },
4256
+ onNote: note => {
4257
+ broadcast(state, {
4258
+ type: 'recording-note',
4259
+ kind: note.kind,
4260
+ message: note.message
4261
+ });
4262
+ }
4263
+ });
4264
+ state.activeRecording = handle;
1967
4265
  return {
1968
- sockets: new Set(),
1969
- snapshots: createSnapshotCache(),
1970
- lastRuns: createLastRunCache()
4266
+ started: true
1971
4267
  };
1972
4268
  }
1973
4269
  /**
1974
- * Sends one event to every connected client. A socket that errors while
1975
- * sending (gone away without a clean close yet reaching us) is dropped
1976
- * rather than left to fail every later broadcast too.
4270
+ * Handles `stop-recording`: closes the recording window, if one is open,
4271
+ * and answers with every step recorded, in order — enough for the caller to
4272
+ * build "make this the login" (`session.bootstrap`) or "keep as steps"
4273
+ * without asking the server again. Also broadcasts `recording-stopped`, so
4274
+ * every connected client (not only the one that asked) knows the window
4275
+ * closed.
1977
4276
  *
1978
- * @param state - The server state holding the connected sockets.
1979
- * @param event - The event to send.
4277
+ * @param state - The server state holding the active recording, if any.
4278
+ * @param _command - The command (carries nothing beyond its type).
4279
+ * @returns Every step recorded, in order; empty when nothing was recording.
1980
4280
  */
1981
- function broadcast(state, event) {
1982
- const text = JSON.stringify(event);
1983
- for (const socket of state.sockets) {
1984
- try {
1985
- socket.send(text);
1986
- } catch {
1987
- state.sockets.delete(socket);
1988
- }
1989
- }
4281
+ async function handleStopRecording(state, _command) {
4282
+ const recording = state.activeRecording;
4283
+ state.activeRecording = undefined;
4284
+ if (recording === undefined) return {
4285
+ steps: []
4286
+ };
4287
+ const {
4288
+ steps
4289
+ } = await recording.stop();
4290
+ broadcast(state, {
4291
+ type: 'recording-stopped',
4292
+ steps
4293
+ });
4294
+ return {
4295
+ steps
4296
+ };
1990
4297
  }
1991
4298
 
1992
4299
  /**
1993
- * Handles `open-workspace`: reads the folder, remembers it as the server's
1994
- * current workspace (every later command that names a recipe resolves it
1995
- * against this folder), and tells every connected client the workspace changed.
4300
+ * Handles `responses-seen`: the JSON responses observed while a recipe's
4301
+ * start page rendered (`page-inspector`'s `responses-seen.use-case.ts`,
4302
+ * issue #93), for the Inspect panel's "responses seen" list — picking one
4303
+ * is the seam the UI uses to switch the recipe to api mode (issue #93's
4304
+ * acceptance; the JSON tree canvas that follows is phase 5's).
1996
4305
  *
1997
- * @param state - The server state to update.
4306
+ * @param state - The server state: `folder` must already be set.
1998
4307
  * @param command - The command.
1999
- * @returns The workspace's recipes and their issues.
4308
+ * @returns The observed responses.
4309
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
2000
4310
  */
2001
- async function handleOpenWorkspace(state, command) {
2002
- const view = await openWorkspace(command.folder);
2003
- state.folder = command.folder;
2004
- broadcast(state, {
2005
- type: 'workspace-changed'
2006
- });
2007
- return view;
4311
+ async function handleResponsesSeen(state, command) {
4312
+ if (state.folder === undefined) throw new Error('open a workspace first');
4313
+ const {
4314
+ input
4315
+ } = await loadRecipePair(state.folder, command.recipeId);
4316
+ const responses = await responsesSeen(input, command.path, state.browser);
4317
+ return {
4318
+ responses
4319
+ };
2008
4320
  }
2009
4321
 
2010
4322
  /**
@@ -2167,6 +4479,25 @@ function serveStatic(root, pathname, response) {
2167
4479
  createReadStream(file).pipe(response);
2168
4480
  }
2169
4481
 
4482
+ /**
4483
+ * Handles `table-preview`: the PDF canvas's live preview of a `table`
4484
+ * extract's options (`document-view`'s `table-preview.use-case.ts`, studio
4485
+ * plan §3.4, issue #94's 5b), off the same cached snapshot `take-snapshot`
4486
+ * already captured — a pure computation over it, so this answers instantly
4487
+ * on every option change, no re-fetch or re-parse.
4488
+ *
4489
+ * @param state - The server state.
4490
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
4491
+ * @returns The matches, or `error` when an option's pattern does not compile.
4492
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
4493
+ */
4494
+ function handleTablePreview(state, command) {
4495
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
4496
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
4497
+ if (!isPdfDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a PDF snapshot: table-preview only reads a read PDF document`);
4498
+ return previewPdfTable(snapshot.body, command.options);
4499
+ }
4500
+
2170
4501
  /**
2171
4502
  * Handles `take-snapshot`: loads the named input recipe, captures its
2172
4503
  * snapshot at `command.path` (only `"start"` is captured for now — see
@@ -2182,13 +4513,24 @@ function serveStatic(root, pathname, response) {
2182
4513
  async function handleTakeSnapshot(state, command) {
2183
4514
  if (state.folder === undefined) throw new Error('open a workspace first');
2184
4515
  const cached = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2185
- if (cached !== undefined) return cached;
4516
+ if (cached !== undefined) return snapshotView(cached);
2186
4517
  const {
2187
4518
  input
2188
4519
  } = await loadRecipePair(state.folder, command.recipeId);
2189
4520
  const snapshot = await takeSnapshot(input, command.path, state.browser);
2190
4521
  putSnapshot(state.snapshots, command.recipeId, command.path, snapshot);
2191
- return snapshot;
4522
+ return snapshotView(snapshot);
4523
+ }
4524
+ /** The wire view of a cached snapshot: `html`/`nodeCount`/`baseUrl`/`format`, never `rawHtml` or the parsed `body` (server-side only — see `SnapshotResult`'s own doc comments). */
4525
+ function snapshotView(snapshot) {
4526
+ return {
4527
+ html: snapshot.html,
4528
+ nodeCount: snapshot.nodeCount,
4529
+ baseUrl: snapshot.baseUrl,
4530
+ ...(snapshot.format !== undefined && {
4531
+ format: snapshot.format
4532
+ })
4533
+ };
2192
4534
  }
2193
4535
 
2194
4536
  /**
@@ -2354,6 +4696,7 @@ async function startStudioServer(options = {}) {
2354
4696
  const state = createStudioState();
2355
4697
  if (options.initialFolder !== undefined) state.folder = options.initialFolder;
2356
4698
  state.browser = options.browser;
4699
+ if (options.recorderProfileDir !== undefined) state.recorderProfileDir = options.recorderProfileDir;
2357
4700
  const server = createServer((request, response) => {
2358
4701
  void handleRequest(request, response, token, state, uiRoot);
2359
4702
  });
@@ -2375,6 +4718,7 @@ async function startStudioServer(options = {}) {
2375
4718
  }
2376
4719
  async function stop(server, state) {
2377
4720
  await state.activeRun?.stop();
4721
+ await state.activeRecording?.stop();
2378
4722
  server.closeAllConnections();
2379
4723
  await new Promise((resolve, reject) => {
2380
4724
  server.close(error => error === undefined ? resolve() : reject(error));
@@ -2395,9 +4739,41 @@ async function handleRequest(request, response, token, state, uiRoot) {
2395
4739
  await handleCommand(request, response, state);
2396
4740
  return;
2397
4741
  }
2398
- if (url.pathname === '/' && url.searchParams.get('token') !== null) response.setHeader('set-cookie', `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`);
4742
+ if (url.pathname === '/api/pdf-bytes' && request.method === 'GET') {
4743
+ await handlePdfBytesRequest(response, state, url.searchParams);
4744
+ return;
4745
+ }
4746
+ if (url.pathname === '/' && url.searchParams.has('token')) response.setHeader('set-cookie', `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`);
2399
4747
  serveStatic(uiRoot, url.pathname, response);
2400
4748
  }
4749
+ /**
4750
+ * `GET /api/pdf-bytes?recipeId=…&path=…`: the raw bytes of a cached PDF
4751
+ * snapshot (`pdf-bytes.handler.ts`, studio plan §3.4, issue #94's 5b) — the
4752
+ * PDF canvas's `pdf.js` fetches this URL directly, so it answers bytes, not
4753
+ * the JSON envelope every `/api/command` response uses.
4754
+ */
4755
+ async function handlePdfBytesRequest(response, state, query) {
4756
+ const recipeId = query.get('recipeId');
4757
+ const path = query.get('path');
4758
+ if (recipeId === null || path === null) {
4759
+ respondJson(response, 400, {
4760
+ error: '"recipeId" and "path" are required query params'
4761
+ });
4762
+ return;
4763
+ }
4764
+ try {
4765
+ const bytes = await handlePdfBytes(state, recipeId, path);
4766
+ response.writeHead(200, {
4767
+ 'content-type': 'application/pdf',
4768
+ 'content-length': bytes.byteLength
4769
+ });
4770
+ response.end(Buffer.from(bytes));
4771
+ } catch (error) {
4772
+ respondJson(response, 500, {
4773
+ error: error instanceof Error ? error.message : String(error)
4774
+ });
4775
+ }
4776
+ }
2401
4777
  async function handleCommand(request, response, state) {
2402
4778
  let command;
2403
4779
  try {
@@ -2466,6 +4842,50 @@ function dispatch(command, state) {
2466
4842
  {
2467
4843
  return handleExplainWhy(state, command);
2468
4844
  }
4845
+ case 'inspect-page':
4846
+ {
4847
+ return Promise.resolve(handleInspectPage(state, command));
4848
+ }
4849
+ case 'responses-seen':
4850
+ {
4851
+ return handleResponsesSeen(state, command);
4852
+ }
4853
+ case 'document-tree':
4854
+ {
4855
+ return Promise.resolve(handleDocumentTree(state, command));
4856
+ }
4857
+ case 'pdf-view':
4858
+ {
4859
+ return Promise.resolve(handlePdfView(state, command));
4860
+ }
4861
+ case 'table-preview':
4862
+ {
4863
+ return Promise.resolve(handleTablePreview(state, command));
4864
+ }
4865
+ case 'grid-view':
4866
+ {
4867
+ return handleGridView(state, command);
4868
+ }
4869
+ case 'grid-preview':
4870
+ {
4871
+ return Promise.resolve(handleGridPreview(state, command));
4872
+ }
4873
+ case 'deck-view':
4874
+ {
4875
+ return Promise.resolve(handleDeckView(state, command));
4876
+ }
4877
+ case 'deck-preview':
4878
+ {
4879
+ return Promise.resolve(handleDeckPreview(state, command));
4880
+ }
4881
+ case 'start-recording':
4882
+ {
4883
+ return handleStartRecording(state, command);
4884
+ }
4885
+ case 'stop-recording':
4886
+ {
4887
+ return handleStopRecording(state);
4888
+ }
2469
4889
  }
2470
4890
  }
2471
4891
  function respondJson(response, status, body) {
@@ -2522,5 +4942,5 @@ async function main(argv) {
2522
4942
  process.on('SIGTERM', shutdown);
2523
4943
  }
2524
4944
 
2525
- export { explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, missingWhyTargetSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, rejectedWhyTargetSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
4945
+ export { deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, main, missingWhyTargetSchema, observedResponseSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
2526
4946
  //# sourceMappingURL=index.esm.js.map