@olomonhelpdesk2/helpdesk 0.1.4 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,13 +784,14 @@ 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",
790
793
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
791
- because: "the shape of a passport or licence number",
794
+ because: "the shape of a passport or license number",
792
795
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
793
796
  },
794
797
  {
@@ -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}`
@@ -1217,7 +1225,7 @@ async function record(args) {
1217
1225
  [
1218
1226
  `Recording everything sent to ${args.upstream}.`,
1219
1227
  "",
1220
- "Point your product's backend address at this and use it \u2014 sign in, open the screens",
1228
+ "Point your product's backend address at this and use it: sign in, open the screens",
1221
1229
  "you want documented, and make the errors happen if you can. What you visit is what",
1222
1230
  "the recording will be able to replay.",
1223
1231
  "",
@@ -1270,7 +1278,7 @@ ${carriedReport(sealed.carried, args.cassette).join("\n")}`);
1270
1278
  console.log(`
1271
1279
  ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
1272
1280
  console.log(
1273
- "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognised. Read them before you commit this."
1281
+ "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognized. Read them before you commit this."
1274
1282
  );
1275
1283
  return 1;
1276
1284
  }
@@ -1278,7 +1286,7 @@ ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}
1278
1286
  return 0;
1279
1287
  }
1280
1288
  function carriedReport(carried, cassette) {
1281
- if (!carried.length) return ["Every key in the recording was recognised and rewritten."];
1289
+ if (!carried.length) return ["Every key in the recording was recognized and rewritten."];
1282
1290
  const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
1283
1291
  const width = Math.max(...ranked.map((field) => field.key.length));
1284
1292
  const worth = ranked.filter((field) => score(field) > 0);
@@ -1289,7 +1297,7 @@ function carriedReport(carried, cassette) {
1289
1297
  (field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
1290
1298
  ),
1291
1299
  "",
1292
- "That is not automatically wrong \u2014 an id, a status or a currency is meant to survive.",
1300
+ "That is not automatically wrong: an id, a status or a currency is meant to survive.",
1293
1301
  "But this package only knows the words every product shares: people, their contact",
1294
1302
  "details, their address, and the role each one is playing. Whatever your product holds",
1295
1303
  "that no other product does is on the list above, and it was not touched.",
@@ -1304,7 +1312,7 @@ function carriedReport(carried, cassette) {
1304
1312
  " company, note, amount",
1305
1313
  "",
1306
1314
  " 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",
1315
+ " amount invents a different figure of the same size, for a salary or a balance:",
1308
1316
  " a number is never detectable by shape, so naming it is the only way it is caught."
1309
1317
  ] : []
1310
1318
  ];
@@ -1380,7 +1388,7 @@ async function routes(args) {
1380
1388
  const cassette = parseCassette(await readFile(args.cassette, "utf8"));
1381
1389
  const found = exampleRoutes(cassette);
1382
1390
  if (!found.length) {
1383
- console.log("The recording holds no parameterised routes, so there are no example ids to offer.");
1391
+ console.log("The recording holds no parameterized routes, so there are no example ids to offer.");
1384
1392
  return 0;
1385
1393
  }
1386
1394
  console.log("Routes the recording can answer, with an identifier it has already served:\n");
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`;
@@ -793,6 +933,7 @@ function readProduct(routes, options) {
793
933
  };
794
934
  const documentable = routes.filter((route) => !route.skipped);
795
935
  const total = Math.min(documentable.length, maxScreens);
936
+ const reading = { how: "source", commit: options.commit ?? null };
796
937
  let done = 0;
797
938
  let withFacts = 0;
798
939
  const out = routes.map((route) => {
@@ -803,18 +944,23 @@ function readProduct(routes, options) {
803
944
  const source = read(reached.path);
804
945
  if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
805
946
  }
806
- const facts = readScreenFacts(files);
807
947
  done += 1;
808
948
  options.onScreen?.(route, done, total);
809
- if (!hasAnything(facts)) return route;
949
+ if (files.length === 0) return route;
950
+ const facts = readScreenFacts(files);
951
+ if (!hasAnything(facts)) {
952
+ return { ...route, facts: noScreenFacts(reading) };
953
+ }
810
954
  withFacts += 1;
811
- return { ...route, facts };
955
+ return { ...route, facts: { ...facts, read: reading } };
812
956
  });
813
957
  return { routes: out, read: withFacts, filesOpened: opened.size };
814
958
  }
815
959
  function hasAnything(facts) {
816
960
  return Boolean(
817
- 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
818
964
  );
819
965
  }
820
966
  function describeReading(routes) {
@@ -823,6 +969,34 @@ function describeReading(routes) {
823
969
  return { screens: documentable.length, read, pathsOnly: read === 0 };
824
970
  }
825
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
+
826
1000
  // core/markers/propose.ts
827
1001
  function toLines(contents) {
828
1002
  const lines = contents.split("\n");
@@ -937,7 +1111,7 @@ function markersPullRequestBody(result) {
937
1111
  const lines = [
938
1112
  "This adds one-line comments that link screens in this repository to the help articles that describe them.",
939
1113
  "",
940
- "**Nothing here changes behaviour.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
1114
+ "**Nothing here changes behavior.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
941
1115
  "",
942
1116
  `${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
943
1117
  ];
@@ -946,7 +1120,7 @@ function markersPullRequestBody(result) {
946
1120
  }
947
1121
  lines.push(
948
1122
  "",
949
- "**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."
950
1124
  );
951
1125
  if (result.unmatchedSlugs.length > 0) {
952
1126
  lines.push(
@@ -1000,7 +1174,10 @@ async function runMap(options) {
1000
1174
  }
1001
1175
  },
1002
1176
  aliases: aliasesFromTsconfig(tsconfig),
1003
- baseUrl: baseUrlFromTsconfig(tsconfig)
1177
+ baseUrl: baseUrlFromTsconfig(tsconfig),
1178
+ // Stamped onto every reading, so a map carries the commit its labels and
1179
+ // controls were read at and a later one can tell fresh from stale.
1180
+ commit: headCommit(options.cwd)
1004
1181
  });
1005
1182
  routes = read.routes;
1006
1183
  const reading = describeReading(routes);
@@ -1024,7 +1201,7 @@ async function runMap(options) {
1024
1201
  lines.push(
1025
1202
  "",
1026
1203
  proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
1027
- 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."
1028
1205
  );
1029
1206
  }
1030
1207
  let sent = false;
@@ -1047,7 +1224,7 @@ async function runMap(options) {
1047
1224
  throw new Error(`The help desk refused the map (${response.status}). ${body}`);
1048
1225
  }
1049
1226
  sent = true;
1050
- 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.");
1051
1228
  } else if (options.token) {
1052
1229
  lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
1053
1230
  }
@@ -1070,11 +1247,28 @@ function readTsconfig(cwd) {
1070
1247
  }
1071
1248
  function originOf(cwd) {
1072
1249
  try {
1073
- return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }).trim();
1250
+ return repositoryFromRemote(execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }));
1074
1251
  } catch {
1075
1252
  return void 0;
1076
1253
  }
1077
1254
  }
1255
+ function headCommit(cwd) {
1256
+ try {
1257
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
1258
+ } catch {
1259
+ return null;
1260
+ }
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
+ `;
1078
1272
 
1079
1273
  // adapters/invoked-as-command.ts
1080
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.4",
3
+ "version": "0.1.9",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {