@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.
- package/CHANGELOG.md +146 -0
- package/README.md +162 -15
- package/dist/baseline.js +6 -0
- package/dist/baseline.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +50 -17
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.js +16 -8
- package/dist/config.js.map +1 -1
- package/dist/diff.js +14 -0
- package/dist/diff.js.map +1 -1
- package/dist/directives.js +9 -6
- package/dist/directives.js.map +1 -1
- package/dist/extract.js +120 -57
- package/dist/extract.js.map +1 -1
- package/dist/glob.js +28 -16
- package/dist/glob.js.map +1 -1
- package/dist/graph.d.ts +2 -1
- package/dist/graph.js +10 -1
- package/dist/graph.js.map +1 -1
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +68 -68
- package/dist/identity.js.map +1 -1
- package/dist/lifecycle.js +17 -11
- package/dist/lifecycle.js.map +1 -1
- package/dist/report.js +34 -5
- package/dist/report.js.map +1 -1
- package/dist/resolve.js +40 -14
- package/dist/resolve.js.map +1 -1
- package/dist/rules.d.ts +8 -0
- package/dist/rules.js +83 -16
- package/dist/rules.js.map +1 -1
- package/dist/runner.js +22 -4
- package/dist/runner.js.map +1 -1
- package/dist/sections.d.ts +9 -0
- package/dist/sections.js +60 -9
- package/dist/sections.js.map +1 -1
- package/dist/select.d.ts +18 -2
- package/dist/select.js +46 -16
- package/dist/select.js.map +1 -1
- package/dist/source.js +2 -0
- package/dist/source.js.map +1 -1
- package/dist/state.js +46 -12
- package/dist/state.js.map +1 -1
- package/dist/yaml.js +11 -3
- package/dist/yaml.js.map +1 -1
- package/package.json +6 -3
- 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
|
|
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(
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
//
|
|
785
|
-
if (
|
|
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
|
-
|
|
920
|
+
// An image first, so that a badge wrapped in a link, `[](x)`,
|
|
921
|
+
// leaves `[icon](x)` for the link's pattern rather than a stray ``.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
1151
|
-
|
|
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
|
|
1226
|
-
|
|
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 (
|
|
1287
|
+
if (family.length < 2)
|
|
1229
1288
|
continue;
|
|
1230
|
-
const start = m.index
|
|
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 +
|
|
1293
|
+
const after = text[start + written.length] ?? ' ';
|
|
1234
1294
|
if (before === '/' || before === '.' || after === '/')
|
|
1235
1295
|
continue;
|
|
1236
|
-
out.push({ text:
|
|
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
|
-
|
|
1246
|
-
|
|
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 ?? ''}]`;
|