@olomonhelpdesk2/helpdesk 0.1.5 → 0.1.10

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.
@@ -51,6 +51,18 @@ function unusablePath(path3) {
51
51
  if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
52
52
  return void 0;
53
53
  }
54
+ var PLAIN_QUERY = /^[\w.~%:+,-]+=[\w.~%:+,-]*(?:&[\w.~%:+,-]+=[\w.~%:+,-]*)*$/;
55
+ function queryFor(route, named) {
56
+ const mark = named.indexOf("?");
57
+ const path3 = mark < 0 ? named : named.slice(0, mark);
58
+ const query = mark < 0 ? "" : named.slice(mark + 1);
59
+ if (!query) return { because: "it has no dynamic segment, so the only thing to name for it is the query it opens with" };
60
+ if (path3 !== route) return { because: `${named} is not ${route} with a query` };
61
+ const unusable = unusablePath(route);
62
+ if (unusable) return { because: unusable };
63
+ if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
64
+ return { visit: named };
65
+ }
54
66
  function readExampleRoutes(raw) {
55
67
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
56
68
  return { ok: false, because: "the file is not a JSON object" };
@@ -68,14 +80,20 @@ function readExampleRoutes(raw) {
68
80
  const paths = /* @__PURE__ */ new Map();
69
81
  const refused = [];
70
82
  for (const [route, value] of entries) {
71
- if (!route.startsWith("/") || dynamicSegments(route).length === 0) {
72
- refused.push({ route, because: "it is not a route with a dynamic segment" });
83
+ if (!route.startsWith("/")) {
84
+ refused.push({ route, because: "it is not a route" });
73
85
  continue;
74
86
  }
75
87
  if (typeof value !== "string") {
76
88
  refused.push({ route, because: "its example is not a path" });
77
89
  continue;
78
90
  }
91
+ if (dynamicSegments(route).length === 0) {
92
+ const opened = queryFor(route, value.trim());
93
+ if ("because" in opened) refused.push({ route, because: opened.because });
94
+ else paths.set(route, opened.visit);
95
+ continue;
96
+ }
79
97
  const path3 = value.trim();
80
98
  const unusable = unusablePath(path3);
81
99
  if (unusable) {
@@ -92,8 +110,8 @@ function readExampleRoutes(raw) {
92
110
  }
93
111
  function resolveVisit(route, examples) {
94
112
  const needs = dynamicSegments(route);
95
- if (needs.length === 0) return { visit: route };
96
113
  const path3 = examples.paths.get(route);
114
+ if (needs.length === 0) return { visit: path3 ?? route };
97
115
  return path3 ? { visit: path3 } : { needs };
98
116
  }
99
117
  function describeMissingExample(route, needs) {
@@ -136,7 +154,20 @@ var CAPTURE_CONTRACT = {
136
154
  /**
137
155
  * Injected before every shot. Necessary, not sufficient — see above.
138
156
  */
139
- motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
157
+ motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
158
+ /**
159
+ * What an app uses to leave its own floating widgets out of the pictures.
160
+ *
161
+ * A feedback button or a chat launcher sits on top of the product on every
162
+ * screen, and an article about the product should show only the product. The
163
+ * capture sets `marker` on `<html>` of every page it photographs, so an app
164
+ * can style a widget away under `html[data-helpdesk-capture]`, and it removes
165
+ * any element carrying `hide` itself. Removed before the screen is read, so
166
+ * what is not in the picture is not asked about either.
167
+ */
168
+ marker: "data-helpdesk-capture",
169
+ hide: "data-helpdesk-hide",
170
+ hideOff: "[data-helpdesk-hide]{display:none!important}"
140
171
  };
141
172
  var VISUAL_STABILITY = {
142
173
  /** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
@@ -165,7 +196,7 @@ function actionFor(outcome) {
165
196
  return void 0;
166
197
  case "not-found": {
167
198
  if ((!outcome.missingEndpoint || outcome.missingEndpoint === outcome.route) && !outcome.route.includes("[")) {
168
- return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens; if it is gone, it will drop out of the plan.`;
199
+ return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens. If it opens only from a record, as a form started from one does, name the address it opens at in the file the seed command writes at ${CI_EXAMPLES_OUT_ENV}. If it is gone, it will drop out of the plan.`;
169
200
  }
170
201
  const asked = outcome.missingEndpoint ?? outcome.route;
171
202
  const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
@@ -340,40 +371,40 @@ function preflight(facts) {
340
371
  name: "target",
341
372
  ok: true,
342
373
  blocking: false,
343
- detail: `${facts.baseUrl}, reading from ${facts.replaying} \u2014 no live backend is reachable from this run, so nothing on these screens can be a real person's`
374
+ detail: `${facts.baseUrl}, reading from ${facts.replaying}. No live backend is reachable from this run, so nothing on these screens can be a real person's`
344
375
  });
345
376
  } else if (facts.session && live && !facts.productionGranted) {
346
377
  checks.push({
347
378
  name: "target",
348
379
  ok: false,
349
380
  blocking: true,
350
- detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data \u2014 point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
381
+ detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data. Point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
351
382
  });
352
383
  } else {
353
384
  checks.push({
354
385
  name: "target",
355
386
  ok: true,
356
387
  blocking: false,
357
- detail: facts.session ? `${facts.baseUrl}, signed in${live ? " \u2014 accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
388
+ detail: facts.session ? `${facts.baseUrl}, signed in${live ? ", accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
358
389
  });
359
390
  }
360
391
  checks.push({
361
392
  name: "browser",
362
393
  ok: Boolean(facts.browser),
363
394
  blocking: false,
364
- detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified \u2014 a browser upgrade will look like the product changing"
395
+ detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified. A browser upgrade will look like the product changing"
365
396
  });
366
397
  checks.push({
367
398
  name: "clock",
368
399
  ok: facts.clockPinned,
369
400
  blocking: false,
370
- detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock) \u2014 screens showing a date will differ every run, so nothing here can tell you whether they changed"
401
+ detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock). Screens showing a date will differ every run, so nothing here can tell you whether they changed"
371
402
  });
372
403
  checks.push({
373
404
  name: "locale",
374
405
  ok: true,
375
406
  blocking: false,
376
- detail: `${facts.locale}, ${facts.timezone} \u2014 set explicitly rather than inherited from this machine`
407
+ detail: `${facts.locale}, ${facts.timezone}, set explicitly rather than inherited from this machine`
377
408
  });
378
409
  checks.push({
379
410
  name: "isolation",
@@ -398,7 +429,7 @@ function preflight(facts) {
398
429
  name: "rebaseline",
399
430
  ok: true,
400
431
  blocking: false,
401
- detail: `replacing every stored frame \u2014 "${facts.rebaselineReason}"`
432
+ detail: `replacing every stored frame: "${facts.rebaselineReason}"`
402
433
  });
403
434
  }
404
435
  const blocked = checks.filter((check) => check.blocking && !check.ok);
@@ -425,11 +456,30 @@ function attestation(run) {
425
456
  ].filter(Boolean).join("\n");
426
457
  }
427
458
 
459
+ // core/capture/run-record.ts
460
+ function runIdentityFromEnv(env) {
461
+ if (env["GITHUB_ACTIONS"] !== "true" || !env["GITHUB_RUN_NUMBER"]) return void 0;
462
+ const said = { number: env["GITHUB_RUN_NUMBER"] };
463
+ for (const [key, name] of [
464
+ ["id", "GITHUB_RUN_ID"],
465
+ ["attempt", "GITHUB_RUN_ATTEMPT"],
466
+ ["repository", "GITHUB_REPOSITORY"],
467
+ ["server", "GITHUB_SERVER_URL"]
468
+ ]) {
469
+ const value = env[name];
470
+ if (value) said[key] = value;
471
+ }
472
+ return said;
473
+ }
474
+
428
475
  // core/capture/run-verdict.ts
476
+ function runCounts(run) {
477
+ return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
478
+ }
429
479
  function runVerdict(run) {
430
480
  const delivered = run.captured + run.unchanged;
431
481
  const missed = run.skipped + run.refused + run.failed;
432
- const counts = `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
482
+ const counts = runCounts(run);
433
483
  if (run.screens > 0 && delivered === 0) {
434
484
  return {
435
485
  exit: 1,
@@ -580,6 +630,8 @@ var ALREADY_SAFE = [
580
630
  function alreadySafe(kind, value) {
581
631
  return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
582
632
  }
633
+ var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
634
+ var LABEL_REACH = 48;
583
635
  var PATTERNS = [
584
636
  {
585
637
  kind: "email",
@@ -629,8 +681,9 @@ var PATTERNS = [
629
681
  {
630
682
  kind: "date-of-birth",
631
683
  regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
632
- because: "a full date \u2014 if it is a birth date it identifies somebody",
633
- replace: () => "1970-01-01"
684
+ because: "a date labeled as a birth date, which identifies somebody",
685
+ replace: () => "1970-01-01",
686
+ labelled: BIRTH_LABEL
634
687
  },
635
688
  {
636
689
  kind: "passport",
@@ -651,8 +704,13 @@ function scrub(text2, options = {}) {
651
704
  let output = text2;
652
705
  for (const pattern of PATTERNS) {
653
706
  const seen = /* @__PURE__ */ new Map();
654
- output = output.replace(pattern.regex, (match) => {
707
+ output = output.replace(pattern.regex, (...args) => {
708
+ const match = args[0];
709
+ const named = typeof args[args.length - 1] === "object";
710
+ const offset = args[args.length - (named ? 3 : 2)];
711
+ const whole = args[args.length - (named ? 2 : 1)];
655
712
  if (alreadySafe(pattern.kind, match)) return match;
713
+ if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
656
714
  const already = seen.get(match);
657
715
  if (already) return already;
658
716
  const replacement = pattern.replace(match, seen.size);
@@ -669,7 +727,7 @@ function scrub(text2, options = {}) {
669
727
  found: brand,
670
728
  replacement: brand,
671
729
  where,
672
- because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
730
+ because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
673
731
  });
674
732
  }
675
733
  }
@@ -697,7 +755,7 @@ function gateFrame(pageText, options = {}) {
697
755
  return {
698
756
  safe: false,
699
757
  findings,
700
- reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit \u2014 so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned \u2014 point the capture at a demo tenant, or fix the seed that produced this.`
758
+ reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit, so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned. Point the capture at a demo tenant, or fix the seed that produced this.`
701
759
  };
702
760
  }
703
761
 
@@ -1037,11 +1095,22 @@ var LIMITS = {
1037
1095
  fields: 24,
1038
1096
  notices: 20,
1039
1097
  warnings: 12,
1098
+ disabled: 24,
1099
+ empty: 12,
1100
+ gatedBy: 8,
1040
1101
  choices: 12,
1041
1102
  /** Longer than this is a paragraph of copy, not a label. */
1042
1103
  label: 120,
1043
1104
  sentence: 300
1044
1105
  };
1106
+ var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
1107
+ function isEmptyState(text2) {
1108
+ const sentence = text2.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
1109
+ if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
1110
+ if (/\b(?:until|unless)\b/i.test(sentence)) return false;
1111
+ if (sentence.split(" ").length < 2) return false;
1112
+ return EMPTY_STATE.test(sentence);
1113
+ }
1045
1114
 
1046
1115
  // core/discovery/aria-facts.ts
1047
1116
  var LANDMARK = {
@@ -1092,6 +1161,12 @@ function readAriaFactRecords(snapshot, options = {}) {
1092
1161
  const state = controlState(node);
1093
1162
  if (node.role === "heading") {
1094
1163
  add("heading", node.name || node.text, node);
1164
+ if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
1165
+ continue;
1166
+ }
1167
+ if (node.role === "paragraph" || node.role === "text") {
1168
+ const said = node.text || node.name;
1169
+ if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
1095
1170
  continue;
1096
1171
  }
1097
1172
  if (node.role === "link") {
@@ -1115,7 +1190,9 @@ function readAriaFactRecords(snapshot, options = {}) {
1115
1190
  continue;
1116
1191
  }
1117
1192
  if (NOTICE.has(node.role)) {
1118
- add("notice", trim(node.text || node.name, LIMITS.sentence), node);
1193
+ const said = trim(node.text || node.name, LIMITS.sentence);
1194
+ add("notice", said, node);
1195
+ if (isEmptyState(said)) add("empty", said, node);
1119
1196
  }
1120
1197
  }
1121
1198
  return facts;
@@ -1152,7 +1229,7 @@ function parse(snapshot) {
1152
1229
  if (node.name || node.text) parent.choices.push(node.name || node.text);
1153
1230
  continue;
1154
1231
  }
1155
- if (role !== "text") out.push(node);
1232
+ out.push(node);
1156
1233
  stack.push({ depth: depth2, node });
1157
1234
  }
1158
1235
  return out;
@@ -1235,6 +1312,7 @@ function readRenderedFactRecords(html, options = {}) {
1235
1312
  const label = accessibleName(element);
1236
1313
  if (HEADING.test(tag) || role === "heading") {
1237
1314
  add("heading", label, landmark);
1315
+ if (isEmptyState(label)) add("empty", label, landmark);
1238
1316
  continue;
1239
1317
  }
1240
1318
  if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
@@ -1260,11 +1338,20 @@ function readRenderedFactRecords(html, options = {}) {
1260
1338
  continue;
1261
1339
  }
1262
1340
  if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
1263
- add("notice", trim2(text(inner), LIMITS.sentence), landmark);
1341
+ const said = trim2(text(inner), LIMITS.sentence);
1342
+ add("notice", said, landmark);
1343
+ if (isEmptyState(said)) add("empty", said, landmark);
1344
+ continue;
1345
+ }
1346
+ if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
1347
+ const said = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
1348
+ if (isEmptyState(said)) add("empty", said, landmark);
1264
1349
  }
1265
1350
  }
1266
1351
  return facts;
1267
1352
  }
1353
+ var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
1354
+ var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
1268
1355
  var OVERLAY_ROLE = {
1269
1356
  dialog: "dialog",
1270
1357
  alertdialog: "dialog",
@@ -1616,7 +1703,7 @@ function requireDeployment(producer, input) {
1616
1703
  const value = deployment.value.trim();
1617
1704
  if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
1618
1705
  throw new UnstampedObservation(
1619
- `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running \u2014 a deployment id, a build id, a release SHA \u2014 or unknownDeployment("why not").`
1706
+ `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running (a deployment id, a build id, a release SHA) or unknownDeployment("why not").`
1620
1707
  );
1621
1708
  }
1622
1709
  }
@@ -1665,7 +1752,7 @@ async function captureOne(options, shot, build) {
1665
1752
  status: "skipped",
1666
1753
  result: "bounced",
1667
1754
  needsAccount: true,
1668
- reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant \u2014 never a real account"
1755
+ reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant, never a real account"
1669
1756
  },
1670
1757
  observed: { route: shot.route, result: "bounced" }
1671
1758
  };
@@ -1853,7 +1940,7 @@ async function captureOne(options, shot, build) {
1853
1940
  sha256,
1854
1941
  settled: frame.settled,
1855
1942
  ...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
1856
- reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
1943
+ reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
1857
1944
  }
1858
1945
  } : {
1859
1946
  shotId: shot.id,
@@ -2003,6 +2090,8 @@ async function playwrightBrowser(options = {}) {
2003
2090
  const response = await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
2004
2091
  status = response?.status();
2005
2092
  await page.addStyleTag({ content: CAPTURE_CONTRACT.motionOff }).catch(() => void 0);
2093
+ await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
2094
+ await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
2006
2095
  await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
2007
2096
  },
2008
2097
  async text() {
@@ -2546,7 +2635,7 @@ async function stageAgainstRecording(options) {
2546
2635
  died = code ?? 1;
2547
2636
  });
2548
2637
  } else {
2549
- options.onLog?.(`The recording is at ${replay.url} \u2014 start your product with ${variable} set to it.`);
2638
+ options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
2550
2639
  }
2551
2640
  const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
2552
2641
  if (app) await new Promise((resolve) => setTimeout(resolve, 250));
@@ -2557,7 +2646,7 @@ async function stageAgainstRecording(options) {
2557
2646
  throw new Error(
2558
2647
  `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
2559
2648
 
2560
- Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run \u2014 this will not photograph it, because it is not the product this run started.` : "")
2649
+ Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run. This will not photograph it, because it is not the product this run started.` : "")
2561
2650
  );
2562
2651
  }
2563
2652
  if (!up) {
@@ -2748,7 +2837,7 @@ function gapReport(gaps) {
2748
2837
  ""
2749
2838
  ];
2750
2839
  for (const gap of gaps) {
2751
- const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
2840
+ const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
2752
2841
  lines.push(` ${gap.method} ${gap.path}${broke}`);
2753
2842
  if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
2754
2843
  }
@@ -2795,7 +2884,7 @@ function coverageReport(found, total) {
2795
2884
  ...found.missing.map((route) => ` ${route}`),
2796
2885
  "",
2797
2886
  "They will not be photographed. Record again and visit them, or they stay out of the",
2798
- "plan \u2014 an empty screen photographed from a gap in the recording looks exactly like an",
2887
+ "plan: an empty screen photographed from a gap in the recording looks exactly like an",
2799
2888
  "empty screen belonging to a customer with nothing, which is how a help article ends up",
2800
2889
  "showing a product with no content in it."
2801
2890
  ];
@@ -2991,7 +3080,7 @@ function score(route, signals) {
2991
3080
  }
2992
3081
  if (hasSegment(route.path, MONEY_OR_ACCESS)) {
2993
3082
  total += 18;
2994
- because.push("money or access \u2014 asked about out of proportion to its traffic");
3083
+ because.push("money or access: asked about out of proportion to its traffic");
2995
3084
  }
2996
3085
  if (hasSegment(route.path, ENTRY_POINT)) {
2997
3086
  total += 16;
@@ -3087,6 +3176,16 @@ function buildArticlePlan(options) {
3087
3176
  function repositoryFiles(cwd) {
3088
3177
  return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
3089
3178
  }
3179
+ var WORKFLOW_ACTIONS = {
3180
+ checkout: "actions/checkout@v7",
3181
+ setupNode: "actions/setup-node@v7"
3182
+ };
3183
+ var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
3184
+ - uses: ${WORKFLOW_ACTIONS.setupNode}
3185
+ with:
3186
+ node-version: "22"
3187
+ package-manager-cache: false
3188
+ `;
3090
3189
 
3091
3190
  // core/discovery/storyboard.ts
3092
3191
  function shotIdFor(route) {
@@ -3109,7 +3208,8 @@ function buildStoryboard(options) {
3109
3208
  const beneath = options.routes.filter(
3110
3209
  (route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
3111
3210
  );
3112
- const ordered = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped).sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
3211
+ const own = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped);
3212
+ const ordered = options.inOrder ? own : own.sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
3113
3213
  const limit = options.maxSteps ?? 5;
3114
3214
  const steps = ordered.slice(0, limit).map((route, index) => ({
3115
3215
  route: route.path,
@@ -3322,6 +3422,10 @@ Approve one on the plan page, then run this again.`
3322
3422
  `
3323
3423
  );
3324
3424
  const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
3425
+ let told = Promise.resolve();
3426
+ const tell = (body) => {
3427
+ told = told.then(() => tellHelpDesk(parsed.to, headers, body));
3428
+ };
3325
3429
  try {
3326
3430
  const report = await runCapture({
3327
3431
  baseUrl,
@@ -3354,13 +3458,16 @@ Approve one on the plan page, then run this again.`
3354
3458
  role: parsed.session ? unknownRole() : noRole("this run was signed out"),
3355
3459
  onProgress: (outcome, index, total) => {
3356
3460
  const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
3357
- console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? ` \u2014 ${outcome.reason}` : ""}`);
3461
+ console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
3358
3462
  if (runId) {
3359
- void tellHelpDesk(parsed.to, headers, {
3463
+ tell({
3360
3464
  action: "progress",
3361
3465
  jobId: runId,
3362
3466
  item: outcome.route,
3467
+ // Kept for a help desk that predates `outcome`, which folds the
3468
+ // five into three and cannot tell a refusal from a skip.
3363
3469
  status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
3470
+ outcome: outcome.status,
3364
3471
  ...outcome.reason ? { note: outcome.reason } : {},
3365
3472
  // So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
3366
3473
  ...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
@@ -3413,10 +3520,8 @@ Approve one on the plan page, then run this again.`
3413
3520
  return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
3414
3521
  }
3415
3522
  });
3416
- console.log(
3417
- `
3418
- ${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
3419
- );
3523
+ console.log(`
3524
+ ${runCounts(report)}.`);
3420
3525
  const unclassified = unclassifiedLines(
3421
3526
  groupUnclassified(
3422
3527
  report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
@@ -3481,7 +3586,7 @@ whether they changed. Usually a spinner that never resolves or a looping animati
3481
3586
  const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
3482
3587
  if (gated.length) {
3483
3588
  console.log(
3484
- stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session \u2014 record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired \u2014 run `sign-in` again." : `
3589
+ stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session: record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired. Run `sign-in` again." : `
3485
3590
  Some screens are behind your sign-in. To photograph them, save a session against a
3486
3591
  DEMO tenant and pass it back:
3487
3592
 
@@ -3508,27 +3613,32 @@ ${attestation({
3508
3613
  console.log(`
3509
3614
  ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3510
3615
  }
3616
+ const counts = {
3617
+ screens: shots.length,
3618
+ captured: report.captured,
3619
+ unchanged: report.unchanged,
3620
+ skipped: report.skipped,
3621
+ refused: report.refused,
3622
+ failed: report.failed
3623
+ };
3511
3624
  if (runId) {
3625
+ await told;
3512
3626
  await tellHelpDesk(parsed.to, headers, {
3513
3627
  action: "finish",
3514
3628
  jobId: runId,
3629
+ // What the verdict below prints, so Setup can say what this log says.
3630
+ counts,
3515
3631
  ...report.survey.length ? { survey: report.survey } : {}
3516
3632
  });
3517
3633
  }
3518
- const verdict = runVerdict({
3519
- screens: shots.length,
3520
- captured: report.captured,
3521
- unchanged: report.unchanged,
3522
- skipped: report.skipped,
3523
- refused: report.refused,
3524
- failed: report.failed
3525
- });
3634
+ const verdict = runVerdict(counts);
3526
3635
  if (verdict.sentence) console.log(`
3527
3636
  ${verdict.sentence}`);
3528
3637
  if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
3529
3638
  return verdict.exit;
3530
3639
  } catch (error) {
3531
3640
  if (runId) {
3641
+ await told;
3532
3642
  await tellHelpDesk(parsed.to, headers, {
3533
3643
  action: "finish",
3534
3644
  jobId: runId,
@@ -3543,11 +3653,12 @@ ${verdict.sentence}`);
3543
3653
  }
3544
3654
  }
3545
3655
  async function openRun(to, headers, total) {
3656
+ const run = runIdentityFromEnv(process.env);
3546
3657
  try {
3547
3658
  const response = await fetch(`${to}/api/helpdesk/captures`, {
3548
3659
  method: "POST",
3549
3660
  headers: { ...headers, "content-type": "application/json" },
3550
- body: JSON.stringify({ action: "start", total })
3661
+ body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
3551
3662
  });
3552
3663
  if (!response.ok) return void 0;
3553
3664
  const body = await response.json();
@@ -3577,7 +3688,7 @@ async function signIn(argv) {
3577
3688
  return 2;
3578
3689
  }
3579
3690
  console.log(
3580
- `Opening ${baseUrl}. Sign in as your DEMO tenant \u2014 never a real customer.
3691
+ `Opening ${baseUrl}. Sign in as your DEMO tenant, never as a real customer.
3581
3692
  This window is yours: use SSO, a one-time code, whatever you normally do.
3582
3693
  The session will be saved to ${sessionPath} and sent nowhere.
3583
3694
  `
package/helpdesk-seed.mjs CHANGED
@@ -219,7 +219,7 @@ function gapReport(gaps2) {
219
219
  ""
220
220
  ];
221
221
  for (const gap of gaps2) {
222
- const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
222
+ const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
223
223
  lines.push(` ${gap.method} ${gap.path}${broke}`);
224
224
  if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
225
225
  }
@@ -733,6 +733,8 @@ var ALREADY_SAFE = [
733
733
  function alreadySafe(kind, value) {
734
734
  return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
735
735
  }
736
+ var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
737
+ var LABEL_REACH = 48;
736
738
  var PATTERNS = [
737
739
  {
738
740
  kind: "email",
@@ -782,8 +784,9 @@ var PATTERNS = [
782
784
  {
783
785
  kind: "date-of-birth",
784
786
  regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
785
- because: "a full date \u2014 if it is a birth date it identifies somebody",
786
- replace: () => "1970-01-01"
787
+ because: "a date labeled as a birth date, which identifies somebody",
788
+ replace: () => "1970-01-01",
789
+ labelled: BIRTH_LABEL
787
790
  },
788
791
  {
789
792
  kind: "passport",
@@ -804,8 +807,13 @@ function scrub(text, options = {}) {
804
807
  let output = text;
805
808
  for (const pattern of PATTERNS) {
806
809
  const seen = /* @__PURE__ */ new Map();
807
- output = output.replace(pattern.regex, (match) => {
810
+ output = output.replace(pattern.regex, (...args) => {
811
+ const match = args[0];
812
+ const named = typeof args[args.length - 1] === "object";
813
+ const offset = args[args.length - (named ? 3 : 2)];
814
+ const whole = args[args.length - (named ? 2 : 1)];
808
815
  if (alreadySafe(pattern.kind, match)) return match;
816
+ if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
809
817
  const already = seen.get(match);
810
818
  if (already) return already;
811
819
  const replacement = pattern.replace(match, seen.size);
@@ -822,7 +830,7 @@ function scrub(text, options = {}) {
822
830
  found: brand,
823
831
  replacement: brand,
824
832
  where,
825
- because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
833
+ because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
826
834
  });
827
835
  }
828
836
  }
@@ -838,7 +846,7 @@ function describeScrub(result) {
838
846
  }
839
847
  const lines = [`${result.findings.length} thing(s) found. Nothing publishes until you confirm.`, ""];
840
848
  for (const [kind, group] of byKind) {
841
- lines.push(` ${kind.toUpperCase().replace(/-/g, " ")} \u2014 ${group[0].because}`);
849
+ lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}: ${group[0].because}`);
842
850
  for (const finding of group.slice(0, 5)) {
843
851
  lines.push(
844
852
  kind === "brand" ? ` ${finding.found} (left as-is: decide whether this frame can ship)` : ` ${finding.found} \u2192 ${finding.replacement}`
@@ -994,7 +1002,20 @@ var CAPTURE_CONTRACT = {
994
1002
  /**
995
1003
  * Injected before every shot. Necessary, not sufficient — see above.
996
1004
  */
997
- motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
1005
+ motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
1006
+ /**
1007
+ * What an app uses to leave its own floating widgets out of the pictures.
1008
+ *
1009
+ * A feedback button or a chat launcher sits on top of the product on every
1010
+ * screen, and an article about the product should show only the product. The
1011
+ * capture sets `marker` on `<html>` of every page it photographs, so an app
1012
+ * can style a widget away under `html[data-helpdesk-capture]`, and it removes
1013
+ * any element carrying `hide` itself. Removed before the screen is read, so
1014
+ * what is not in the picture is not asked about either.
1015
+ */
1016
+ marker: "data-helpdesk-capture",
1017
+ hide: "data-helpdesk-hide",
1018
+ hideOff: "[data-helpdesk-hide]{display:none!important}"
998
1019
  };
999
1020
  var RECORDING_MISS_STATUS = 599;
1000
1021
 
@@ -1217,7 +1238,7 @@ async function record(args) {
1217
1238
  [
1218
1239
  `Recording everything sent to ${args.upstream}.`,
1219
1240
  "",
1220
- "Point your product's backend address at this and use it \u2014 sign in, open the screens",
1241
+ "Point your product's backend address at this and use it: sign in, open the screens",
1221
1242
  "you want documented, and make the errors happen if you can. What you visit is what",
1222
1243
  "the recording will be able to replay.",
1223
1244
  "",
@@ -1289,7 +1310,7 @@ function carriedReport(carried, cassette) {
1289
1310
  (field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
1290
1311
  ),
1291
1312
  "",
1292
- "That is not automatically wrong \u2014 an id, a status or a currency is meant to survive.",
1313
+ "That is not automatically wrong: an id, a status or a currency is meant to survive.",
1293
1314
  "But this package only knows the words every product shares: people, their contact",
1294
1315
  "details, their address, and the role each one is playing. Whatever your product holds",
1295
1316
  "that no other product does is on the list above, and it was not touched.",
@@ -1304,7 +1325,7 @@ function carriedReport(carried, cassette) {
1304
1325
  " company, note, amount",
1305
1326
  "",
1306
1327
  " note invents a sentence of about the same length, for anything free-text.",
1307
- " amount invents a different figure of the same size, for a salary or a balance \u2014",
1328
+ " amount invents a different figure of the same size, for a salary or a balance:",
1308
1329
  " a number is never detectable by shape, so naming it is the only way it is caught."
1309
1330
  ] : []
1310
1331
  ];
package/helpdesk.mjs CHANGED
@@ -188,7 +188,7 @@ function score(route, signals) {
188
188
  }
189
189
  if (hasSegment(route.path, MONEY_OR_ACCESS)) {
190
190
  total += 18;
191
- because.push("money or access \u2014 asked about out of proportion to its traffic");
191
+ because.push("money or access: asked about out of proportion to its traffic");
192
192
  }
193
193
  if (hasSegment(route.path, ENTRY_POINT)) {
194
194
  total += 16;
@@ -538,11 +538,22 @@ var LIMITS = {
538
538
  fields: 24,
539
539
  notices: 20,
540
540
  warnings: 12,
541
+ disabled: 24,
542
+ empty: 12,
543
+ gatedBy: 8,
541
544
  choices: 12,
542
545
  /** Longer than this is a paragraph of copy, not a label. */
543
546
  label: 120,
544
547
  sentence: 300
545
548
  };
549
+ var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
550
+ function isEmptyState(text) {
551
+ const sentence = text.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
552
+ if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
553
+ if (/\b(?:until|unless)\b/i.test(sentence)) return false;
554
+ if (sentence.split(" ").length < 2) return false;
555
+ return EMPTY_STATE.test(sentence);
556
+ }
546
557
  var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
547
558
  var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
548
559
  var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
@@ -553,18 +564,24 @@ function readScreenFacts(files, options = {}) {
553
564
  const links = [];
554
565
  const fields = [];
555
566
  const notices = [];
556
- const warnings = [];
567
+ const disabled = [];
568
+ const empty = [];
557
569
  const messageDepth = options.messageDepth ?? 2;
570
+ const read = files.filter((file) => !options.skip?.(file.path));
571
+ const gatedBy = flagsGating(read, messageDepth);
558
572
  for (const file of files) {
559
573
  if (options.skip?.(file.path)) continue;
560
574
  const source = withoutComments(file.contents);
561
- const before = headings.length + actions.length + links.length + fields.length + notices.length;
562
- readElements(source, { headings, actions, links, fields });
563
- if ((file.depth ?? 0) <= messageDepth) readMessages(source, notices);
564
- const after = headings.length + actions.length + links.length + fields.length + notices.length;
575
+ const before = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
576
+ readElements(source, { headings, actions, links, fields, disabled, empty });
577
+ if ((file.depth ?? 0) <= messageDepth) {
578
+ readMessages(source, notices);
579
+ readEmptyMessages(source, empty);
580
+ }
581
+ const after = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
565
582
  if (after > before) facts.readFrom.push(file.path);
566
583
  }
567
- return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom });
584
+ return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom, disabled, empty, gatedBy });
568
585
  }
569
586
  function finishScreenFacts(parts) {
570
587
  const warnings = [];
@@ -581,23 +598,48 @@ function finishScreenFacts(parts) {
581
598
  fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
582
599
  notices: capped(parts.notices, LIMITS.notices),
583
600
  warnings: capped(warnings, LIMITS.warnings),
584
- readFrom: parts.readFrom
601
+ readFrom: parts.readFrom,
602
+ ...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
603
+ };
604
+ }
605
+ function states(parts) {
606
+ const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
607
+ const empty = capped(parts.empty ?? [], LIMITS.empty);
608
+ const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
609
+ return {
610
+ ...disabled.length ? { disabled } : {},
611
+ ...empty.length ? { empty } : {},
612
+ ...gatedBy.length ? { gatedBy } : {}
585
613
  };
586
614
  }
587
615
  var HEADING_TAG = /^h[1-4]$/;
588
616
  var BUTTON_TAG = /^(button|Button|MenuItem|Tab)$/;
589
617
  var LINK_TAG = /^(a|Link|NavLink)$/;
590
618
  var FIELD_TAG = /^(input|Input|textarea|Textarea|select|Select|Checkbox|Radio|Switch|Toggle)$/;
619
+ var TEXT_TAG = /^(p|span|small|td|li|div)$/;
620
+ var NOT_INLINE = /<(?!\/?(?:strong|em|b|i|code|br)\b)[A-Za-z]/;
621
+ var EMPTY_TAG = /^(EmptyState|Empty|EmptyMessage|EmptyPanel|NoResults|ZeroState|BlankState)$/;
622
+ var EMPTY_PROPS = ["emptyState", "emptyText", "emptyMessage", "emptyLabel", "emptyTitle", "emptyDescription"];
623
+ var DISABLED_PROP = /(?:^|\s)(?:disabled|aria-disabled)(?=\s|=|\/|$)(?!\s*=\s*\{\s*false\s*\})(?!\s*=\s*["']false["'])/;
591
624
  function readElements(source, into) {
592
625
  for (const tag of tagsIn(source)) {
593
626
  const attributes = readAttributes(tag.attributes);
594
627
  if (HEADING_TAG.test(tag.name)) {
595
- push(into.headings, visibleText(childrenOf(source, tag)));
628
+ const children = childrenOf(source, tag);
629
+ const heading = visibleText(children);
630
+ push(into.headings, heading);
631
+ for (const said of [heading, ...chosenIn(children)]) if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
596
632
  continue;
597
633
  }
598
634
  push(into.headings, attributes["heading"]);
635
+ for (const prop of EMPTY_PROPS) pushWords(into.empty, attributes[prop]);
636
+ if (EMPTY_TAG.test(tag.name)) {
637
+ for (const prop of ["title", "description", "message"]) pushWords(into.empty, attributes[prop]);
638
+ }
599
639
  if (BUTTON_TAG.test(tag.name)) {
600
- push(into.actions, visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"]);
640
+ const label = visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"];
641
+ push(into.actions, label);
642
+ if (DISABLED_PROP.test(tag.attributes)) push(into.disabled, label);
601
643
  continue;
602
644
  }
603
645
  if (LINK_TAG.test(tag.name)) {
@@ -611,6 +653,16 @@ function readElements(source, into) {
611
653
  if (FIELD_TAG.test(tag.name)) {
612
654
  const field = fieldFrom(tag.name, attributes, source, tag);
613
655
  if (field) into.fields.push(field);
656
+ if (field && DISABLED_PROP.test(tag.attributes)) push(into.disabled, field.label);
657
+ continue;
658
+ }
659
+ if (TEXT_TAG.test(tag.name)) {
660
+ const children = childrenOf(source, tag);
661
+ if (NOT_INLINE.test(children)) continue;
662
+ const written = visibleText(children, LIMITS.sentence).replace(/\s+([.,;:!])/g, "$1");
663
+ for (const said of [written, ...chosenIn(children)]) {
664
+ if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
665
+ }
614
666
  }
615
667
  }
616
668
  for (const tag of tagsIn(source)) {
@@ -723,7 +775,7 @@ function childrenOf(source, tag) {
723
775
  function escapeName(name) {
724
776
  return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
725
777
  }
726
- function visibleText(fragment) {
778
+ function visibleText(fragment, limit = LIMITS.label) {
727
779
  if (!fragment) return "";
728
780
  let text = fragment;
729
781
  let previous = "";
@@ -732,7 +784,26 @@ function visibleText(fragment) {
732
784
  text = text.replace(/\{[^{}]*\}/g, " ");
733
785
  }
734
786
  text = text.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&mdash;/g, "\u2014").replace(/&ndash;/g, "\u2013").replace(/&(?:l|r)squo;/g, "'").replace(/&(?:l|r)dquo;/g, '"').replace(/&#\d+;/g, " ").replace(/\s+/g, " ").trim();
735
- return /[A-Za-z]/.test(text) ? trim(text, LIMITS.label) : "";
787
+ return /[A-Za-z]/.test(text) ? trim(text, limit) : "";
788
+ }
789
+ function chosenIn(fragment) {
790
+ const chosen = [];
791
+ let depth2 = 0;
792
+ let start = 0;
793
+ for (let index = 0; index < fragment.length; index += 1) {
794
+ if (fragment[index] === "{") {
795
+ if (depth2 === 0) start = index + 1;
796
+ depth2 += 1;
797
+ } else if (fragment[index] === "}" && depth2 > 0) {
798
+ depth2 -= 1;
799
+ if (depth2 > 0) continue;
800
+ for (const literal of fragment.slice(start, index).matchAll(/"([^"\\]*)"|'([^'\\]*)'|`([^`\\$]*)`/g)) {
801
+ const text = (literal[1] ?? literal[2] ?? literal[3] ?? "").replace(/\s+/g, " ").trim();
802
+ if (text) chosen.push(text);
803
+ }
804
+ }
805
+ }
806
+ return chosen;
736
807
  }
737
808
  function readAttributes(raw) {
738
809
  const attributes = {};
@@ -752,11 +823,80 @@ function humanise(name) {
752
823
  function scrubExpressions(value) {
753
824
  return value.replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
754
825
  }
755
- function push(into, value) {
826
+ function push(into, value, limit = LIMITS.label) {
756
827
  const text = value === void 0 ? "" : scrubExpressions(value);
757
828
  if (!text || !/[A-Za-z]/.test(text)) return;
758
829
  if ((text.match(/…/g)?.length ?? 0) > 1) return;
759
- into.push(trim(text, LIMITS.label));
830
+ into.push(trim(text, limit));
831
+ }
832
+ function pushWords(into, value) {
833
+ if (value && value.trim().split(/\s+/).length >= 2) push(into, value, LIMITS.sentence);
834
+ }
835
+ var EMPTY_PROPERTY = /(?:^|[\s,{(])(?:emptyState|emptyText|emptyMessage|emptyLabel|emptyTitle|emptyDescription)\s*:\s*(["'`])((?:(?!\1)[\s\S]){4,}?)\1/g;
836
+ function readEmptyMessages(source, into) {
837
+ for (const match of source.matchAll(EMPTY_PROPERTY)) pushWords(into, match[2].replace(/\s+/g, " ").trim());
838
+ }
839
+ var FLAG_NAME = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:ENABLED|ENABLE|FLAG|FEATURE)|(?:ENABLE|FEATURE|FLAG)_[A-Z0-9]+(?:_[A-Z0-9]+)*)\b/g;
840
+ var FLAG_NAME_ONLY = /^[A-Za-z0-9_.:-]{1,80}$/;
841
+ var DECLARATION = /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=>/gm;
842
+ var NOT_THERE = /\b(?:notFound|redirect)\s*\(/g;
843
+ function flagsIn(text) {
844
+ return [...new Set([...text.matchAll(FLAG_NAME)].map((match) => match[1]))];
845
+ }
846
+ function flagsGating(files, messageDepth) {
847
+ const named = /* @__PURE__ */ new Map();
848
+ for (const file of files) {
849
+ if ((file.depth ?? 0) > messageDepth) continue;
850
+ const source = withoutComments(file.contents);
851
+ const declarations = [...source.matchAll(DECLARATION)];
852
+ declarations.forEach((declaration, index) => {
853
+ const name = declaration[1] ?? declaration[2];
854
+ const body = source.slice(declaration.index, declarations[index + 1]?.index ?? source.length);
855
+ const flags = flagsIn(body);
856
+ if (flags.length) named.set(name, /* @__PURE__ */ new Set([...named.get(name) ?? [], ...flags]));
857
+ });
858
+ }
859
+ const gating = /* @__PURE__ */ new Set();
860
+ for (const file of files) {
861
+ if ((file.depth ?? 0) > 0) continue;
862
+ const source = withoutComments(file.contents);
863
+ for (const call of source.matchAll(NOT_THERE)) {
864
+ const condition = guardOf(source, call.index);
865
+ if (condition === void 0) continue;
866
+ for (const flag of flagsIn(condition)) gating.add(flag);
867
+ for (const [, dotted, bracketed] of condition.matchAll(
868
+ /process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\])/g
869
+ )) {
870
+ gating.add(dotted ?? bracketed);
871
+ }
872
+ for (const [, args] of condition.matchAll(/\b\w*(?:[Ff]lag|[Ff]eature|[Ee]nabled)\w*\s*\(([^()]*)\)/g)) {
873
+ for (const [, literal] of args.matchAll(/["'`]([^"'`]+)["'`]/g)) {
874
+ if (FLAG_NAME_ONLY.test(literal)) gating.add(literal);
875
+ }
876
+ }
877
+ for (const [, callee] of condition.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
878
+ for (const flag of named.get(callee) ?? []) gating.add(flag);
879
+ }
880
+ }
881
+ }
882
+ return [...gating];
883
+ }
884
+ function guardOf(source, callAt) {
885
+ const before = source.slice(Math.max(0, callAt - 400), callAt);
886
+ const opened = [...before.matchAll(/\bif\s*\(/g)].at(-1);
887
+ if (!opened) return void 0;
888
+ const start = opened.index + opened[0].length;
889
+ let depth2 = 1;
890
+ let cursor = start;
891
+ while (cursor < before.length && depth2 > 0) {
892
+ const character = before[cursor];
893
+ if (character === "(") depth2 += 1;
894
+ else if (character === ")") depth2 -= 1;
895
+ cursor += 1;
896
+ }
897
+ if (depth2 !== 0) return void 0;
898
+ if (!/^\s*(?:\{\s*)?(?:return\s+)?$/.test(before.slice(cursor))) return void 0;
899
+ return before.slice(start, cursor - 1);
760
900
  }
761
901
  function trim(value, limit) {
762
902
  return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
@@ -818,7 +958,9 @@ function readProduct(routes, options) {
818
958
  }
819
959
  function hasAnything(facts) {
820
960
  return Boolean(
821
- facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length
961
+ facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length || // A flagged page whose screen is all in a component the reader cannot
962
+ // follow still says it is behind a flag, and an empty state is still words.
963
+ facts.gatedBy?.length || facts.empty?.length
822
964
  );
823
965
  }
824
966
  function describeReading(routes) {
@@ -827,6 +969,34 @@ function describeReading(routes) {
827
969
  return { screens: documentable.length, read, pathsOnly: read === 0 };
828
970
  }
829
971
 
972
+ // core/github/remote.ts
973
+ function repositoryFromRemote(remote) {
974
+ const raw = remote?.trim();
975
+ if (!raw) return void 0;
976
+ if (OWNER_NAME.test(raw)) return withoutGit(raw);
977
+ const scp = SCP.exec(raw);
978
+ const at = scp ? { host: scp[1], hostname: scp[1], path: scp[2] } : located(raw);
979
+ if (!at) return void 0;
980
+ const parts = withoutGit(at.path.replace(/^\/+|\/+$/g, "")).split("/").filter(Boolean);
981
+ if (!parts.length) return void 0;
982
+ if (GITHUB.test(at.hostname)) return parts.length === 2 ? `${parts[0]}/${parts[1]}` : void 0;
983
+ return `${at.host}/${parts.join("/")}`;
984
+ }
985
+ var OWNER_NAME = /^[A-Za-z0-9][\w-]*\/(?!\.\.?$)[\w.-]+$/;
986
+ var SCP = /^(?:[^@\s/]+@)?([^:\s/]{2,}):(?!\/)(\S+)$/;
987
+ var GITHUB = /^(www\.)?github\.com$/i;
988
+ function withoutGit(path) {
989
+ return path.replace(/\.git$/, "");
990
+ }
991
+ function located(raw) {
992
+ try {
993
+ const url = new URL(raw);
994
+ return url.hostname && /^(https?|ssh|git):$/.test(url.protocol) ? { host: url.host, hostname: url.hostname, path: url.pathname } : void 0;
995
+ } catch {
996
+ return void 0;
997
+ }
998
+ }
999
+
830
1000
  // core/markers/propose.ts
831
1001
  function toLines(contents) {
832
1002
  const lines = contents.split("\n");
@@ -950,7 +1120,7 @@ function markersPullRequestBody(result) {
950
1120
  }
951
1121
  lines.push(
952
1122
  "",
953
- "**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route \u2014 this only makes the match more precise."
1123
+ "**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route. This only makes the match more precise."
954
1124
  );
955
1125
  if (result.unmatchedSlugs.length > 0) {
956
1126
  lines.push(
@@ -1031,7 +1201,7 @@ async function runMap(options) {
1031
1201
  lines.push(
1032
1202
  "",
1033
1203
  proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
1034
- git apply ${options.markersOut}` : "Every file already carries its marker \u2014 nothing to propose."
1204
+ git apply ${options.markersOut}` : "Every file already carries its marker: nothing to propose."
1035
1205
  );
1036
1206
  }
1037
1207
  let sent = false;
@@ -1054,7 +1224,7 @@ async function runMap(options) {
1054
1224
  throw new Error(`The help desk refused the map (${response.status}). ${body}`);
1055
1225
  }
1056
1226
  sent = true;
1057
- lines.push("", "Sent. Whoever asked you to run this can see it now \u2014 you are done.");
1227
+ lines.push("", "Sent. Whoever asked you to run this can see it now. You are done.");
1058
1228
  } else if (options.token) {
1059
1229
  lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
1060
1230
  }
@@ -1077,7 +1247,7 @@ function readTsconfig(cwd) {
1077
1247
  }
1078
1248
  function originOf(cwd) {
1079
1249
  try {
1080
- return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }).trim();
1250
+ return repositoryFromRemote(execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }));
1081
1251
  } catch {
1082
1252
  return void 0;
1083
1253
  }
@@ -1089,6 +1259,16 @@ function headCommit(cwd) {
1089
1259
  return null;
1090
1260
  }
1091
1261
  }
1262
+ var WORKFLOW_ACTIONS = {
1263
+ checkout: "actions/checkout@v7",
1264
+ setupNode: "actions/setup-node@v7"
1265
+ };
1266
+ var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
1267
+ - uses: ${WORKFLOW_ACTIONS.setupNode}
1268
+ with:
1269
+ node-version: "22"
1270
+ package-manager-cache: false
1271
+ `;
1092
1272
 
1093
1273
  // adapters/invoked-as-command.ts
1094
1274
  import { realpathSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.5",
3
+ "version": "0.1.10",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {