@descent-vtt/spec-graph 0.9.1 → 0.9.3

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/CHANGELOG.md +146 -0
  2. package/README.md +162 -15
  3. package/dist/baseline.js +6 -0
  4. package/dist/baseline.js.map +1 -1
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +50 -17
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +3 -3
  9. package/dist/config.js +16 -8
  10. package/dist/config.js.map +1 -1
  11. package/dist/diff.js +14 -0
  12. package/dist/diff.js.map +1 -1
  13. package/dist/directives.js +9 -6
  14. package/dist/directives.js.map +1 -1
  15. package/dist/extract.js +120 -57
  16. package/dist/extract.js.map +1 -1
  17. package/dist/glob.js +28 -16
  18. package/dist/glob.js.map +1 -1
  19. package/dist/graph.d.ts +2 -1
  20. package/dist/graph.js +10 -1
  21. package/dist/graph.js.map +1 -1
  22. package/dist/identity.d.ts +6 -0
  23. package/dist/identity.js +68 -68
  24. package/dist/identity.js.map +1 -1
  25. package/dist/lifecycle.js +17 -11
  26. package/dist/lifecycle.js.map +1 -1
  27. package/dist/report.js +34 -5
  28. package/dist/report.js.map +1 -1
  29. package/dist/resolve.js +40 -14
  30. package/dist/resolve.js.map +1 -1
  31. package/dist/rules.d.ts +8 -0
  32. package/dist/rules.js +83 -16
  33. package/dist/rules.js.map +1 -1
  34. package/dist/runner.js +22 -4
  35. package/dist/runner.js.map +1 -1
  36. package/dist/sections.d.ts +9 -0
  37. package/dist/sections.js +60 -9
  38. package/dist/sections.js.map +1 -1
  39. package/dist/select.d.ts +18 -2
  40. package/dist/select.js +46 -16
  41. package/dist/select.js.map +1 -1
  42. package/dist/source.js +2 -0
  43. package/dist/source.js.map +1 -1
  44. package/dist/state.js +46 -12
  45. package/dist/state.js.map +1 -1
  46. package/dist/yaml.js +11 -3
  47. package/dist/yaml.js.map +1 -1
  48. package/package.json +6 -3
  49. package/src/vendor/spec-core/README.md +0 -8
package/dist/extract.js CHANGED
@@ -18,7 +18,7 @@ import { attr, attrList, bindItemDirectives, parseDirectives } from './directive
18
18
  import { identify, isDocumentTarget, isExternal, looksLikePath, normaliseRef, parsePrefixedRef, withinOneEdit, ID_KEYS, } from './identity.js';
19
19
  import { isStatusHeading, phaseFromPath, phaseOf, STATUS_KEYS, supersessionTargetsIn } from './lifecycle.js';
20
20
  import { anchorsOf, isMarkdownLine, isOnlyComment, referenceLinks, scanMarkdown, slugify, } from './markdown.js';
21
- import { findSpecificationRegions, regionAt } from './sections.js';
21
+ import { findSpecificationRegions, opensHeading, regionAt } from './sections.js';
22
22
  import { resolveItemState } from './state.js';
23
23
  import { refOf } from './source.js';
24
24
  import { readEntries, toRecord, valuesOf } from './yaml.js';
