@opencraw/studio 0.0.31 → 0.0.33

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 (49) hide show
  1. package/dist/document-view/index.d.ts +1 -1
  2. package/dist/document-view/workbook-view.mapper.d.ts +17 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.esm.js +658 -49
  5. package/dist/page-snapshot/blocked-page.algorithm.d.ts +10 -0
  6. package/dist/page-snapshot/consent-banner.algorithm.d.ts +23 -0
  7. package/dist/page-snapshot/detect-url.use-case.d.ts +13 -0
  8. package/dist/page-snapshot/index.d.ts +4 -0
  9. package/dist/page-snapshot/inline-assets.mapper.d.ts +29 -0
  10. package/dist/page-snapshot/inline-local-images.use-case.d.ts +19 -0
  11. package/dist/page-snapshot/take-snapshot.use-case.d.ts +2 -0
  12. package/dist/recipe-workspace/import-document.use-case.d.ts +5 -3
  13. package/dist/selector-inference/selector-ranking.policy.d.ts +1 -1
  14. package/dist/src/document-view/index.d.ts +1 -1
  15. package/dist/src/document-view/workbook-view.mapper.d.ts +17 -1
  16. package/dist/src/index.d.ts +2 -2
  17. package/dist/src/page-snapshot/blocked-page.algorithm.d.ts +10 -0
  18. package/dist/src/page-snapshot/consent-banner.algorithm.d.ts +23 -0
  19. package/dist/src/page-snapshot/detect-url.use-case.d.ts +13 -0
  20. package/dist/src/page-snapshot/index.d.ts +4 -0
  21. package/dist/src/page-snapshot/inline-assets.mapper.d.ts +29 -0
  22. package/dist/src/page-snapshot/inline-local-images.use-case.d.ts +19 -0
  23. package/dist/src/page-snapshot/take-snapshot.use-case.d.ts +2 -0
  24. package/dist/src/recipe-workspace/import-document.use-case.d.ts +5 -3
  25. package/dist/src/selector-inference/selector-ranking.policy.d.ts +1 -1
  26. package/dist/src/studio-api/command.contract.d.ts +31 -0
  27. package/dist/src/studio-api/deck-view.contract.d.ts +2 -0
  28. package/dist/src/studio-api/detect-url-view.contract.d.ts +24 -0
  29. package/dist/src/studio-api/grid-view.contract.d.ts +15 -0
  30. package/dist/src/studio-api/index.d.ts +8 -6
  31. package/dist/src/studio-api/infer-selector-view.contract.d.ts +86 -0
  32. package/dist/src/studio-api/snapshot-view.contract.d.ts +1 -0
  33. package/dist/src/studio-server/detect-url.handler.d.ts +10 -0
  34. package/dist/src/studio-server/grid-view.handler.d.ts +12 -1
  35. package/dist/studio-api/command.contract.d.ts +31 -0
  36. package/dist/studio-api/deck-view.contract.d.ts +2 -0
  37. package/dist/studio-api/detect-url-view.contract.d.ts +24 -0
  38. package/dist/studio-api/grid-view.contract.d.ts +15 -0
  39. package/dist/studio-api/index.d.ts +8 -6
  40. package/dist/studio-api/infer-selector-view.contract.d.ts +86 -0
  41. package/dist/studio-api/snapshot-view.contract.d.ts +1 -0
  42. package/dist/studio-server/detect-url.handler.d.ts +10 -0
  43. package/dist/studio-server/grid-view.handler.d.ts +12 -1
  44. package/dist/ui/assets/index-Db6-8OgZ.js +42 -0
  45. package/dist/ui/assets/{pdf-CtmGNMM2.js → pdf-rdp2xUbL.js} +1 -1
  46. package/dist/ui/index.html +11 -2
  47. package/dist/ui/opencraw-logo.svg +28 -0
  48. package/package.json +3 -3
  49. package/dist/ui/assets/index-CMzc4-FL.js +0 -40
package/dist/index.esm.js CHANGED
@@ -1,9 +1,9 @@
1
- import { join, dirname, basename, extname, isAbsolute, relative, resolve, normalize, sep } from 'node:path';
1
+ import { join, dirname, extname, relative, sep, basename, isAbsolute, resolve, normalize } from 'node:path';
2
2
  import { createServer } from 'node:http';
3
3
  import { promisify, stripVTControlCharacters } from 'node:util';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