@@ -105,9 +105,9 @@ export const RELATION_KEYS = {
105
105
  * catch (ADR-0006).
106
106
  */
107
107
  function looksLikeCitation(value) {
108
+ // A blank value needs no case of its own: it is neither an identifier nor a
109
+ // path, so both tests below already answer no.
108
110
  const trimmed = value.trim();
109
- if (trimmed.length === 0)
110
- return false;
111
111
  if (parsePrefixedRef(trimmed) !== null)
112
112
  return true;
113
113
  return looksLikePath(trimmed) && isDocumentTarget(trimmed) && !isExternal(trimmed);
@@ -125,6 +125,9 @@ function misreadRelationKeys(byKey, from, at) {
125
125
  const out = [];
126
126
  for (const [key, entry] of byKey) {
127
127
  const folded = foldRelationKey(key);
128
+ // A key of nothing but punctuation folds to nothing. The shortest relation
129
+ // key folds to four letters, so the edit test below would pass it over too
130
+ // and no test can see this half; it keeps that true for any future key.
128
131
  if (folded.length === 0 || RELATION_INDEX.has(folded))
129
132
  continue;
130
133
  if (!valuesOf(entry).some(looksLikeCitation))
@@ -315,6 +318,9 @@ const VERB_RULES = [
315
318
  * disclaimed by [138]" was being read as a delegation *to* 138, which is the
316
319
  * opposite of what the sentence says, and an inference that inverts its source
317
320
  * is worse than no inference at all.
321
+ *
322
+ * The `^` never decides anything: the text tested starts just after a phrase,
323
+ * and a phrase only matches where no letter follows it.
318
324
  */
319
325
  const NEGATION = /(?:^|\s)(?:no|not|nobody|none|never|neither|nor|nothing|without)(?:\s|$)/;
320
326
  /** How close a governing phrase must sit to the reference it governs. */
@@ -328,6 +334,10 @@ const VERB_WINDOW = 40;
328
334
  * prefers `partially resolved` over `resolved`, matching the precedence the
329
335
  * per-phrase search had. Lookarounds rather than `\b`, because phrases contain
330
336
  * hyphens and `\b` does the wrong thing around them.
337
+ *
338
+ * Neither the sort nor the escaping decides anything today, and no test can
339
+ * pin them: no phrase in the table is another one followed by more words, and
340
+ * none holds a character a pattern would read. Both are for the next phrase.
331
341
  */
332
342
  const VERB_LOOKUP = new Map(VERB_RULES.flatMap((rule) => rule.phrases.map((phrase) => [phrase, { kind: rule.kind, inverted: rule.inverted }])));
333
343
  const VERB_PATTERN = new RegExp(`(?<![\\p{L}\\p{N}])(?:${[...VERB_LOOKUP.keys()]
@@ -394,13 +404,12 @@ const OBLIGATION_SECTIONS = new Set([
394
404
  * Prefixes that look like specification identifiers but never are.
395
405
  *
396
406
  * The corpus filter in `resolve.ts` is the real defence; this list only keeps
397
- * the obvious noise out of the candidate set.
407
+ * the obvious noise out of the candidate set. A single letter - `v2`, `x86`,
408
+ * `p99` - is not here because no prefix that short is ever read as a family.
398
409
  */
399
410
  const NOT_A_FAMILY = new Set([
400
- 'v',
401
411
  'ver',
402
412
  'version',
403
- 'p',
404
413
  'pp',
405
414
  'fig',
406
415
  'figure',
@@ -431,8 +440,6 @@ const NOT_A_FAMILY = new Set([
431
440
  'ssl',
432
441
  'es',
433
442
  'ecma',
434
- 'x',
435
- 'h',
436
443
  'p99',
437
444
  'base',
438
445
  'sql',
@@ -445,10 +452,9 @@ const NOT_A_FAMILY = new Set([
445
452
  'python',
446
453
  'java',
447
454
  'go',
448
- 'c',
449
455
  'cpp',
450
456
  ]);
451
- const BARE_REF = /\b([A-Za-z][A-Za-z0-9]{0,14})[\s._-]?(\d{1,6})\b/g;
457
+ const BARE_REF = /\b[A-Za-z][A-Za-z0-9]{0,14}[\s._-]?\d{1,6}\b/g;
452
458
  /**
453
459
  * Reads one file into graph fragments, one entry per specification in it.
454
460
  *
@@ -538,14 +544,24 @@ export function extractDocument(input) {
538
544
  const provisional = identityOf(frontMatterId, null);
539
545
  const provisionalRegions = findSpecificationRegions(scanned, directives, provisional.id);
540
546
  const nodeDirective = directives.find((directive) => directive.name === 'spec-node' &&
541
- !provisionalRegions.some((region) => (region.heading !== null || region.row !== null) &&
547
+ !provisionalRegions.some(
548
+ // `>=` and `>` agree on the start: a region starts at a heading's
549
+ // first character or a row's, and a line that opens with a comment is
550
+ // neither a heading nor a row.
551
+ (region) => (region.heading !== null || region.row !== null) &&
542
552
  directive.start >= region.start &&
543
553
  directive.end <= region.end)) ?? null;
544
554
  const declaredId = (nodeDirective ? attr(nodeDirective, 'id')?.value : null) ?? frontMatterId;
545
- const identity = declaredId === frontMatterId && nodeDirective === null
546
- ? provisional
547
- : identityOf(declaredId, nodeDirective);
548
- const status = readStatus(scanned, byKey, nodeDirective, index, file);
555
+ // With no directive of its own the file's identity is the provisional one,
556
+ // computed from exactly these inputs. Settling it again would give the same
557
+ // answer, which is why no test can tell this reuse from a second call.
558
+ const identity = nodeDirective === null ? provisional : identityOf(declaredId, nodeDirective);
559
+ // A register's sections are specifications too. The file is always the first
560
+ // region, so a one-decision file behaves exactly as it did before. The regions
561
+ // depend on the file's id alone, so when the directive left it unchanged the
562
+ // provisional ones are the answer, and finding them again is the same answer.
563
+ const regions = identity.id === provisional.id ? provisionalRegions : findSpecificationRegions(scanned, directives, identity.id);
564
+ const status = readStatus(scanned, byKey, nodeDirective, index, file, regions);
549
565
  const pathPhase = phaseFromPath(input.path);
550
566
  // A record is a categorical statement about what the file *is*, made by
551
567
  // configuration or by an explicit directive, so it outranks a status word
@@ -569,9 +585,6 @@ export function extractDocument(input) {
569
585
  statusAt: status.at,
570
586
  frontMatter: toRecord(entries),
571
587
  };
572
- // A register's sections are specifications too. The file is always the first
573
- // region, so a one-decision file behaves exactly as it did before.
574
- const regions = identity.id === provisional.id ? provisionalRegions : findSpecificationRegions(scanned, directives, identity.id);
575
588
  // Every region but the first: a heading section, or a row of a register kept
576
589
  // as a table.
577
590
  const subSpecifications = regions
@@ -588,20 +601,14 @@ export function extractDocument(input) {
588
601
  record,
589
602
  inherited,
590
603
  }));
591
- const ownerAt = (offset) => {
604
+ // The specification that owns an offset: the innermost region around it, or
605
+ // the file when no region holds it and `regionAt` answers `null`.
606
+ const documentAt = (offset) => {
592
607
  const region = regionAt(regions, offset);
593
- if (region === null)
594
- return identity.id;
595
- const owner = subSpecifications.find((spec) => spec.region === region);
596
- return owner?.document.id ?? identity.id;
608
+ return subSpecifications.find((spec) => spec.region === region)?.document ?? document;
597
609
  };
610
+ const ownerAt = (offset) => documentAt(offset).id;
598
611
  const allItems = extractItems({ scanned, directives, ownerAt, file, index });
599
- const documentAt = (offset) => {
600
- const id = ownerAt(offset);
601
- if (id === identity.id)
602
- return document;
603
- return subSpecifications.find((spec) => spec.document.id === id)?.document ?? document;
604
- };
605
612
  const allReferences = extractReferences({
606
613
  scanned,
607
614
  directives,
@@ -682,7 +689,8 @@ function buildRegion(context) {
682
689
  id: identity.id,
683
690
  kind: 'document',
684
691
  title: (region.directive ? attr(region.directive, 'title')?.value : null) ?? region.title ?? identity.id,
685
- // Anchored at its heading, or at the row when the register is a table.
692
+ // Anchored at its heading, or at the row when the register is a table. Every
693
+ // region here has one or the other, so neither `?.` ever stops the chain.
686
694
  at: at(region.start, Math.min(region.end, region.heading?.end ?? region.row?.end ?? region.end)),
687
695
  path: input.path,
688
696
  aliases: identity.aliases,
@@ -712,6 +720,8 @@ function buildRegion(context) {
712
720
  document,
713
721
  extracted: {
714
722
  document,
723
+ // Never read: the file's extraction replaces it once every item's owner
724
+ // is known.
715
725
  items: [],
716
726
  references,
717
727
  problems: [],
@@ -733,8 +743,13 @@ function buildRegion(context) {
733
743
  * deliberate each is: a directive, a front-matter field, then the body of a
734
744
  * `## Status` section. A repository using none of them still gets a phase from
735
745
  * its directory layout, which is handled by the caller.
746
+ *
747
+ * A status section inside a register's region is that region's, not the
748
+ * file's. Read as the file's, a register whose first decision said
749
+ * `### Status` / `Superseded by ADR-0009` was itself retired, and itself
750
+ * superseded by ADR-0009, for something one of its decisions said.
736
751
  */
737
- function readStatus(scanned, byKey, nodeDirective, index, file) {
752
+ function readStatus(scanned, byKey, nodeDirective, index, file, regions) {
738
753
  if (nodeDirective) {
739
754
  const declared = attr(nodeDirective, 'status');
740
755
  if (declared) {
@@ -758,7 +773,7 @@ function readStatus(scanned, byKey, nodeDirective, index, file) {
758
773
  phase: phaseOf(raw),
759
774
  };
760
775
  }
761
- const heading = scanned.headings.find((h) => isStatusHeading(h.text));
776
+ const heading = scanned.headings.find((h) => isStatusHeading(h.text) && regionAt(regions, h.start) === null);
762
777
  if (heading) {
763
778
  const body = statusSectionBody(scanned, heading.line);
764
779
  if (body) {
@@ -780,10 +795,11 @@ function statusSectionBody(scanned, headingLine) {
780
795
  continue;
781
796
  if (!isMarkdownLine(line))
782
797
  return null;
783
- const trimmed = line.content.trim();
784
- // A heading immediately after means the section is empty.
785
- if (trimmed.startsWith('#'))
798
+ // A heading immediately after means the section is empty - a setext one
799
+ // too, whose text line does not start with `#` and read as the status.
800
+ if (opensHeading(scanned, line.line))
786
801
  return null;
802
+ const trimmed = line.content.trim();
787
803
  const offset = line.contentStart + line.content.indexOf(trimmed);
788
804
  // Bullet lists under Status are a status history; take the first entry.
789
805
  const cleaned = trimmed.replace(/^[-*+]\s+/, '');
@@ -857,6 +873,9 @@ function isObligation(item, inObligationSection, directive) {
857
873
  function sectionPathAt(scanned, offset) {
858
874
  const path = [];
859
875
  for (const heading of scanned.headings) {
876
+ // A heading opening exactly at the offset would be a reference or an item
877
+ // starting on a `#` or on the first word of a setext heading - and a heading
878
+ // that begins with a reference is no section name either way.
860
879
  if (heading.start > offset)
861
880
  break;
862
881
  while (path.length > 0 && path[path.length - 1].level >= heading.level)
@@ -869,6 +888,9 @@ function sectionPathAt(scanned, offset) {
869
888
  * Memoised because it is called for every heading above every reference and
870
889
  * every item, and a corpus has only a handful of distinct headings - `Context`,
871
890
  * `Decision`, `Open Questions`, `See also` - repeated thousands of times.
891
+ *
892
+ * The cache changes how fast an answer arrives and never the answer, so no test
893
+ * can see whether it is consulted, filled or bounded.
872
894
  */
873
895
  const headingCache = new Map();
874
896
  function normaliseHeading(text) {
@@ -895,7 +917,10 @@ function normaliseHeading(text) {
895
917
  */
896
918
  function summarise(text) {
897
919
  const flat = text
898
- .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
920
+ // An image first, so that a badge wrapped in a link, `[![icon](i.png)](x)`,
921
+ // leaves `[icon](x)` for the link's pattern rather than a stray `![icon](x)`.
922
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
923
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
899
924
  // `[[target|display]]` keeps the display half: that is the text a reader
900
925
  // of the rendered document actually sees.
901
926
  .replace(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/g, '$1')
@@ -903,12 +928,20 @@ function summarise(text) {
903
928
  .trim();
904
929
  return flat.length <= 120 ? flat : `${flat.slice(0, 117)}...`;
905
930
  }
906
- /** True when an offset falls in a cell a column header already accounted for. */
931
+ /**
932
+ * True when an offset falls in a cell a column header already accounted for.
933
+ *
934
+ * Whether the end is in or out decides nothing: what sits there is the cell's
935
+ * closing pipe or the space before it, and no reference starts on either.
936
+ */
907
937
  function isClaimed(claimed, offset) {
908
938
  return claimed.some((range) => offset >= range.start && offset < range.end);
909
939
  }
910
940
  function extractReferences(context) {
911
941
  const { scanned, directives, document, documentAt, items, byKey, status, claimed, file, index } = context;
942
+ // This list and the one `withoutOverlaps` builds from it are only ever read
943
+ // through `belongsTo`, which passes nothing that was not written on a
944
+ // document or one of its items: a stray entry in either would reach no one.
912
945
  const out = [];
913
946
  const at = (start, end) => refOf(file, index, start, end);
914
947
  // Front matter, walked in the order it was written rather than in the order
@@ -935,7 +968,8 @@ function extractReferences(context) {
935
968
  }
936
969
  }
937
970
  // `status: Superseded by ADR-0009` is the most common supersession record of
938
- // all, and it never appears as a field of its own.
971
+ // all, and it never appears as a field of its own. A reading carries its text
972
+ // and its place together or neither, so either test alone would do.
939
973
  if (status.raw && status.at) {
940
974
  for (const target of supersessionTargetsIn(status.raw)) {
941
975
  const cleaned = cleanTarget(target);
@@ -1060,6 +1094,10 @@ function extractReferences(context) {
1060
1094
  function withoutOverlaps(candidates) {
1061
1095
  const out = [];
1062
1096
  for (const candidate of candidates) {
1097
+ // Today the only readings that can overlap are a status line and the prose
1098
+ // inside it, by one owner, and where they disagree they disagree on kind and
1099
+ // direction together. So the kind, owner and direction tests below cannot
1100
+ // be told apart one by one; each is here for a reader that differs in one.
1063
1101
  const index = out.findIndex((other) => other.kind === candidate.kind &&
1064
1102
  other.from === candidate.from &&
1065
1103
  other.inverted === candidate.inverted &&
@@ -1074,7 +1112,9 @@ function withoutOverlaps(candidates) {
1074
1112
  // always the one already held and this comparison always replaces it. It is
1075
1113
  // written as a comparison rather than an unconditional swap because which
1076
1114
  // reader runs first is not a property worth depending on, and a mutant that
1077
- // makes it unconditional is equivalent only by that accident.
1115
+ // makes it unconditional is equivalent only by that accident. Two of the
1116
+ // same width that match are one value written twice in one list, alike in
1117
+ // everything, so which of them is kept cannot matter.
1078
1118
  if (width(candidate.declaredAt) < width(held.declaredAt))
1079
1119
  out[index] = candidate;
1080
1120
  }
@@ -1086,18 +1126,28 @@ const width = (ref) => ref.span.end.offset - ref.span.start.offset;
1086
1126
  *
1087
1127
  * Offsets alone, with no file comparison: every candidate here was built from
1088
1128
  * one scan of one file, so there is no second file for them to be offsets into.
1129
+ * Spans that only touch never occur between two readings of one target, so
1130
+ * whether touching counts as overlapping decides nothing.
1089
1131
  */
1090
1132
  function overlapping(a, b) {
1091
1133
  return a.span.start.offset < b.span.end.offset && b.span.start.offset < a.span.end.offset;
1092
1134
  }
1093
- /** The item whose block contains an offset, if any. */
1135
+ /**
1136
+ * The item whose block contains an offset, if any.
1137
+ *
1138
+ * An item's span runs from its indentation or list marker to the line break
1139
+ * that ends it, and no reference or directive starts on either, so both bounds
1140
+ * could be closed or open alike. Items arrive in document order, where a nested
1141
+ * item follows the one it nests in and never shares its start: the last item
1142
+ * that contains the offset is the innermost, which the comparison below says
1143
+ * rather than relies on.
1144
+ */
1094
1145
  function ownerOf(items, offset) {
1095
1146
  let best = null;
1096
1147
  for (const item of items) {
1097
1148
  const span = item.at.span;
1098
1149
  if (offset < span.start.offset || offset >= span.end.offset)
1099
1150
  continue;
1100
- // Innermost wins when items nest.
1101
1151
  if (best === null || span.start.offset > best.at.span.start.offset)
1102
1152
  best = item;
1103
1153
  }
@@ -1127,6 +1177,8 @@ export function classifyReference(text, start, end, section) {
1127
1177
  // negation in there reverses the claim the phrase would otherwise make.
1128
1178
  if (NEGATION.test(before.slice(end)))
1129
1179
  continue;
1180
+ // Always found: the pattern was compiled from this table's own keys, and a
1181
+ // lower-cased search matches them only as written. The check is for types.
1130
1182
  const rule = VERB_LOOKUP.get(phrase);
1131
1183
  if (rule)
1132
1184
  best = rule;
@@ -1147,9 +1199,8 @@ export function classifyReference(text, start, end, section) {
1147
1199
  return { kind: rule.kind, inverted: true };
1148
1200
  }
1149
1201
  }
1150
- const innermost = section.length > 0 ? normaliseHeading(section[section.length - 1]) : '';
1151
- if (WEAK_SECTIONS.has(innermost))
1152
- return { kind: 'relates-to', inverted: false };
1202
+ // Any enclosing heading will do: a link under `### Notes` inside `## See also`
1203
+ // is bookkeeping still.
1153
1204
  for (const heading of section) {
1154
1205
  if (WEAK_SECTIONS.has(normaliseHeading(heading)))
1155
1206
  return { kind: 'relates-to', inverted: false };
@@ -1212,6 +1263,8 @@ function findBareReferences(scanned, links) {
1212
1263
  for (const link of links) {
1213
1264
  const start = Math.max(link.start, cursor);
1214
1265
  const end = Math.min(link.end, source.length);
1266
+ // Links from one scan come in order and never nest, so this skips nothing
1267
+ // today. It keeps a scanner that did nest them from copying text twice.
1215
1268
  if (end <= start)
1216
1269
  continue;
1217
1270
  text += source.slice(cursor, start);
@@ -1222,18 +1275,25 @@ function findBareReferences(scanned, links) {
1222
1275
  const out = [];
1223
1276
  BARE_REF.lastIndex = 0;
1224
1277
  for (let m = BARE_REF.exec(text); m !== null; m = BARE_REF.exec(text)) {
1225
- const prefix = m[1].toLowerCase();
1226
- if (NOT_A_FAMILY.has(prefix))
1278
+ const written = m[0];
1279
+ // The family as resolution will read it, which takes the shortest prefix
1280
+ // the number allows: `base64` is `base` and 64. Read off this pattern's
1281
+ // greedy prefix it was `base6` and 4, and `ES2015`, `ARM64` and `Win32`
1282
+ // walked past the list below as families of their own. Every match is a
1283
+ // prefixed identifier by construction, so the parse cannot fail.
1284
+ const family = parsePrefixedRef(written).family.toLowerCase();
1285
+ if (NOT_A_FAMILY.has(family))
1227
1286
  continue;
1228
- if (prefix.length < 2)
1287
+ if (family.length < 2)
1229
1288
  continue;
1230
- const start = m.index ?? 0;
1231
- // A token glued to a path separator or an extension is not a citation.
1289
+ const start = m.index;
1290
+ // A token glued to a path separator or an extension is not a citation. At
1291
+ // either end of the text the stand-in need only be neither `/` nor `.`.
1232
1292
  const before = text[start - 1] ?? ' ';
1233
- const after = text[start + m[0].length] ?? ' ';
1293
+ const after = text[start + written.length] ?? ' ';
1234
1294
  if (before === '/' || before === '.' || after === '/')
1235
1295
  continue;
1236
- out.push({ text: m[0], start, end: start + m[0].length });
1296
+ out.push({ text: written, start, end: start + written.length });
1237
1297
  }
1238
1298
  return out;
1239
1299
  }
@@ -1242,10 +1302,8 @@ function findBareReferences(scanned, links) {
1242
1302
  /* -------------------------------------------------------------------------- */
1243
1303
  function firstValue(byKey, keys) {
1244
1304
  for (const key of keys) {
1245
- const entry = byKey.get(key);
1246
- if (!entry)
1247
- continue;
1248
- const value = asString(entry);
1305
+ // A key that is missing reads as null, the same as a blank one.
1306
+ const value = asString(byKey.get(key));
1249
1307
  if (value !== null && value.length > 0)
1250
1308
  return value;
1251
1309
  }
@@ -1271,12 +1329,17 @@ function stripAnchor(target) {
1271
1329
  const hash = target.indexOf('#');
1272
1330
  return hash <= 0 ? target : target.slice(0, hash);
1273
1331
  }
1332
+ /**
1333
+ * A link's text for an edge's `raw`.
1334
+ *
1335
+ * An autolink never reaches here: spec-core reads one only when it carries a
1336
+ * scheme, and a link with a scheme is external and was dropped before this. A
1337
+ * reference or shortcut link always has a label, so the `''` is for the type.
1338
+ */
1274
1339
  function renderLink(link) {
1275
1340
  switch (link.form) {
1276
1341
  case 'wiki':
1277
1342
  return `[[${link.target}]]`;
1278
- case 'autolink':
1279
- return `<${link.target}>`;
1280
1343
  case 'reference':
1281
1344
  case 'shortcut':
1282
1345
  return `[${link.text}][${link.label ?? ''}]`;