- import { BODY_KINDS, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, marginRows, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
6
+ import { BODY_KINDS, formatOfResponse, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, marginRows, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
7
7
  import { randomBytes, createHash } from 'node:crypto';
8
8
  import { execFile } from 'node:child_process';
9
9
  import { createRequire } from 'node:module';
@@ -52,6 +52,11 @@ const openWorkspaceCommandSchema = z.object({
52
52
  type: z.literal('open-workspace'),
53
53
  folder: z.string().min(1)
54
54
  });
55
+ /** Says what a page address returns, so a recipe is started the right way: a JSON API is read with a request, a page with a browser. */
56
+ const detectUrlCommandSchema = z.object({
57
+ type: z.literal('detect-url'),
58
+ url: z.string().min(1)
59
+ });
55
60
  /** Says what a path is (folder, recipe or document) and where to open it: how a link or an IDE entry opens a path (issue #235). `workspace` is the folder a document should belong to, when the caller has one. */
56
61
  const openPathCommandSchema = z.object({
57
62
  type: z.literal('open-path'),
@@ -134,7 +139,9 @@ const inferSelectorCommandSchema = z.object({
134
139
  type: z.literal('infer-selector'),
135
140
  recipeId: z.string().min(1),
136
141
  path: z.string().min(1),
137
- nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
142
+ nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema]),
143
+ /** The selector of the items of a list the recipe already loops over: a single pick inside one of them answers as a field of the item, to add to the loop. */
144
+ within: z.string().min(1).optional()
138
145
  });
139
146
  /**
140
147
  * The Inspect panel's DOM tree and data-in-the-page findings, off the same
@@ -234,6 +241,18 @@ const gridViewCommandSchema = z.object({
234
241
  delimiter: z.string().min(1).optional(),
235
242
  encoding: z.string().min(1).optional()
236
243
  });
244
+ /**
245
+ * `grid-rows`: a run of one sheet's rows of a big CSV/spreadsheet, for the grid canvas that holds only the screens around where it is
246
+ * scrolled to. The first window comes with `grid-view`; the rest are asked for as the person scrolls.
247
+ */
248
+ const gridRowsCommandSchema = z.object({
249
+ type: z.literal('grid-rows'),
250
+ recipeId: z.string().min(1),
251
+ path: z.string().min(1),
252
+ sheet: z.int().min(0),
253
+ from: z.int().min(0),
254
+ count: z.int().min(1).max(1000)
255
+ });
237
256
  /** 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`. */
238
257
  const gridPreviewOptionsSchema = z.object({
239
258
  sheet: z.string().min(1).optional(),
@@ -354,7 +373,7 @@ const importDocumentCommandSchema = z.object({
354
373
  name: z.string().min(1),
355
374
  bytes: z.string()
356
375
  });
357
- const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
376
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, detectUrlCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridRowsCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
358
377
 
359
378
  /**
360
379
  * What `take-snapshot` answers with: the rewritten document ready for the
@@ -368,7 +387,9 @@ const snapshotViewSchema = z.object({
368
387
  html: z.string(),
369
388
  nodeCount: z.number(),
370
389
  baseUrl: z.string(),
371
- format: z.enum(BODY_KINDS).optional()
390
+ format: z.enum(BODY_KINDS).optional(),
391
+ /** Something to tell the person about the page: it is a bot check or an access-denied page, not the content. */
392
+ notice: z.string().optional()
372
393
  });
373
394
 
374
395
  /**
@@ -389,18 +410,29 @@ const verifySelectorViewSchema = z.object({
389
410
 
390
411
  /** The candidate tiers `selector-inference`'s ranking works through, best first — see `selector-candidates.algorithm.ts`. */
391
412
  const candidateTierSchema = z.enum(['id', 'data-attr', 'class', 'structure', 'position']);
413
+ /** Another selector that reaches the clicked node, verified against the snapshot, for the person to choose instead of the best one. */
414
+ const selectorAlternativeSchema = z.object({
415
+ selector: z.string(),
416
+ tier: candidateTierSchema,
417
+ matches: z.number()
418
+ });
392
419
  /** One clicked node's field: the best selector found for it, verified against the snapshot. */
393
420
  const fieldPickSchema = z.object({
394
421
  selector: z.string(),
395
422
  tier: candidateTierSchema,
396
423
  take: z.string(),
397
- matches: z.number()
424
+ matches: z.number(),
425
+ /** Every verified selector that reaches the node, best first (the chosen one among them), for the options the person is shown. */
426
+ alternatives: z.array(selectorAlternativeSchema).optional(),
427
+ /** The node's own id when it looks generated (so it is not used in any selector above), to tell the person why. */
428
+ generatedId: z.string().optional()
398
429
  });
399
430
  /** A list's item shape: the best selector that matches every item, verified against the snapshot. */
400
431
  const itemPickSchema = z.object({
401
432
  selector: z.string(),
402
433
  tier: candidateTierSchema,
403
- matches: z.number()
434
+ matches: z.number(),
435
+ alternatives: z.array(selectorAlternativeSchema).optional()
404
436
  });
405
437
  /**
406
438
  * What `infer-selector` answers with (issue #91, studio plan §3.2):
@@ -417,6 +449,9 @@ const inferSelectorViewSchema = z.discriminatedUnion('kind', [z.object({
417
449
  kind: z.literal('list'),
418
450
  item: itemPickSchema,
419
451
  field: fieldPickSchema
452
+ }), z.object({
453
+ kind: z.literal('item-field'),
454
+ field: fieldPickSchema
420
455
  }), z.object({
421
456
  kind: z.literal('unsupported'),
422
457
  reason: z.string()
@@ -585,6 +620,14 @@ const sampleRunViewSchema = z.object({
585
620
  trace: z.array(z.string())
586
621
  });
587
622
 
623
+ /**
624
+ * `detect-url`'s answer: what the address returns, so "Load ▾ Web page" starts the right recipe: `html` is a page (opened in a
625
+ * browser); `json`, `pdf`, `csv`, `xlsx`, `markdown`, `xml`... are documents (read with a request); `unknown` when it could not be fetched.
626
+ */
627
+ const detectUrlViewSchema = z.object({
628
+ kind: z.enum(['html', 'json', 'jsonl', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown', 'xml', 'docx', 'unknown'])
629
+ });
630
+
588
631
  /** What `fetch-start-page` answers with. */
589
632
  const startPageViewSchema = z.object({
590
633
  html: z.string()
@@ -746,6 +789,8 @@ const gridSheetViewSchema = z.object({
746
789
  hiddenRows: z.array(z.number().int()),
747
790
  columnCount: z.number().int(),
748
791
  rows: z.array(z.array(gridCellViewSchema)),
792
+ /** How many rows the sheet has, set only when `rows` holds just the first window of them (a big file); the rest come from `grid-rows`. */
793
+ rowCount: z.number().int().optional(),
749
794
  merges: z.array(gridMergeViewSchema),
750
795
  /** Column widths in characters by 0-based column, as the spreadsheet stores them (absent for a CSV, and for a column the file does not size). */
751
796
  columnWidths: z.record(z.string(), z.number()).optional(),
@@ -756,6 +801,10 @@ const gridSheetViewSchema = z.object({
756
801
  /** The height of a row the file does not size, in points. */
757
802
  defaultRowHeight: z.number().optional()
758
803
  });
804
+ /** What `grid-rows` answers with: a run of one sheet's rows. */
805
+ const gridRowsViewSchema = z.object({
806
+ rows: z.array(z.array(gridCellViewSchema))
807
+ });
759
808
  const csvFormatViewSchema = z.object({
760
809
  encoding: z.string(),
761
810
  delimiter: z.string()
@@ -1041,6 +1090,72 @@ async function handleChooseFolder(state) {
1041
1090
  };
1042
1091
  }
1043
1092
 
1093
+ /** How much of the start of a response is looked at: enough for the engine to tell a PDF or an Office package from its bytes, and for JSON to start. */
1094
+ const LOOKED_AT_BYTES = 65_536;
1095
+ /** How long to wait for an address to start answering. */
1096
+ const TIMEOUT_MS = 20_000;
1097
+ /**
1098
+ * What an address returns: the format the engine reads its response as (content type, then what the first bytes
1099
+ * show, then the file extension in the address), and, for an API that serves JSON as `text/plain` (common), JSON when
1100
+ * the text starts like an object or an array. Only the start of the response is read: a large file is not downloaded.
1101
+ * An address that cannot be fetched is `unknown`; the recipe started from it then reports why on its first snapshot.
1102
+ *
1103
+ * @param url - The address.
1104
+ * @returns The kind: `html` is a page to open in a browser, anything else a document to read.
1105
+ */
1106
+ async function detectUrlKind(url) {
1107
+ const controller = new AbortController();
1108
+ const timer = setTimeout(() => {
1109
+ controller.abort();
1110
+ }, TIMEOUT_MS);
1111
+ try {
1112
+ const response = await fetch(url, {
1113
+ signal: controller.signal,
1114
+ headers: {
1115
+ 'user-agent': 'Mozilla/5.0 (compatible; OpenCrawStudio)',
1116
+ 'accept': '*/*'
1117
+ }
1118
+ });
1119
+ const bytes = await firstBytes(response);
1120
+ const named = downloadName(response.headers.get('content-disposition'));
1121
+ const where = named === undefined ? response.url === '' ? url : response.url : `https://download.invalid/${named}`;
1122
+ const format = formatOfResponse(response.headers.get('content-type') ?? '', where, bytes, response.ok);
1123
+ if (format === 'text' && /^[[{]/.test(new TextDecoder().decode(bytes).trimStart())) return 'json';
1124
+ return format;
1125
+ } catch {
1126
+ return 'unknown';
1127
+ } finally {
1128
+ clearTimeout(timer);
1129
+ controller.abort();
1130
+ }
1131
+ }
1132
+ /** The first bytes of a response body; the rest is never downloaded. */
1133
+ async function firstBytes(response) {
1134
+ const reader = response.body?.getReader();
1135
+ if (reader === undefined) return new Uint8Array();
1136
+ const chunks = [];
1137
+ let length = 0;
1138
+ while (length < LOOKED_AT_BYTES) {
1139
+ const {
1140
+ done,
1141
+ value
1142
+ } = await reader.read();
1143
+ if (done) break;
1144
+ chunks.push(value);
1145
+ length += value.length;
1146
+ }
1147
+ try {
1148
+ await reader.cancel();
1149
+ } catch {
1150
+ // The rest of the body was not wanted anyway.
1151
+ }
1152
+ return Buffer.concat(chunks);
1153
+ }
1154
+ /** The file name a download is offered under (`Content-Disposition: attachment; filename=series.csv`): an address like `/generator?format=csv` says nothing about it. */
1155
+ function downloadName(disposition) {
1156
+ return disposition === null ? undefined : /filename=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)?.[1]?.trim();
1157
+ }
1158
+
1044
1159
  const execFileAsync = promisify(execFile);
1045
1160
  /** `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. */
1046
1161
  function playwrightCliPath() {
@@ -1200,6 +1315,102 @@ function textOf$2(body) {
1200
1315
  return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
1201
1316
  }
1202
1317
 
1318
+ /** What a bot-protection or access-denied page says about itself, in the words the common services use. */
1319
+ const BLOCKED_PATTERNS = [/performing security verification/i, /verify(?:ing)? (?:that )?you are (?:a )?human/i, /are you a robot/i, /checking your browser/i, /enable javascript and cookies to continue/i, /just a moment\.\.\./i, /attention required/i, /access denied/i, /request (?:was )?blocked/i, /unusual traffic/i, /pardon our interruption/i];
1320
+ /**
1321
+ * Whether a page is the site's bot check or an access-denied page instead of its content, and, if so, what to tell the person: the
1322
+ * Studio's browser was turned away, so everything else on the screen is not the page they asked for.
1323
+ *
1324
+ * @param text - The page's title and the start of its visible text.
1325
+ * @param status - The HTTP status the page came with, when known.
1326
+ * @returns A sentence for the person, or `undefined` when the page looks like a page.
1327
+ */
1328
+ function blockedPageNotice(text, status) {
1329
+ const turnedAway = status === 403 || status === 429;
1330
+ if (!turnedAway && BLOCKED_PATTERNS.every(pattern => !pattern.test(text))) return undefined;
1331
+ if (turnedAway && text.trim().length > 4000) return undefined;
1332
+ return 'This site is showing a security check or an access-denied page instead of its content: it has turned the Studio\'s browser away. Try again later, or look for the data the site itself offers (a download, a feed or an API).';
1333
+ }
1334
+
1335
+ /** The attribute a pop-up that covers the page is marked with in the snapshot; the picture leaves it out unless "Show hidden" is on. */
1336
+ /**
1337
+ * A script that, in a page, finds a cookie or consent banner's button and presses it: "Reject all", "Only necessary cookies" and the
1338
+ * like first, else "Accept all", "I agree"... in English, Portuguese, Spanish, French, Italian, German and Dutch, and by the ids the common
1339
+ * consent tools give their buttons (OneTrust, Didomi, Cookiebot, Usercentrics, Osano, iubenda). A banner that is left in place covers the
1340
+ * page in the Studio's view of it, and the Studio's own clicks (to pick a value) would land on it. Only buttons whose whole text is one
1341
+ * of these are pressed, so a "Cookie settings" or "Gerir os cookies" button, a link to a policy and the page's own buttons are left alone.
1342
+ * The answer is the text of the button pressed, or an empty string when there was none.
1343
+ *
1344
+ * It only ever runs in the browser the Studio captures the page with, never in a crawl.
1345
+ */
1346
+ const DISMISS_CONSENT_SCRIPT = String.raw`(() => {
1347
+ const rejectIds = ['#onetrust-reject-all-handler', '#didomi-notice-disagree-button', '#CybotCookiebotDialogBodyButtonDecline', '[data-testid=uc-deny-all-button]', '.osano-cm-denyAll', '.iubenda-cs-reject-btn', '.cmp-button_button--reject', '#truste-consent-required', 'button[id*=reject i]', 'button[id*=decline i]']
1348
+ const acceptIds = ['#onetrust-accept-btn-handler', '#didomi-notice-agree-button', '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll', '[data-testid=uc-accept-all-button]', '.osano-cm-accept-all', '.iubenda-cs-accept-btn', 'button[id*=accept i]']
1349
+ const rejectWords = ['reject', 'decline', 'deny', 'refuse', 'rejeitar', 'recusar', 'nao aceitar', 'rechazar', 'refuser', 'rifiuta', 'rifiutare', 'ablehnen', 'weigeren', 'only necessary', 'only essential', 'necessary only', 'essential only', 'apenas necessarios', 'apenas essenciais', 'somente necessarios', 'solo necesarias', 'solo necessari', 'uniquement necessaires', 'uniquement essentiels', 'nur notwendige', 'nur erforderliche', 'alleen noodzakelijke', 'continue without accepting', 'continuar sem aceitar', 'continuar sin aceptar', 'continuer sans accepter', 'continua senza accettare', 'no thanks', 'nao obrigado']
1350
+ const acceptWords = ['accept', 'allow', 'agree', 'i agree', 'i accept', 'ok', 'okay', 'got it', 'yes i agree', 'yes these cookies are ok', 'aceitar', 'aceito', 'concordo', 'permitir', 'aceptar', 'acepto', 'permitir todas', 'accepter', 'j accepte', 'accetta', 'accetto', 'akzeptieren', 'zustimmen', 'einverstanden', 'accepteren']
1351
+ const tail = '(?:\\s+(?:all|todos|todas|todo|tudo|tout|tous|tutti|tutte|alle|alles|os|as|las|los|les|i|le|the|die))*(?:\\s+(?:cookies?|kekse|koekjes))?'
1352
+ const rejectText = new RegExp('^(?:' + rejectWords.join('|') + ')' + tail + '$')
1353
+ const acceptText = new RegExp('^(?:' + acceptWords.join('|') + ')' + tail + '$')
1354
+ const visible = (element) => {
1355
+ const box = element.getBoundingClientRect()
1356
+ const style = getComputedStyle(element)
1357
+
1358
+ return box.width > 0 && box.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
1359
+ }
1360
+ const plain = (text) => text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z\s]/g, ' ').replace(/\s+/g, ' ').trim()
1361
+ const label = (element) => (element.innerText || element.value || element.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ')
1362
+ const byId = (selectors) => {
1363
+ for (const selector of selectors) {
1364
+ const found = [...document.querySelectorAll(selector)].find(visible)
1365
+ if (found !== undefined) return found
1366
+ }
1367
+
1368
+ return undefined
1369
+ }
1370
+ const candidates = [...document.querySelectorAll('button, a, [role=button], input[type=button], input[type=submit]')].filter(visible)
1371
+ const byText = (pattern) => candidates.find((element) => pattern.test(plain(label(element))))
1372
+ const target = byId(rejectIds) ?? byText(rejectText) ?? byId(acceptIds) ?? byText(acceptText)
1373
+ if (target === undefined) return ''
1374
+ const text = label(target)
1375
+ target.click()
1376
+
1377
+ return text || target.id
1378
+ })()`;
1379
+ /**
1380
+ * A script that marks, in the page, every pop-up that covers it: something fixed to the window that fills most of it (a dimming backdrop,
1381
+ * a modal), a modal dialog, or a banner a consent tool put there. The picture of the page leaves them out (the page behind is what the
1382
+ * person wants to pick from), and "Show hidden" brings them back. Run after the consent button has been tried: a banner that went away
1383
+ * needs nothing, one that stayed (its button in a form the script does not know, an icon with no text) is covered here.
1384
+ *
1385
+ * Only the outermost element of an overlay is marked, since hiding it hides all it holds.
1386
+ */
1387
+ const MARK_OVERLAYS_SCRIPT = `(() => {
1388
+ const attribute = 'data-oc-overlay'
1389
+ const consentName = /cookie|consent|gdpr|privacy|onetrust|ot-sdk|didomi|cookiebot|usercentrics|trustarc|truste|osano|iubenda|cmp|banner-modal/i
1390
+ const width = window.innerWidth
1391
+ const height = window.innerHeight
1392
+ const marked = []
1393
+ for (const element of document.querySelectorAll('body *')) {
1394
+ if (marked.some((outer) => outer.contains(element))) continue
1395
+ const style = getComputedStyle(element)
1396
+ if (style.position !== 'fixed' || style.display === 'none' || style.visibility === 'hidden') continue
1397
+ const box = element.getBoundingClientRect()
1398
+ if (box.width === 0 || box.height === 0) continue
1399
+ const covers = box.width >= width * 0.6 && box.height >= height * 0.5
1400
+ const modal = element.getAttribute('aria-modal') === 'true' || element.getAttribute('role') === 'dialog' || element.getAttribute('role') === 'alertdialog'
1401
+ const named = consentName.test((element.id || '') + ' ' + (typeof element.className === 'string' ? element.className : ''))
1402
+ // A page built as one fixed, scrolling frame is the page, not a pop-up on it.
1403
+ if (covers && !modal && !named && (element.innerText || '').length > document.body.innerText.length * 0.6) continue
1404
+ // A bar along an edge (a site's own header or footer) is not a pop-up unless a consent tool named it.
1405
+ if (covers || modal || named) {
1406
+ element.setAttribute(attribute, '1')
1407
+ marked.push(element)
1408
+ }
1409
+ }
1410
+
1411
+ return marked.length
1412
+ })()`;
1413
+
1203
1414
  /** The attribute `hiddenMarksScript` stamps on an element hidden on the live page. */
1204
1415
  const HIDDEN_ATTRIBUTE = 'data-oc-hidden';
1205
1416
  /**
@@ -1235,6 +1446,161 @@ function hiddenMarksScript(attribute = HIDDEN_ATTRIBUTE) {
1235
1446
  })()`;
1236
1447
  }
1237
1448
 
1449
+ const FONT_TYPES = {
1450
+ woff2: 'font/woff2',
1451
+ woff: 'font/woff',
1452
+ ttf: 'font/ttf',
1453
+ otf: 'font/otf',
1454
+ eot: 'application/vnd.ms-fontobject',
1455
+ svg: 'image/svg+xml'
1456
+ };
1457
+ /** `url(...)` in a stylesheet, quoted or not. */
1458
+ const CSS_URL = /url\(\s*(['"]?)([^'"\s)]+)\1\s*\)/g;
1459
+ /** The address a `url()` in a stylesheet at `base` points to, or `undefined` for one that is not an address (inline data, a fragment). */
1460
+ function resolved(reference, base) {
1461
+ if (reference.startsWith('data:') || reference.startsWith('#')) return undefined;
1462
+ try {
1463
+ return new URL(reference, base).href;
1464
+ } catch {
1465
+ return undefined;
1466
+ }
1467
+ }
1468
+ function fontType(address) {
1469
+ const extension = /\.([a-z0-9]+)(?:[?#]|$)/i.exec(new URL(address).pathname + new URL(address).search)?.[1]?.toLowerCase();
1470
+ return (extension === undefined ? undefined : FONT_TYPES[extension]) ?? 'application/octet-stream';
1471
+ }
1472
+ /**
1473
+ * A stylesheet's text with every `url(...)` made absolute, and each font the page loaded embedded as a data address. The picture of a page
1474
+ * is shown in a frame that is not the site, so a font file is a cross-site request the site's server does not allow: its text comes
1475
+ * out in a fallback face and its icons (a menu, a close button) as empty boxes. A font inside the page cannot be refused.
1476
+ *
1477
+ * @param css - The stylesheet text.
1478
+ * @param base - Where the stylesheet came from (or the page, for a `<style>` in it).
1479
+ * @param assets - What the page loaded, by address.
1480
+ * @returns The stylesheet, ready to sit inside the snapshot.
1481
+ */
1482
+ function embedFonts(css, base, assets) {
1483
+ // eslint-disable-next-line unicorn/prefer-string-replace-all -- one replacer with a global pattern
1484
+ return css.replace(CSS_URL, (whole, _quote, reference) => {
1485
+ const address = resolved(reference.trim(), base);
1486
+ if (address === undefined) return whole;
1487
+ const asset = assets.get(address);
1488
+ if (asset?.kind !== 'font') return `url("${address}")`;
1489
+ return `url("data:${fontType(address)};base64,${Buffer.from(asset.body).toString('base64')}")`;
1490
+ });
1491
+ }
1492
+ /**
1493
+ * Turns a captured page into one that carries its own styles: every `<link rel="stylesheet">` the page loaded becomes a `<style>` with
1494
+ * the stylesheet's text, and the fonts in it (and in the page's own `<style>`s) are embedded. A stylesheet that was not captured is left
1495
+ * as the link it was.
1496
+ *
1497
+ * @param html - The page as captured.
1498
+ * @param pageUrl - The page's address.
1499
+ * @param assets - The stylesheets and fonts the page loaded, by address.
1500
+ * @returns The page with its styles and fonts inside it.
1501
+ */
1502
+ function inlineAssets(html, pageUrl, assets) {
1503
+ const $ = load(html);
1504
+ $('link[rel~="stylesheet"][href]').each((_index, element) => {
1505
+ const reference = $(element).attr('href') ?? '';
1506
+ const address = resolved(reference, pageUrl);
1507
+ const asset = address === undefined ? undefined : assets.get(address);
1508
+ if (address === undefined || asset?.kind !== 'css') return;
1509
+ const css = embedFonts(Buffer.from(asset.body).toString('utf8'), address, assets);
1510
+ const inlined = $('<style></style>').attr('data-oc-from', address);
1511
+ inlined.text(css);
1512
+ $(element).replaceWith(inlined);
1513
+ });
1514
+ $('style').each((_index, element) => {
1515
+ if ($(element).attr('data-oc-from') !== undefined) return;
1516
+ const css = $(element).text();
1517
+ if (css.includes('url(')) $(element).text(embedFonts(css, pageUrl, assets));
1518
+ });
1519
+ return $.html();
1520
+ }
1521
+
1522
+ const IMAGE_TYPES = {
1523
+ '.png': 'image/png',
1524
+ '.jpg': 'image/jpeg',
1525
+ '.jpeg': 'image/jpeg',
1526
+ '.gif': 'image/gif',
1527
+ '.webp': 'image/webp',
1528
+ '.svg': 'image/svg+xml',
1529
+ '.avif': 'image/avif',
1530
+ '.bmp': 'image/bmp'
1531
+ };
1532
+ /** The most one picture may weigh to be embedded, and the most all of a document's pictures may (bytes). */
1533
+ const MAX_IMAGE_BYTES = 1_500_000;
1534
+ const MAX_TOTAL_BYTES = 8_000_000;
1535
+ /** An image address made absolute against the document's own; one that cannot be read as an address is left as it is. */
1536
+ function absoluteAddress(source, documentUrl) {
1537
+ try {
1538
+ return new URL(source, documentUrl).href;
1539
+ } catch {
1540
+ return source;
1541
+ }
1542
+ }
1543
+ /** A file's bytes, or `undefined` when it cannot be read. */
1544
+ async function readIfThere(path) {
1545
+ try {
1546
+ return await readFile(path);
1547
+ } catch {
1548
+ return undefined;
1549
+ }
1550
+ }
1551
+ /** The file a `file:` image address names, when it sits in the document's own folder or below it; `undefined` for anything else (another site, another folder). */
1552
+ function localPathOf(source, documentFolder) {
1553
+ if (!source.startsWith('file:')) return undefined;
1554
+ let path;
1555
+ try {
1556
+ path = fileURLToPath(source);
1557
+ } catch {
1558
+ return undefined;
1559
+ }
1560
+ const inside = relative(documentFolder, path);
1561
+ return inside === '' || inside.startsWith('..') || inside.split(sep).includes('..') ? undefined : path;
1562
+ }
1563
+ /**
1564
+ * A document read from a file (a Markdown file, say) names its pictures by paths beside it, which the browser showing it cannot open. The
1565
+ * pictures that sit in the document's own folder are embedded in the page as data addresses, so they show; ones that are not there are counted
1566
+ * so the person is told. Nothing outside the document's folder is ever read.
1567
+ *
1568
+ * @param html - The document as HTML, its addresses already made absolute against `documentUrl`.
1569
+ * @param documentUrl - The document's own `file:` address.
1570
+ * @returns The page with the pictures embedded, and how many were embedded and how many could not be.
1571
+ */
1572
+ async function inlineLocalImages(html, documentUrl) {
1573
+ if (!documentUrl.startsWith('file:')) return {
1574
+ html,
1575
+ inlined: 0,
1576
+ missing: 0
1577
+ };
1578
+ const $ = load(html);
1579
+ const folder = dirname(fileURLToPath(documentUrl));
1580
+ let inlined = 0;
1581
+ let missing = 0;
1582
+ let total = 0;
1583
+ for (const element of $('img[src]')) {
1584
+ const source = absoluteAddress($(element).attr('src') ?? '', documentUrl);
1585
+ if (!source.startsWith('file:')) continue;
1586
+ const path = localPathOf(source, folder);
1587
+ const type = path === undefined ? undefined : IMAGE_TYPES[extname(path).toLowerCase()];
1588
+ const bytes = path !== undefined && type !== undefined ? await readIfThere(path) : undefined;
1589
+ if (bytes === undefined || type === undefined || bytes.length > MAX_IMAGE_BYTES || total + bytes.length > MAX_TOTAL_BYTES) {
1590
+ missing += 1;
1591
+ continue;
1592
+ }
1593
+ total += bytes.length;
1594
+ inlined += 1;
1595
+ $(element).attr('src', `data:${type};base64,${bytes.toString('base64')}`);
1596
+ }
1597
+ return {
1598
+ html: $.html(),
1599
+ inlined,
1600
+ missing
1601
+ };
1602
+ }
1603
+
1238
1604
  /** Attributes that carry a URL and are rewritten to absolute; `srcset` is handled separately (it carries several). */
1239
1605
  const URL_ATTRIBUTES = ['href', 'src', 'action', 'poster'];
1240
1606
  /** Schemes left untouched: they are not a location `new URL(value, base)` should resolve, or already carry their own meaning. */
@@ -1314,6 +1680,11 @@ function escapeAttribute(value) {
1314
1680
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
1315
1681
  }
1316
1682
 
1683
+ /** How long a cookie banner is given to go away once its button is pressed. */
1684
+ const CONSENT_SETTLE_MS = 1200;
1685
+ /** A banner appears a moment after the page loads (some only after its scripts have run for a few seconds): it is looked for this many times, this far apart, and a page with none costs this much time. */
1686
+ const CONSENT_ATTEMPTS = 6;
1687
+ const CONSENT_RETRY_MS = 800;
1317
1688
  /**
1318
1689
  * Captures the snapshot the content pane shows (studio plan §3.1, issue
1319
1690
  * #91): runs the recipe up to `stepPath`, serialises the document, marks
@@ -1343,7 +1714,7 @@ async function takeSnapshot(input, stepPath, browser, hooks) {
1343
1714
  const point = input.start[0];
1344
1715
  if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
1345
1716
  const storageState = await loginState(input, browser, hooks);
1346
- return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState);
1717
+ return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState, requestedAs(input));
1347
1718
  }
1348
1719
  /** What the recipe's own login leaves behind, so the snapshot shows the page as the crawl sees it (issue #185); nothing when it declares no login. */
1349
1720
  async function loginState(input, browser, hooks) {
@@ -1368,15 +1739,34 @@ async function snapshotWeb(url, storageState, browser) {
1368
1739
  storageState
1369
1740
  });
1370
1741
  try {
1371
- await session.page.goto(url);
1742
+ const assets = new Map();
1743
+ const captures = [];
1744
+ const budget = {
1745
+ bytes: 0
1746
+ };
1747
+ session.page.on('response', loaded => {
1748
+ captures.push(captureAsset(loaded, assets, budget));
1749
+ });
1750
+ const response = await session.page.goto(url);
1751
+ // A cookie banner left in place covers the page in this view, and the clicks that pick a value would land on it.
1752
+ await dismissConsent(session.page);
1753
+ // What still covers the page after that (a modal whose button the script does not know) is left out of the picture: "Show hidden" brings it back.
1754
+ await session.page.evaluate(MARK_OVERLAYS_SCRIPT);
1755
+ const seen = String(await session.page.evaluate(String.raw`document.title + "\n" + (document.body ? document.body.innerText : "").slice(0, 3000)`));
1372
1756
  await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
1373
- const html = await session.page.content();
1757
+ await Promise.all(captures);
1374
1758
  const baseUrl = session.page.url();
1759
+ // The page's own styles and fonts go inside the picture: the frame that shows it is not the site, so the site's fonts would be refused there.
1760
+ const html = inlineAssets(await session.page.content(), baseUrl, assets);
1375
1761
  const rewritten = rewriteDocument(html, baseUrl);
1762
+ const notice = blockedPageNotice(seen, response?.status());
1376
1763
  return {
1377
1764
  ...rewritten,
1378
1765
  baseUrl,
1379
- rawHtml: html
1766
+ rawHtml: html,
1767
+ ...(notice !== undefined && {
1768
+ notice
1769
+ })
1380
1770
  };
1381
1771
  } finally {
1382
1772
  await session.close();
@@ -1385,22 +1775,78 @@ async function snapshotWeb(url, storageState, browser) {
1385
1775
  await client.close();
1386
1776
  }
1387
1777
  }
1388
- async function snapshotApi(url, storageState) {
1778
+ /** The most a stylesheet may weigh to be kept, the most a font may, and how much of both a page may bring (bytes): a picture is not worth a heavier page than this. */
1779
+ const MAX_CSS_BYTES = 800_000;
1780
+ const MAX_FONT_BYTES = 600_000;
1781
+ const MAX_ASSET_BUDGET = 6_000_000;
1782
+ /** Keeps a stylesheet or a font the page loaded, within the limits above; anything else, or anything that cannot be read, is let go. */
1783
+ async function captureAsset(loaded, assets, budget) {
1784
+ const type = loaded.request().resourceType();
1785
+ if (type !== 'stylesheet' && type !== 'font') return;
1786
+ try {
1787
+ const body = await loaded.body();
1788
+ if (body.length > (type === 'font' ? MAX_FONT_BYTES : MAX_CSS_BYTES) || budget.bytes + body.length > MAX_ASSET_BUDGET) return;
1789
+ budget.bytes += body.length;
1790
+ assets.set(loaded.url(), {
1791
+ kind: type === 'font' ? 'font' : 'css',
1792
+ body
1793
+ });
1794
+ } catch {
1795
+ // A response that went away before it could be read is not kept.
1796
+ }
1797
+ }
1798
+ /** How the recipe's own first request reads the document (`as`), so a JSON API served as `text/plain` is shown as JSON here too. */
1799
+ function requestedAs(input) {
1800
+ const request = input.steps.find(step => step.type === 'request');
1801
+ return request?.type === 'request' ? request.as : undefined;
1802
+ }
1803
+ /**
1804
+ * Presses the cookie banner's reject (or accept) button, if the page shows one. Many sites put their consent dialog in a frame of its
1805
+ * own (a separate document, often covering the whole window), so every frame is searched, not only the page. The page is looked at
1806
+ * a few times, since banners come late.
1807
+ */
1808
+ async function dismissConsent(page) {
1809
+ for (let attempt = 0; attempt < CONSENT_ATTEMPTS; attempt += 1) {
1810
+ for (const frame of page.frames()) {
1811
+ let pressed = '';
1812
+ try {
1813
+ pressed = String(await frame.evaluate(DISMISS_CONSENT_SCRIPT));
1814
+ } catch {
1815
+ // A frame that went away or is not ready has no banner to press.
1816
+ }
1817
+ if (pressed !== '') {
1818
+ await page.waitForTimeout(CONSENT_SETTLE_MS);
1819
+ return;
1820
+ }
1821
+ }
1822
+ await page.waitForTimeout(CONSENT_RETRY_MS);
1823
+ }
1824
+ }
1825
+ async function snapshotApi(url, storageState, as) {
1389
1826
  const client = await HttpClient.open({
1390
1827
  storageState
1391
1828
  });
1392
1829
  try {
1393
1830
  const response = await client.send({
1394
- url
1831
+ url,
1832
+ ...(as !== undefined && {
1833
+ as
1834
+ })
1395
1835
  });
1396
1836
  const text = textOf$1(response.body);
1397
- const rewritten = rewriteDocument(text, url);
1837
+ // A document read from a file names its pictures by paths beside it, which the browser cannot open: the ones next to the file are embedded.
1838
+ const pictures = await inlineLocalImages(text, url);
1839
+ const rewritten = rewriteDocument(pictures.html, url);
1840
+ const notice = pictures.missing === 0 ? undefined : `${String(pictures.missing)} picture${pictures.missing === 1 ? '' : 's'} in this document ${pictures.missing === 1 ? 'is' : 'are'} not shown: only pictures kept in the same folder as the file can be.`;
1398
1841
  return {
1399
1842
  ...rewritten,
1400
1843
  baseUrl: url,
1401
1844
  rawHtml: text,
1402
1845
  format: response.format,
1403
- body: response.body
1846
+ body: response.body,
1847
+ ...(notice !== undefined && {
1848
+ notice
1849
+ })
1404
1850
  };
1405
1851
  } finally {
1406
1852
  await client.dispose();
@@ -1549,21 +1995,26 @@ const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2
1549
1995
  * @param document - The read workbook (a spreadsheet or a CSV).
1550
1996
  * @returns The view.
1551
1997
  */
1552
- function workbookDocumentView(document) {
1998
+ function workbookDocumentView(document, options = {}) {
1553
1999
  return {
1554
- sheets: document.sheets.map(sheet => sheetView(sheet)),
2000
+ sheets: document.sheets.map(sheet => sheetView(sheet, options.windowRows)),
1555
2001
  ...(document.csv !== undefined && {
1556
2002
  csv: document.csv
1557
2003
  })
1558
2004
  };
1559
2005
  }
1560
- function sheetView(sheet) {
2006
+ function sheetView(sheet, windowRows) {
2007
+ const windowed = windowRows !== undefined && sheet.rows.length > windowRows;
1561
2008
  return {
1562
2009
  name: sheet.name,
1563
2010
  hidden: sheet.hidden === true,
1564
2011
  hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
1565
- columnCount: Math.max(0, ...sheet.rows.map(row => row.length)),
1566
- rows: sheet.rows.map(row => row.map(cell => cellView(cell))),
2012
+ // Not `Math.max(...lengths)`: a spread of a few hundred thousand rows overflows the call stack.
2013
+ columnCount: sheet.rows.reduce((widest, row) => Math.max(widest, row.length), 0),
2014
+ rows: (windowed ? sheet.rows.slice(0, windowRows) : sheet.rows).map(row => row.map(cell => cellView(cell))),
2015
+ ...(windowed && {
2016
+ rowCount: sheet.rows.length
2017
+ }),
1567
2018
  merges: (sheet.merges ?? []).flatMap(reference => {
1568
2019
  const range = rangeOf(reference);
1569
2020
  return range === undefined ? [] : [{
@@ -1589,6 +2040,21 @@ function sheetView(sheet) {
1589
2040
  })
1590
2041
  };
1591
2042
  }
2043
+ /**
2044
+ * A run of one sheet's rows, typed, for a grid that only holds the screens around where it is scrolled to (the rest of a big file is never
2045
+ * sent to the browser until it is needed).
2046
+ *
2047
+ * @param document - The read workbook.
2048
+ * @param sheetIndex - Which sheet, 0-based.
2049
+ * @param from - The first row, 0-based.
2050
+ * @param count - How many rows at most.
2051
+ * @returns The rows (fewer at the end of the sheet, none past it).
2052
+ */
2053
+ function workbookSheetRows(document, sheetIndex, from, count) {
2054
+ const sheet = document.sheets[sheetIndex];
2055
+ if (sheet === undefined) return [];
2056
+ return sheet.rows.slice(from, from + count).map(row => row.map(cell => cellView(cell)));
2057
+ }
1592
2058
  function cellView(cell) {
1593
2059
  return {
1594
2060
  value: cell,
@@ -2753,6 +3219,8 @@ function handleGridPreview(state, command) {
2753
3219
  return previewGridTable(snapshot.body, command.options);
2754
3220
  }
2755
3221
 
3222
+ /** How many rows of a sheet the first `grid-view` answer carries; a bigger sheet's other rows come from `grid-rows` as the grid is scrolled. */
3223
+ const GRID_WINDOW_ROWS = 200;
2756
3224
  /**
2757
3225
  * Handles `grid-view`: the grid canvas's sheets and cells (`document-view`'s
2758
3226
  * `workbook-view.mapper.ts`, studio plan §3.4, issue #94's 5c), off the same
@@ -2775,7 +3243,9 @@ async function handleGridView(state, command) {
2775
3243
  const overriding = command.delimiter !== undefined || command.encoding !== undefined;
2776
3244
  if (!overriding) {
2777
3245
  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`);
2778
- return workbookDocumentView(snapshot.body);
3246
+ return workbookDocumentView(snapshot.body, {
3247
+ windowRows: GRID_WINDOW_ROWS
3248
+ });
2779
3249
  }
2780
3250
  if (snapshot.format !== 'csv') throw new Error(`"${command.recipeId}" at "${command.path}" is not a CSV snapshot: "delimiter"/"encoding" only override a CSV reading`);
2781
3251
  const body = await rereadCsv(snapshot.baseUrl, {
@@ -2786,7 +3256,25 @@ async function handleGridView(state, command) {
2786
3256
  ...snapshot,
2787
3257
  body
2788
3258
  });
2789
- return workbookDocumentView(body);
3259
+ return workbookDocumentView(body, {
3260
+ windowRows: GRID_WINDOW_ROWS
3261
+ });
3262
+ }
3263
+ /**
3264
+ * Handles `grid-rows`: a run of one sheet's rows from the cached snapshot, for the grid that holds only a few screens of a big file at a time.
3265
+ *
3266
+ * @param state - The server state.
3267
+ * @param command - The command; `command.path` must already have a cached snapshot.
3268
+ * @returns The rows (none past the end of the sheet).
3269
+ * @throws Error when there is no cached snapshot or it is not a workbook.
3270
+ */
3271
+ function handleGridRows(state, command) {
3272
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3273
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3274
+ if (!isWorkbookDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a workbook snapshot: grid-rows only reads a CSV/spreadsheet document`);
3275
+ return {
3276
+ rows: workbookSheetRows(snapshot.body, command.sheet, command.from, command.count)
3277
+ };
2790
3278
  }
2791
3279
 
2792
3280
  /**
@@ -3059,7 +3547,7 @@ function nodeToStep(node) {
3059
3547
  */
3060
3548
  async function openWorkspace(folder) {
3061
3549
  const documents = await readRecipeSource(folder);
3062
- const entries = documents.map(document => parseDocument$1(document));
3550
+ const entries = await Promise.all(documents.map(async document => parseDocument$1(document, await storedContent(document))));
3063
3551
  const outputs = entries.filter(isParsed('output'));
3064
3552
  const inputs = entries.filter(isParsed('input'));
3065
3553
  for (const output of outputs) bindOutput(output, inputs);
@@ -3079,16 +3567,16 @@ async function openWorkspace(folder) {
3079
3567
  kind,
3080
3568
  id,
3081
3569
  issues,
3082
- content
3570
+ stored
3083
3571
  }) => ({
3084
3572
  file,
3085
3573
  kind,
3086
3574
  id,
3087
3575
  issues,
3088
- text: `${JSON.stringify(content, null, 2)}\n`,
3576
+ text: `${JSON.stringify(stored, null, 2)}\n`,
3089
3577
  // The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
3090
3578
  // throws, so this covers a file that fails validation too (its outline just has "custom" cards).
3091
- outline: kind === 'input' ? recipeToOutline(content) : undefined
3579
+ outline: kind === 'input' ? recipeToOutline(stored) : undefined
3092
3580
  }))
3093
3581
  };
3094
3582
  }
@@ -3112,14 +3600,24 @@ function bindOutput(output, inputs) {
3112
3600
  function isParsed(kind) {
3113
3601
  return entry => entry.kind === kind && entry.recipe !== undefined;
3114
3602
  }
3115
- function parseDocument$1(document) {
3603
+ /** The recipe as its `.json` file holds it; the decoded content for anything else (a JSON Lines line, text, an object), or a file that cannot be read back. */
3604
+ async function storedContent(document) {
3605
+ if (!document.source.endsWith('.json')) return document.content;
3606
+ try {
3607
+ return JSON.parse(await readFile(document.source, 'utf8'));
3608
+ } catch {
3609
+ return document.content;
3610
+ }
3611
+ }
3612
+ function parseDocument$1(document, stored) {
3116
3613
  const kind = document.content?.kind;
3117
- if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
3118
- if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
3614
+ if (kind === 'output') return parseOne(document.source, document.content, stored, 'output', parseOutputRecipe);
3615
+ if (kind === 'input') return parseOne(document.source, document.content, stored, 'input', parseInputRecipe);
3119
3616
  return {
3120
3617
  file: document.source,
3121
3618
  kind: 'unknown',
3122
3619
  content: document.content,
3620
+ stored,
3123
3621
  issues: [{
3124
3622
  path: 'kind',
3125
3623
  message: '"kind" is missing or not "input"/"output"',
@@ -3127,13 +3625,14 @@ function parseDocument$1(document) {
3127
3625
  }]
3128
3626
  };
3129
3627
  }
3130
- function parseOne(file, content, kind, parse) {
3628
+ function parseOne(file, content, stored, kind, parse) {
3131
3629
  try {
3132
3630
  const recipe = parse(content, file);
3133
3631
  return {
3134
3632
  file,
3135
3633
  kind,
3136
3634
  content,
3635
+ stored,
3137
3636
  id: recipe.id,
3138
3637
  issues: [],
3139
3638
  recipe
@@ -3144,6 +3643,7 @@ function parseOne(file, content, kind, parse) {
3144
3643
  file,
3145
3644
  kind,
3146
3645
  content,
3646
+ stored,
3147
3647
  issues: error.issues.map(issue => ({
3148
3648
  path: issue.path === '' ? '(root)' : issue.path,
3149
3649
  message: issue.message,
@@ -3207,8 +3707,10 @@ const MAX_NUMBERED_COPIES = 100;
3207
3707
  * `file:` URL the same way a run would read any downloaded file (core's
3208
3708
  * `HttpClient` handles `file:` itself). The folder is created when missing,
3209
3709
  * so dropping a file is also a way to start a workspace. A name already in
3210
- * the folder is never overwritten: the copy gets the next free numbered
3211
- * name (`report-2.pdf`), and the returned `path`/`url` say which.
3710
+ * the folder is never overwritten: a file there with the very same bytes is
3711
+ * reused (loading one document twice leaves one copy), a different one gets
3712
+ * the next free numbered name (`report-2.pdf`); the returned `path`/`url`
3713
+ * say which.
3212
3714
  *
3213
3715
  * @param folder - The workspace folder; created (recursively) when it does not exist.
3214
3716
  * @param name - The file's own name, no directories: the copy keeps it, extension included.
@@ -3229,10 +3731,14 @@ async function importDocument(folder, name, bytes) {
3229
3731
  });
3230
3732
  return {
3231
3733
  path,
3232
- url: pathToFileURL(path).href
3734
+ url: `file:${encodeURIComponent(candidate)}`
3233
3735
  };
3234
3736
  } catch (error) {
3235
3737
  if (!isAlreadyThere(error)) throw error;
3738
+ if (await hasBytes(path, bytes)) return {
3739
+ path,
3740
+ url: `file:${encodeURIComponent(candidate)}`
3741
+ };
3236
3742
  }
3237
3743
  }
3238
3744
  throw new Error(`"${name}" and ${MAX_NUMBERED_COPIES} numbered copies of it are already in ${folder}`);
@@ -3248,6 +3754,11 @@ function* candidateNames(name) {
3248
3754
  const stem = name.slice(0, name.length - extension.length);
3249
3755
  for (let copy = 2; copy <= MAX_NUMBERED_COPIES + 1; copy += 1) yield `${stem}-${copy}${extension}`;
3250
3756
  }
3757
+ /** Whether the file at `path` is, byte for byte, `bytes`. */
3758
+ async function hasBytes(path, bytes) {
3759
+ const existing = await readFile(path);
3760
+ return existing.length === bytes.length && existing.equals(bytes);
3761
+ }
3251
3762
  function isAlreadyThere(error) {
3252
3763
  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
3253
3764
  }
@@ -3501,7 +4012,10 @@ const GENERATED_TOKEN_PATTERNS = [/** styled-components / emotion: `sc-bdVaJa`,
3501
4012
  /^(?:jss|mui[a-z]*-)[a-z0-9-]*\d{2}$/i, /** A token that is only digits: not a class a human would type. */
3502
4013
  /^\d+$/, /** A bare hex hash: `a3f9c21`, `1a2b3c4d` (6+ hex chars, nothing else). */
3503
4014
  /^[0-9a-f]{6,}$/i, /** Tailwind/atomic-CSS-in-JS runtime classes some bundlers emit: `_1a2b3c` */
3504
- /^_[0-9a-z]{5,}$/i];
4015
+ /^_[0-9a-z]{5,}$/i, /** A framework's runtime state, added and removed as the page runs, not written by anyone: AngularJS/Angular `ng-scope`, `ng-binding`, `ng-star-inserted`, `ng-valid`… */
4016
+ /^ng-/, /** Ember's: `ember-view`, `ember123` */
4017
+ /^ember(?:-view|\d+)$/, /** Svelte's scoping hash: `svelte-1a2b3c` */
4018
+ /^svelte-[a-z0-9]+$/i];
3505
4019
  /**
3506
4020
  * Whether a class token looks hand-authored and safe to build a selector on.
3507
4021
  *
@@ -3615,6 +4129,12 @@ function structureCandidates(path) {
3615
4129
  tier: 'structure'
3616
4130
  });
3617
4131
  }
4132
+ // The whole tree down to the node, each level named by its stable id or classes (an id or class that looks generated is left out of it).
4133
+ const levels = path.slice(-5);
4134
+ if (levels.length > 2) candidates.push({
4135
+ selector: levels.map(level => simpleSelector(level)).join(' > '),
4136
+ tier: 'structure'
4137
+ });
3618
4138
  return candidates;
3619
4139
  }
3620
4140
  /** `parent > tag:nth-of-type(n)`, the position-only fallback; never `nth-child`, which counts every sibling tag, not just this element's own. */
@@ -3666,7 +4186,7 @@ const TIER_ORDER = {
3666
4186
  * Ordering: candidates that do not match `targetNodeId` (when given) sort
3667
4187
  * last; among the rest, best tier first, and within a tier the fewest
3668
4188
  * matches first (the most specific selector that still hits the target).
3669
- * A candidate matching nothing is dropped entirely: it is not a real option.
4189
+ * Equal on all that, the selector naming more ids and classes comes first. A candidate matching nothing is dropped entirely: it is not a real option.
3670
4190
  *
3671
4191
  * @param html - The document to verify against (the snapshot, or the live page for the second, cross-check pass).
3672
4192
  * @param candidates - Unranked candidates, e.g. from `candidatesFor`.
@@ -3683,7 +4203,11 @@ function rankCandidates(html, candidates, targetNodeId) {
3683
4203
  hitsTarget
3684
4204
  };
3685
4205
  });
3686
- return ranked.filter(candidate => candidate.matches > 0).sort((a, b) => Number(b.hitsTarget) - Number(a.hitsTarget) || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.matches - b.matches);
4206
+ return ranked.filter(candidate => candidate.matches > 0).sort((a, b) => Number(b.hitsTarget) - Number(a.hitsTarget) || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.matches - b.matches || namedParts(b.selector) - namedParts(a.selector));
4207
+ }
4208
+ /** How many ids and classes a selector names: of two that tie, the one built on names keeps working better than one made only of tags. */
4209
+ function namedParts(selector) {
4210
+ return (selector.match(/[#.]/g) ?? []).length;
3687
4211
  }
3688
4212
  /** The single best candidate, or `undefined` when none of them matched anything. */
3689
4213
  function bestCandidate(html, candidates, targetNodeId) {
@@ -3808,9 +4332,9 @@ function handleInferSelector(state, command) {
3808
4332
  reason: 'one of the picked nodes is no longer in the snapshot; take a fresh snapshot and try again'
3809
4333
  };
3810
4334
  const [pathA, pathB] = paths;
3811
- if (pathB === undefined) return {
4335
+ if (pathB === undefined) return itemField(html, command, pathA) ?? {
3812
4336
  kind: 'field',
3813
- field: fieldPick(html, pathA)
4337
+ field: fieldPick(html, pathA, pathA, command.nodeIds[0])
3814
4338
  };
3815
4339
  try {
3816
4340
  const result = inferList(pathA, pathB);
@@ -3818,7 +4342,8 @@ function handleInferSelector(state, command) {
3818
4342
  kind: 'unsupported',
3819
4343
  reason: 'these two picks do not belong to the same list: pick the same field on two similar items'
3820
4344
  };
3821
- const item = bestCandidate(html, candidatesFor(result.itemPath, false));
4345
+ const itemOptions = rankCandidates(html, candidatesFor(result.itemPath, false));
4346
+ const item = itemOptions[0];
3822
4347
  if (item === undefined) return {
3823
4348
  kind: 'unsupported',
3824
4349
  reason: 'no selector matches every item of this list; try picking two items closer to each other in the markup'
@@ -3829,7 +4354,8 @@ function handleInferSelector(state, command) {
3829
4354
  item: {
3830
4355
  selector: item.selector,
3831
4356
  tier: item.tier,
3832
- matches: item.matches
4357
+ matches: item.matches,
4358
+ alternatives: alternativesOf(itemOptions)
3833
4359
  },
3834
4360
  field
3835
4361
  };
@@ -3841,19 +4367,63 @@ function handleInferSelector(state, command) {
3841
4367
  throw error;
3842
4368
  }
3843
4369
  }
4370
+ /**
4371
+ * A single pick inside an item of the list the recipe already loops over (`command.within` is the items' selector): the field read from the item,
4372
+ * for the loop's body. `undefined` when no list is given, or the node is not inside one of its items (or is the item itself).
4373
+ */
4374
+ function itemField(html, command, clicked) {
4375
+ if (command.within === undefined) return undefined;
4376
+ const $ = load(html);
4377
+ let itemId;
4378
+ try {
4379
+ itemId = $(`[${NODE_ID_ATTRIBUTE}="${command.nodeIds[0]}"]`).first().closest(command.within).attr(NODE_ID_ATTRIBUTE);
4380
+ } catch {
4381
+ return undefined;
4382
+ }
4383
+ const itemPath = itemId === undefined ? undefined : pathToNode(html, itemId);
4384
+ if (itemPath === undefined || itemPath.length >= clicked.length) return undefined;
4385
+ return {
4386
+ kind: 'item-field',
4387
+ field: fieldPick(html, clicked.slice(itemPath.length - 1))
4388
+ };
4389
+ }
3844
4390
  /** The best selector for one clicked node, `take` decided from the concrete clicked element; `rankSource` (a generalised path) is used for candidate generation when given, so a per-item-unique attribute of the one node picked cannot end up in the selector. */
3845
- function fieldPick(html, clicked, rankSource = clicked) {
4391
+ function fieldPick(html, clicked, rankSource = clicked, targetNodeId) {
3846
4392
  const leaf = clicked.at(-1);
3847
4393
  const take = leaf === undefined ? 'text' : takeKindFor(leaf);
3848
- const best = bestCandidate(html, candidatesFor(rankSource));
4394
+ const options = rankCandidates(html, candidatesFor(rankSource), targetNodeId);
4395
+ const best = options[0];
3849
4396
  if (best === undefined) throw new Error('no selector matches the picked node — this should not happen, since the node came from this same document');
4397
+ const generatedId = leaf?.id !== undefined && !isStableId(leaf.id) ? leaf.id : undefined;
3850
4398
  return {
3851
4399
  selector: best.selector,
3852
4400
  tier: best.tier,
3853
4401
  take,
3854
- matches: best.matches
4402
+ matches: best.matches,
4403
+ alternatives: alternativesOf(options),
4404
+ ...(generatedId !== undefined && {
4405
+ generatedId
4406
+ })
3855
4407
  };
3856
4408
  }
4409
+ /** How many other selectors a person is offered at most. */
4410
+ const MAX_ALTERNATIVES = 6;
4411
+ /** The verified candidates as the options shown to the person: the ones that reach the clicked node, each selector once. */
4412
+ function alternativesOf(options) {
4413
+ const seen = new Set();
4414
+ const alternatives = [];
4415
+ for (const option of options) {
4416
+ if (!option.hitsTarget || seen.has(option.selector)) continue;
4417
+ seen.add(option.selector);
4418
+ alternatives.push({
4419
+ selector: option.selector,
4420
+ tier: option.tier,
4421
+ matches: option.matches
4422
+ });
4423
+ if (alternatives.length === MAX_ALTERNATIVES) break;
4424
+ }
4425
+ return alternatives;
4426
+ }
3857
4427
 
3858
4428
  /** A leaf element's own text longer than this is cut, with an ellipsis. */
3859
4429
  const TEXT_LIMIT = 80;
@@ -3938,13 +4508,28 @@ function collapseRepeats(nodes) {
3938
4508
  }
3939
4509
  return result;
3940
4510
  }
3941
- /** A run member's identity for collapsing: everything but its own `nodeId` (every instance has a different one by construction). */
4511
+ /** Signatures already worked out: a node is looked at once for its run and again as part of its parent's. */
4512
+ const signatures = new WeakMap();
4513
+ /**
4514
+ * A run member's identity for collapsing: everything but its own `nodeId` (every instance has a different one by
4515
+ * construction). Its children enter as their own short hashes, not as the text of their signatures: nesting that
4516
+ * text in JSON escapes it again at every level, which grows with the depth of the page until the string is too
4517
+ * long to exist (a page nested 40 deep).
4518
+ */
3942
4519
  function signatureOf(node) {
3943
- return JSON.stringify({
3944
- ...node,
4520
+ const known = signatures.get(node);
4521
+ if (known !== undefined) return known;
4522
+ const {
4523
+ children,
4524
+ ...own
4525
+ } = node;
4526
+ const signature = createHash('sha1').update(JSON.stringify({
4527
+ ...own,
3945
4528
  nodeId: undefined,
3946
- children: node.children.map(child => signatureOf(child))
3947
- });
4529
+ children: children.map(child => signatureOf(child))
4530
+ })).digest('hex');
4531
+ signatures.set(node, signature);
4532
+ return signature;
3948
4533
  }
3949
4534
  function abbreviate(text) {
3950
4535
  const trimmed = text.trim().replaceAll(/\s+/g, ' ');
@@ -4182,6 +4767,19 @@ function handleInspectPage(state, command) {
4182
4767
  };
4183
4768
  }
4184
4769
 
4770
+ /**
4771
+ * Handles `detect-url`: what the address returns, for "Load ▾ Web page" to start a recipe that reads a document (a JSON API, a PDF,
4772
+ * a spreadsheet, Markdown...) instead of a browser recipe over a response that is not a page.
4773
+ *
4774
+ * @param command - The address.
4775
+ * @returns What it returns.
4776
+ */
4777
+ async function handleDetectUrl(command) {
4778
+ return {
4779
+ kind: await detectUrlKind(command.url)
4780
+ };
4781
+ }
4782
+
4185
4783
  /**
4186
4784
  * Handles `open-path`: says what the path is (folder, recipe or document) and
4187
4785
  * where to open it. Opens nothing itself: the UI follows with `open-workspace`
@@ -5327,6 +5925,9 @@ function snapshotView(snapshot) {
5327
5925
  baseUrl: snapshot.baseUrl,
5328
5926
  ...(snapshot.format !== undefined && {
5329
5927
  format: snapshot.format
5928
+ }),
5929
+ ...(snapshot.notice !== undefined && {
5930
+ notice: snapshot.notice
5330
5931
  })
5331
5932
  };
5332
5933
  }
@@ -5644,6 +6245,10 @@ function dispatch(command, state) {
5644
6245
  {
5645
6246
  return handleFetchStartPage(state, command);
5646
6247
  }
6248
+ case 'detect-url':
6249
+ {
6250
+ return handleDetectUrl(command);
6251
+ }
5647
6252
  case 'save-outline':
5648
6253
  {
5649
6254
  return handleSaveOutline(state, command);
@@ -5692,6 +6297,10 @@ function dispatch(command, state) {
5692
6297
  {
5693
6298
  return handleGridView(state, command);
5694
6299
  }
6300
+ case 'grid-rows':
6301
+ {
6302
+ return Promise.resolve(handleGridRows(state, command));
6303
+ }
5695
6304
  case 'grid-preview':
5696
6305
  {
5697
6306
  return Promise.resolve(handleGridPreview(state, command));
@@ -5779,5 +6388,5 @@ async function main(argv, options = {}) {
5779
6388
  process.on('SIGTERM', shutdown);
5780
6389
  }
5781
6390
 
5782
- export { chooseFolderCommandSchema, chooseFolderViewSchema, 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, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, 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 };
6391
+ export { chooseFolderCommandSchema, chooseFolderViewSchema, deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, detectUrlCommandSchema, detectUrlViewSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridRowsCommandSchema, gridRowsViewSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, selectorAlternativeSchema, 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 };
5783
6392
  //# sourceMappingURL=index.esm.js.map