1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110import { JSDOM, VirtualConsole } from "jsdom";
// Make the ids of independently extracted pages unique across the assembled
// document, while leaving every reference that already worked
// alone.
//
// Why this is needed at all: extraction is per page and concurrent. `extractPage`
// sees one image and nothing of what any other page emitted, and assembly is a
// plain concatenation β so an id is a claim about the whole document that no page
// is in a position to make. The page prompt asks for ids by name for footnotes
// (`id="fn-N"`, `href="#fn-N"`, "preserve the original numbering"), so a scan whose
// pages each carry a footnote 1 emits several `id="fn-1"` in one file. Then every
// `href="#fn-1"` resolves to the first: a screen-reader user following the
// reference on page 3 lands on page 1's note and the back-reference returns them to
// the wrong paragraph. Both notes exist, both are announced, the link works. The
// lint gate does not see it either β WCAG 2.2 dropped 4.1.1, so axe tags
// `duplicate-id` obsolete and lint.ts's tag filter excludes it (`duplicate-id-aria`
// is current but fires only for ids referenced from ARIA attributes, not an `href`),
// which is why lint.ts re-enables it by name.
//
// **Only ids that more than one page claims are renamed.** A first version prefixed
// every id with its page number, which fixed collisions and broke everything that
// legitimately pointed across a page break: a `<label for="q1">` whose `<input
// id="q1">` fell on the next page, or endnotes with continuous numbering where the
// markers are in the body and the notes are collected at the back. Those references
// resolved correctly before assembly touched them, and renaming one end of the pair
// made them dangle β which for `for`/`headers`/`aria-*` is a real 1.3.1/4.1.2
// failure (that is how a field gets its accessible name and how a data cell is
// attributed to its headers) on content that was correct when the page produced it.
// Trading a wrong-target reference for a no-target one is not a fix.
//
// Renaming only the collisions makes the common document a no-op, and it is also
// the honest scope: a unique id needs nothing done to it, and a colliding one has no
// correct cross-page interpretation to preserve.
//
// A reference to a colliding id is then repointed rather than abandoned: to the
// page's own copy if it has one, otherwise to the first page in document order that
// claims the id β which is what a browser resolved the bare reference to before any
// of this ran. See pass 2 below for why leaving it dangling was the worse of the two.
const IDREF_ATTRS = [
"for", // <label for> β the field's accessible name
"form",
"list", // <input list> β <datalist>
"headers", // <td headers> β which <th> describes this cell
"aria-labelledby",
"aria-describedby",
"aria-details",
"aria-errormessage",
"aria-controls",
"aria-owns",
"aria-flowto",
"aria-activedescendant",
];
export interface AnchorReport {
// Bare ids that more than one page claimed. Namespaced, EXCEPT for whatever appears in
// `pinned_ids` β see there, and do not read this field as "every one of these was
// rewritten".
collisions: string[];
// Colliding ids whose first owner was deliberately left bare, because a page that could
// not be rewritten holds a reference to it and a frozen reference can only find a bare
// id. Reported for the same reason `skipped_pages` is: without it, a bare colliding id in
// the delivered document is indistinguishable in the run log from the namespacing having
// silently failed. The other owners of a pinned id are still renamed, so the id appears
// here AND in `collisions`.
pinned_ids: string[];
// References that name a colliding id from a page that does not own it, so no page
// can say which copy was meant. They are repointed at the first owner in document
// order β what the un-namespaced document resolved them to β and reported here
// because a reference disambiguated by document order rather than by the agent that
// wrote it is a document worth a human's attention. Named `ambiguous` and not
// `unresolved`: they do resolve, just not on the page's own authority.
ambiguous: { page: number; ref: string }[];
// The subset of `ambiguous` that was deliberately NOT repointed: a link whose target is
// already spoken for, so document order would have aimed it at a note that has its own
// marker (#233). Left bare, which means it lands nowhere once the owners are renamed β
// and that is the point. A subset, so every entry here is in `ambiguous` too; read the
// difference between the two as "disambiguated by document order" versus "given up on".
unrepointed: { page: number; ref: string }[];
// Pages left exactly as written, either because rewriting them would have lost markup
// (see `wouldChangeMarkup`) or because they could not be parsed at all (see
// `parseFragment`), and which are relevant to the join: they own a colliding id or refer
// to one. Either the page's own colliding ids keep their bare form β in
// which case lint's `duplicate-id` / `duplicate-id-active` names the collision β or
// an ambiguous reference on it keeps pointing at a bare id that other pages
// renamed away, which is why the page is named here as well as in `ambiguous`.
skipped_pages: number[];
}
const EMPTY_REPORT: AnchorReport = {
collisions: [],
pinned_ids: [],
ambiguous: [],
unrepointed: [],
skipped_pages: [],
};
// The characters that can sit immediately before an attribute name in source HTML, as a
// regex character class. Both passes below use a cheap regex over the unparsed source to
// decide whether a page is worth parsing at all, and each needs to be certain it cannot
// return false when a real attribute is present.
//
// `\s` and `/` are the obvious ones. `"` and `'` are here because a quoted attribute
// value can be followed IMMEDIATELY by the next attribute name: `<p class="note"id="x">`
// is a parse error the spec recovers from by reconsuming in before-attribute-name state,
// so jsdom reads a real `id` there. That one cost a round β the comment this replaces
// asserted `[\s/]` was the complete set, having reasoned about it rather than measured.
//
// So it is measured. Enumerating every ASCII character in each position where a preceding
// attribute can end, and recording the character sitting directly before every attribute
// jsdom actually parsed, yields exactly eight: `\t \n \f \r space " ' /`. All eight are in
// this class, and the enumeration lives in test/assembly-anchors.test.ts so a jsdom upgrade
// that widens the set fails a test rather than going unnoticed.
//
// One shared constant rather than the same class written twice: the two sniffs have now
// drifted apart twice, and both times the pass that was left behind was the bug.
//
// Exported only for that test. It is the class ITSELF that has to be measured, not a copy
// of it written out again in the test file β a copy would keep passing while this drifted.
export const ATTR_SEP = `[\\s/"']`;
// Start tags and text, in document order. Tags are `<name`, text is `#normalized`, so no
// text run can ever be mistaken for a tag (a run that decodes to `<div` becomes `#<div`).
//
// TEXT is in here, not just tags, because foster parenting moves text on its own. A tag
// sequence alone declared this page safe and reordered it:
//
// <table><caption id="c1">Cap</caption>Continued from page 1<tr><td>x</td></tr></table>
//
// reserializes with `Continued from page 1` after the entire table instead of before the
// rows. Every tag is present and in order, so the tag-only sequence saw nothing β while
// text a page agent plausibly emits for a table continued across a break, on exactly the
// shape that produces these collisions, silently moved out of reading order.
// Quoted attribute values are consumed as units so a `>` inside one does not end the tag:
// `<p title="a > b">` is one tag, and treating it as a tag plus the text `b">` made the
// guard skip an ordinary page and leave a real collision unfixed. Unbalanced quotes fail to
// match at all and fall through to text, which is the over-skip direction β safe.
const SOURCE_TOKEN = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][^\s/>]*)(?:"[^"]*"|'[^']*'|[^>"'])*>/g;
// Whitespace-collapsed, and entity-decoded when there is an entity to decode, because the
// parsed side is compared against text nodes: `a & b` in the source is `a & b` there.
// The decode is a parse, so it is skipped unless the run contains `&` β which also keeps
// the parser away from runs holding a bare `<` that is only prose.
function normalizeText(raw: string, document: Document): string {
let text = raw;
if (text.includes("&")) {
const decoder = document.createElement("div");
decoder.innerHTML = text; // A text run by construction: no tag to be moved by this parse.
text = decoder.textContent ?? "";
}
return text.replace(/\s+/g, " ").trim();
}
// The sequence the source ASKS for. Computed from the source text and not from the parsed
// document, because the whole question is what parsing changed.
//
// Over-inclusive by design, in both the tag and the text direction: an attribute value
// containing `>` splits into a tag plus a text run that the parsed document will not have,
// and a comment or doctype the pattern does not match becomes text. Each of those is an
// extra token that fails to match, so the page is left as written β the safe direction. The
// unsafe direction is a token this MISSES, which is what let both previous versions through.
function sourceSequence(source: string, document: Document): string[] {
const out: string[] = [];
let last = 0;
const pushText = (raw: string) => {
const text = normalizeText(raw, document);
if (text) out.push(`#${text}`);
};
for (const m of source.matchAll(SOURCE_TOKEN)) {
pushText(source.slice(last, m.index));
last = m.index + m[0].length;
// Start tags only. A close tag is not a position in the reading order β the parser
// supplies missing ones and moves them freely, and `<b>1<p>2</b>3</p>` legitimately
// ends up with more of them than the source wrote.
if (!m[0].startsWith("<!--") && m[1] === "") out.push(`<${m[2].toLowerCase()}`);
}
pushText(source.slice(last));
return out;
}
// The same sequence, read off the parsed document. Comments are skipped on both sides.
function parsedSequence(document: Document): string[] {
const out: string[] = [];
const visit = (node: Node) => {
for (const child of node.childNodes) {
if (child.nodeType === child.ELEMENT_NODE) {
out.push(`<${(child as Element).tagName.toLowerCase()}`);
visit(child);
} else if (child.nodeType === child.TEXT_NODE) {
const text = (child.textContent ?? "").replace(/\s+/g, " ").trim();
if (text) out.push(`#${text}`);
}
}
};
visit(document.body);
return out;
}
// Would reserializing this page change its markup as anything more than a rewrite of the
// ids? True means leave the page exactly as its agent wrote it.
//
// The source's sequence of tags AND text must appear in order in the parsed document β a
// subsequence, not an equality and not a multiset. That form is what it is because of what
// each relaxation has to allow and what it must still catch:
//
// * Extra tokens in the parsed output are fine, so a subsequence rather than equality. A
// well-formed `<table><tr>` gains the `<tbody>` the source omitted, and the adoption
// agency algorithm DUPLICATES a tag to repair misnesting β `<b>1<p>2</b>3</p>` becomes
// `<b>1</b><p><b>2</b>3</p>`, two `<b>` from one. An equality check abandons the
// rewrite on both, leaving exactly the collisions this exists to fix.
// * Order matters, which is why this is a sequence and not the COUNTS it used to be.
// Counting cannot see a MOVE, and foster parenting moves things: a `<p>` inside a
// `<table>` is hoisted out to before the table, so
// `<table>β¦<p id="fn-1">note</p></table><p>tail</p>` reserializes with the note ahead
// of the table it followed. Every count is identical, so that guard called the page
// safe and rewrote it β a reading-order change, on the one property this codebase
// exists to protect, and on precisely the shape (content inside a table) that a table
// spanning a page break produces, which is also what produces these collisions.
// * Text is a token, not just tags, which is the same failure one level down. Bare prose
// inside a table foster-parents out with every tag still in place and in order, so a
// tag-only sequence declared the page safe and moved the text β see SOURCE_TOKEN. The
// `<p>`-wrapped version of that content was caught only because `<p>` is a tag.
//
// Skipping the page costs a duplicate id that lint reports. Rewriting it costs silently
// reordered content. The header's rule applies: that is not a trade to make.
function wouldChangeMarkup(source: string, document: Document): boolean {
const wanted = sourceSequence(source, document);
const got = parsedSequence(document);
let next = 0;
for (const token of got) {
if (next < wanted.length && wanted[next] === token) next++;
}
return next < wanted.length;
}
function ownedIds(document: Document): Set<string> {
const ids = new Set<string>();
for (const el of document.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
if (id) ids.add(id);
}
return ids;
}
// How deeply a page's PARSED tree may nest before this module refuses to rewrite it.
//
// Rewriting a page recurses per level of nesting in three places β jsdom's serializer
// (`body.innerHTML`), its `window.close()`, and this file's own `parsedSequence` β so a deep
// enough page overflows the stack in one of them. Which one goes first is not worth relying
// on: measured, serialization and `close()` throw from about 4,000 levels while the parse
// itself survives past 10,000, so the band between them PARSED and then threw out of the
// rewrite, while a DEEPER page whose parse failed cleanly was delivered fine. Behaviour that
// was non-monotonic in depth, with the worse outcome on the shallower input. And each
// threshold moves with how much stack the caller has already used, so none of them is a
// number to build on.
//
// Hence one limit, far below all of them. Past it the page is delivered exactly as written,
// which is the same outcome as tripping the reserialization guard and is handled by the same
// code. Real documents do not nest 500 elements deep; the limit is for pathological input,
// and refusing a page that did not need refusing costs a duplicate id that lint reports β
// the trade this file's header makes in that direction.
//
// With one qualification worth stating where the number is chosen, because it is the one
// place the trade is weaker than it sounds: axe overflows on a deep document too, from a few
// thousand levels, and `runAxe` returns no verdict with an `error` set rather than failing the
// session. So past that depth the duplicate id ships with no lint finding naming it. That is
// not a reason to fail the run over nesting β a delivered document with a duplicate id beats
// no document β but it does mean the fallback reporter is silent exactly where this guard is
// most likely to fire, which is why `runAssembly` logs `lint_error` alongside `lint_ok`.
const MAX_NESTING = 500;
// The exact depth of the PARSED tree, measured with an explicit stack rather than recursion
// (the recursion is what is being guarded against) and stopping as soon as the limit is
// passed.
//
// The parsed tree and not the source, because the source cannot be counted. The first
// version of this guard counted start tags that had not been closed, which is not a depth:
// void elements and implied end tags never came back down, so a 120-row table written
// `<tr><td>a<td>b` β real depth 4 β and a page with 600 `<br>` β real depth 1 β were both
// refused. Both are ordinary page-agent output, and refusing them shipped the duplicate id
// this module exists to remove. A table continued across a page break is the very scenario
// that produces these collisions, so that regression was not a corner case. Estimating
// depth from source means modelling the parser's implied-end-tag and void-element rules,
// which is exactly what `sourceAttrs` had to stop doing; measuring the tree needs no model.
function exceedsNesting(document: Document): boolean {
// Depth of `body`'s children is 1, matching "how deeply nested is this page".
const stack: { node: Element; depth: number }[] = [...document.body.children].map((node) => ({ node, depth: 1 }));
while (stack.length > 0) {
const { node, depth } = stack.pop()!;
if (depth > MAX_NESTING) return true;
for (const child of node.children) stack.push({ node: child, depth: depth + 1 });
}
return false;
}
// What parsing a page yielded. Three outcomes, and the middle one is the reason this is a
// result type rather than `JSDOM | null`:
//
// * `dom` set and `rewritable` true β the ordinary page.
// * `dom` set and `rewritable` FALSE β too deep to rewrite, but its tree can still be
// READ. Reading it is exact, where scanning its source is a guess (see `readable`).
// * `dom` null β the parse itself threw, so there is no tree at all and the source scan is
// the only reading available.
type ParsedPage = { dom: JSDOM | null; rewritable: boolean };
// Parse a page and decide what may be done with it.
//
// Parsing FIRST and measuring the tree is deliberate. The parse is not what overflows first
// β it survives to roughly twice the depth the serializer does β so it is the one step that
// can be run in order to find out how deep the page really is. Malformed markup reaches
// neither failure exit: the HTML parser has a recovery rule for everything and raises
// nothing.
//
// A too-deep page KEEPS its DOM rather than discarding it, which is the point. The recursive
// steps are serialization, `window.close()` and this file's `parsedSequence`; `querySelectorAll`
// is iterative and works at any depth, so ids and references can be read off the tree exactly.
// The earlier version threw the DOM away and fell back to `sourceIds`, which reads the source
// without the parser's tree-construction rules and so invented owners for markup the parser
// DROPS β an orphan `<tr><td id="c">`, a stray `<caption>`, a `<col>`, anything after
// `<plaintext>`. Each phantom manufactured a collision no delivered element had, renamed the
// real owner, suppressed the pin and left a `<label for>` naming nothing: the 1.3.1/4.1.2
// failure the pin exists to prevent, on an id that never collided. Modelling more of the
// parser was the wrong direction β reading the tree it already built needs no model.
function parseFragment(innerHtml: string): ParsedPage {
let dom: JSDOM;
try {
dom = new JSDOM(`<body>${innerHtml}</body>`, { virtualConsole: new VirtualConsole() });
} catch {
return { dom: null, rewritable: false };
}
return { dom, rewritable: !exceedsNesting(dom.window.document) };
}
// The ids a page's SOURCE claims, read without parsing. Used only when `parseFragment`
// refused the page (nested past `MAX_NESTING`, or the parse threw), where the alternative is
// recording that the page owns nothing β and that is not a
// neutral default but a false statement with teeth. Such a page is delivered
// byte-for-byte, so every id in its source is in the output; a page missing from `claims`
// is invisible as an owner to both readers of `skipped`, so the collision goes undetected
// AND the pin below sees no skipped owner and fires, putting a second bare copy of the id
// in the document. The duplicate that whole condition exists to prevent, reached by the
// one route that reports nothing.
//
// This one must NOT be over-inclusive, which is the opposite of the rule everywhere else in
// this file, because a phantom owner is worse than a missed one. Both readers of `skipped`
// rest on "a skipped owner is ALREADY keeping the bare id", and a phantom owner never had
// one: an `id=x` read out of prose on an unparseable page made that page a `claims` owner,
// so the pin declined to fire and every REAL owner was renamed β leaving a `<label for="x">`
// on another page pointing at nothing, the unnamed-field failure (1.3.1/4.1.2) the pin
// exists to prevent, on an id that was never a collision in the first place. Missing a real
// id costs a duplicate id that lint's `duplicate-id` reports, which is the trade this file's
// header makes in that direction and only that direction.
//
// So ids are read from real attribute positions only β see `sourceAttrs`.
//
// Exported for the test that measures it against jsdom, for the same reason `ATTR_SEP` is:
// reaching it through `namespaceAnchors` needs a fragment too deep for jsdom to parse, which
// costs ~10s per shape, so an enumeration would dominate the suite. The end-to-end route has
// its own tests; this export is what lets the scan's agreement with the parser be checked
// shape by shape.
export function sourceIds(innerHtml: string): Set<string> {
return new Set(sourceAttrs(innerHtml).get("id") ?? []);
}
// The references a page's SOURCE makes, read without parsing β the mirror of `sourceIds`,
// needed for the same reason. An unparseable page is delivered as written, so its
// `for="q1"` is frozen in bare form exactly like a page the reserialization guard skipped.
// Without this the pin never learns the reference exists, every owner of `q1` is renamed,
// and the reference names nothing β an unnamed field, 1.3.1/4.1.2, which is the trade this
// file's header refuses and the defect the pin was added to fix. It was fixed for the guard
// route and left open for this one.
//
// Here over-inclusiveness IS the safe direction, unlike `sourceIds` above: a reference that
// is not really there pins a first owner that did not need pinning, which leaves one
// colliding id bare and renames the rest β no duplicate, no dangling reference. Missing one
// is the dangling reference. The two functions read the same source through the same scan
// and want opposite error directions, which is why that asymmetry is stated at both ends
// rather than left to a shared comment.
//
// `headers` and the `aria-*` list attributes are space-separated, so each token counts.
//
// Exported for the test that measures it against jsdom, for the same reason `sourceIds` is.
export function sourceRefs(innerHtml: string): Set<string> {
const refs = new Set<string>();
const attrs = sourceAttrs(innerHtml);
for (const value of attrs.get("href") ?? []) {
if (value.startsWith("#") && value.length > 1) refs.add(value.slice(1));
}
for (const attr of IDREF_ATTRS) {
for (const value of attrs.get(attr) ?? []) {
for (const token of value.split(/\s+/)) if (token) refs.add(token);
}
}
return refs;
}
// Elements whose CONTENT the parser does not build a tree from, so a tag-shaped string
// inside one is text and its attributes are not attributes. Measured, not listed from the
// spec: for every element name a `<b id="phantom">` was placed inside it and the parsed
// document was asked whether `phantom` came back. These are the ones where it did not.
//
// `template` is in here for a different reason than the rest β its content IS parsed, into
// a separate document fragment `querySelectorAll` on the body never sees β but the
// consequence for this scan is identical, so the distinction does not earn a branch.
//
// `select` is here too, and it is the odd one: the parser drops most tags inside it rather
// than treating them as text, so `<select><b id="x"></select>` yields no `x` β but
// `<option id="x">`, `<optgroup>` and `<hr>` ARE kept. Skipping the whole element therefore
// MISSES real ids rather than inventing phantoms, which is `sourceIds`' safe direction and
// `sourceRefs`' unsafe one; both are the same trade the unmatched-tag case already makes
// below, and a scan that modelled select's content rules would be modelling the parser.
//
// `plaintext` and `noscript` are deliberately absent, for opposite reasons.
//
// `plaintext` never ends: everything after it is text, so the parser keeps no id that
// follows one. Omitting it here therefore invents a phantom for every such id β the unsafe
// direction for `sourceIds`, and a real gap while this scan was the only reading of a
// too-deep page. Adding it would be the safe (miss) direction, since the skip would run to a
// `</plaintext>` that cannot exist and so to the end of the page. It stays out because that
// choice no longer decides anything: `parseFragment` keeps the too-deep DOM and ids are read
// from the tree, so this scan now runs only for a page with no tree at all, where the id
// count is a guess either way. Left as-is rather than "fixed" in a function whose remaining
// caller cannot tell the difference.
//
// `noscript` is absent because its content IS parsed when scripting is disabled, which is how
// jsdom parses these fragments β skipping it would miss ids the parser really kept.
const RAW_CONTENT = new Set([
"script",
"style",
"textarea",
"title",
"template",
"xmp",
"noembed",
"noframes",
"iframe",
"select",
]);
// Attribute name (lowercased) -> every value the source gives it, read without parsing.
//
// Tag-aware, not a bare regex over the whole string, because `sourceIds` needs a name that
// is really in attribute position: a plain `id\s*=` scan matched `id=x` in prose and
// `title='id="x"'`, and each phantom cost a dangling reference (see `sourceIds`). Tags are
// found with the same SOURCE_TOKEN the reserialization guard uses β so comments are skipped
// and a quoted value containing `>` does not end the tag β and each tag's attributes are
// then walked in order, so a value is consumed by the name it belongs to and can never be
// re-read as a name itself.
//
// Being in a tag is not sufficient, though: a tag inside a `<textarea>` or `<script>` is
// TEXT, and the tag-aware version still walked it. `<textarea><p id="x"></textarea>` on an
// unparseable page made that page a phantom owner of `x`, suppressing the pin and renaming
// the real owner β the manufactured dangling `for=` that `sourceIds` describes, reached
// through markup a page agent transcribing a form field plausibly emits. So RAW_CONTENT
// elements are skipped to their close tag.
//
// Values are DECODED, because the parser decodes them: `for="q1"` is a reference to
// `q1`, and reading it literally left the frozen reference unseen, every owner of `q1`
// renamed, and nothing in the report β `sourceRefs`' unsafe direction. `id="fn-1"` is
// the mirror on the id side, where an undecoded value is a phantom.
//
// First value wins per name, because that is the parser's rule for a repeated attribute:
// `<p id="a" id="b">` is `a`, and collecting both made `b` a phantom.
//
// A tag SOURCE_TOKEN cannot match (an unbalanced quote, say) contributes nothing. For
// references that is the unsafe direction and is why `sourceRefs` is only ever consulted for
// pages already being delivered as written, where a missed reference leaves the same
// wrong-target it had before assembly rather than a new dangling one; for ids it is the safe
// direction, per `sourceIds`.
function sourceAttrs(innerHtml: string): Map<string, string[]> {
const out = new Map<string, string[]>();
// A scratch document, only to decode attribute values. One per call, not one per value.
const scratch = new JSDOM("<body></body>", { virtualConsole: new VirtualConsole() });
try {
const holder = scratch.window.document.createElement("div");
// Re-parsed in attribute position, which is where the value came from and where the
// decoding rules differ from text: `a&b` stays literal in an attribute and becomes
// `a&b` in text. A raw `"` can only have arrived from a single-quoted or unquoted value,
// so re-encoding it round-trips. Measured against the parser over every quoting style.
const decode = (raw: string) => {
if (!raw.includes("&")) return raw;
holder.innerHTML = `<i x="${raw.replace(/"/g, """)}">`;
return holder.firstElementChild?.getAttribute("x") ?? raw;
};
let skipTo = 0;
for (const tag of innerHtml.matchAll(SOURCE_TOKEN)) {
if (tag.index < skipTo) continue;
if (tag[0].startsWith("<!--")) continue;
const name = tag[2].toLowerCase();
// Past the tag name: `<` + optional `/` + the name.
const interior = tag[0].slice(1 + tag[1].length + tag[2].length, -1);
// The element's own attributes are real either way β it is its CONTENT that is not
// markup β so they are read before skipping. A close tag has none to read.
if (tag[1] === "") {
// Per TAG, so a repeated name keeps this tag's first value β the parser's rule.
// Accumulated across tags, because every tag's `id` is a separate id.
const seen = new Set<string>();
for (const attr of interior.matchAll(TAG_ATTR)) {
const attrName = attr[1].toLowerCase();
const value = attr[2] ?? attr[3] ?? attr[4];
if (value === undefined || value === "") continue;
if (seen.has(attrName)) continue;
seen.add(attrName);
out.set(attrName, [...(out.get(attrName) ?? []), decode(value)]);
}
}
if (tag[1] === "" && RAW_CONTENT.has(name)) {
// To the matching close tag, which the parser accepts with trailing junk
// (`</textarea foo>`) and in any case. No close tag means the element runs to the
// end of the page β the parser's rule, and the miss rather than phantom direction.
// Not nesting-aware, which matches the parser: raw text ends at its first close tag,
// and a nested `<template>` inside one already-skipped `<template>` only widens the
// skip, never narrows it.
const close = new RegExp(`</${name}(?:${ATTR_SEP}[^>]*)?>`, "i");
const rest = innerHtml.slice(tag.index + tag[0].length);
const found = rest.search(close);
skipTo = found === -1 ? innerHtml.length : tag.index + tag[0].length + found;
}
}
} finally {
scratch.window.close();
}
return out;
}
// One attribute inside a tag: a name, then optionally `=` and a quoted or bare value.
// Matched repeatedly over a tag's interior so each value is consumed by its own name.
const TAG_ATTR = /([^\s/>="']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g;
// Every reference a page makes, so the report can name the ones left dangling.
function referencesIn(document: Document): string[] {
const refs: string[] = [];
for (const el of document.querySelectorAll("[href^='#']")) {
const target = el.getAttribute("href")!.slice(1);
if (target) refs.push(target);
}
for (const attr of IDREF_ATTRS) {
for (const el of document.querySelectorAll(`[${attr}]`)) {
for (const token of el.getAttribute(attr)!.split(/\s+/)) if (token) refs.push(token);
}
}
return refs;
}
// How many elements ONE page fragment gives an id, and which of those ids it used more than once β
// the collision `namespaceAnchors` cannot fix, answered at the page step instead of at assembly
// (#373 directive 3).
//
// Why it is here and not left to lint: prefixing cannot separate two copies on one page β either
// the id is renamed on both or on neither, and the second is the ordinary outcome, since
// `namespaceAnchors` renames only ids more than one PAGE claims. `namespaceAnchors` says so where
// it declines the case, and until now the sentence ended "it needs a human or the review loop".
// Which branch a document took also decides what lint's finding is CALLED β `p3-fn-1` where the
// rename ran, `fn-1` where nothing collided across pages β so the name the page agent would have
// to act on is not even predictable from here. The page step
// is the one place that can still ask the model that wrote the ids to renumber them, and
// `agents/page.md` is where the rule lives (*"never hand one an id that a numbered footnote on
// this page already uses⦠is a duplicate id that ships"*), so this is the prompt's own rule
// checked rather than only asked for β the argument #290's placeholder-alt rule makes.
//
// Read off the PARSED tree only, and `[]` where the parse threw. That is the opposite of
// `sourceIds`' rule and for the same underlying reason: there, a missed id costs a duplicate lint
// reports, so over-collecting is the danger; here, an over-collected id is a phantom DUPLICATE,
// which buys a correction round against a defect the document does not have. That is precisely
// the failure #373 is about, so the direction of the trade flips with the consequence β a
// duplicate this cannot see ships exactly as it shipped before, and lint still names it on the
// assembled document.
//
// The regex screen is the same one `namespaceAnchors` uses, and it is what keeps this off the
// ordinary page's bill: a fragment with no id in attribute position is not parsed at all. It runs
// on every page rather than a sample, like the link and alt checks beside it in extraction.ts,
// because it is exact and free.
//
// Measured before it was written, over every page reply on disk in the benchmark's round logs
// (1,501 fragments, 1,421 of them carrying an id, no model call): **2** fragments duplicate an id
// within themselves, both of them a footnote β `fnref-1` twice on `gpt-5.6-luna`, and
// `numbering-note-1`/`-2` on `nova-2-lite`. On the deployed page model (`kimi-k2.5`) it is 0 of
// 328, and on `sonnet` 0 of 469. So this buys a correction on roughly one page in 750 and none at
// all on the model Iris ships today β the reason to have it is #344, which proposes swapping the
// page agent to the one arm that produced a hit.
// `ids` comes back with the duplicates because a count of duplicates is not readable without its
// denominator: 0 duplicates on a page that carries no ids at all says nothing about this rule, and
// 0 on a page carrying 40 says something (`alts_checked` in extraction.ts is the same pair for the
// same reason). Both off ONE parse β asking two functions would parse the fragment twice.
//
// A page whose parse threw reports 0 and 0. That is one page's worth of denominator lost rather
// than a wrong answer, it is the page no reader in this file can see at all, and per
// `parseFragment` it is close to unreachable: malformed markup does not get there, since the HTML
// parser has a recovery rule for everything.
export function idAudit(innerHtml: string): { ids: number; duplicated: string[] } {
if (!new RegExp(`${ATTR_SEP}id\\s*=`, "i").test(innerHtml)) return { ids: 0, duplicated: [] };
const { dom } = parseFragment(innerHtml);
if (!dom) return { ids: 0, duplicated: [] };
try {
const counts = new Map<string, number>();
for (const el of dom.window.document.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
// A blank id is skipped rather than counted, and it is not being called harmless: `id=""` and
// `id=" "` are invalid markup, and two of them ARE two elements a reference cannot reach.
// What does not fit is this rule's remedy β "renumber every copy after the first" is not the
// repair for an id with no name, and `duplicateIdProblem` would quote an empty string back at
// the model as the thing to renumber. Never observed in 1,501 page replies, and the trade this
// whole function makes is that a duplicate it cannot name costs nothing new while a
// correction bought on a sentence the model cannot act on costs a page call.
if (id?.trim()) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return {
// Elements carrying a usable id, not distinct ids: it is the number of things that could
// have collided, which is what makes a zero a measurement.
ids: [...counts.values()].reduce((n, c) => n + c, 0),
duplicated: [...counts]
.filter(([, n]) => n > 1)
.map(([id]) => id)
.sort(),
};
} finally {
// Cleanup is allowed to fail, for the reason `namespaceAnchors` gives at its own `close()`
// below: `close()` walks the tree recursively and overflows from about 4,000 levels, and this
// function reaches pages that deep on purpose β it ignores `rewritable`, because reading ids
// off a too-deep tree is exact where scanning its source is a guess. A throw from a `finally`
// would replace the counts with a `RangeError`, and the two call sites turn that into
// different damage: `duplicateIds` at the page step lands in the per-page catch and marks
// @page-failed a page Iris already holds (the shape #171 fixed), while `idCounts` is
// evaluated inline in the roll-up event, outside any catch, and would fail the whole run
// after every page had been billed. Neither is worth a count of ids.
try {
dom.window.close();
} catch {
// Deliberately empty: see above.
}
}
}
// The duplicates alone, for the three page-step callers that have no use for the denominator.
export function duplicateIds(innerHtml: string): string[] {
return idAudit(innerHtml).duplicated;
}
// What the correction pass is asked to do about one, in the shape `missingLinkProblem` and
// `genericAltProblem` use: the exact defect, the exact repair, and nothing else changed.
//
// The repair names the references as well as the ids, because renaming an id on its own is how
// this defect gets worse: `href="#fn-1"` currently reaches the first copy, and a rename that
// leaves the link behind turns a wrong target into a dangling one. Both of the duplicates
// measured on disk are footnote ids, which is the shape where that matters most β the marker and
// its back-reference point at each other.
export function duplicateIdProblem(id: string): string {
return (
`More than one element on this page has id="${id}". Ids must be unique within a page, so a ` +
`reference to it β href="#${id}", aria-labelledby, or a <label for> β reaches only the first ` +
`one, and the others cannot be linked to at all. Renumber every copy after the first to an id ` +
`nothing else on this page uses, and repoint the reference that meant each one, so each link ` +
`still lands on the element it names. Change nothing else about the page.`
);
}
// Namespace colliding ids across a document's pages. Input is one entry per page in
// document order; output is the rewritten inner HTML in the same order, plus what
// was done. Pages are processed as a set because a collision is not visible from
// inside any single one of them.
//
// **`pages` must already be in document order** β a precondition, not a preference.
// `resolve` sends a reference the page does not own to the FIRST page in the array
// claiming that id, on the grounds that this is what a browser resolved the bare
// reference to before any renaming. Array order is what that reads, so an unsorted
// input would silently repoint at a different owner than the pre-assembly document
// used. `assembleBodyWithReport`, the only caller, sorts by `.order` first. Sorting
// again here would be the wrong fix: `order` is caller-supplied and can repeat (see
// the label deduplication below), so a sort would not establish the property, and it
// would hide a caller that had lost track of its own ordering.
export function namespaceAnchors(pages: { order: number; innerHtml: string }[]): {
pages: string[];
report: AnchorReport;
} {
// Pass 1: who owns what. A page with no ids cannot contribute a collision, and
// needs no parse for the rewrite either, so most documents stop here.
const doms: (JSDOM | null)[] = [];
const owned: Set<string>[] = [];
// Pages that were parsed and CANNOT be rewritten β the parse threw, or the tree is deeper
// than `MAX_NESTING`. As against merely not parsed yet: a page pass 1 skipped for owning no
// id can still be parsed in pass 2 for its references, while one of these is delivered as
// written whatever pass 2 finds. The first version of pass 2 ran the second kind through the
// first kind's path, so it exited at the reference sniff and was never recorded as skipped β
// the duplicate id survived with an empty report.
//
// Not the same as `doms[i] == null`, which is why it is its own set: a too-deep page keeps a
// readable DOM (see `parseFragment`), so it is in here WITH a `doms` entry.
const unrewritable = new Set<number>();
// Bare id -> the pages claiming it, in document order, identified by ARRAY INDEX
// and not by `order`. The FIRST entry is what a browser resolves that bare id to in
// the un-namespaced document, which is what a reference from a non-owning page has
// to keep pointing at.
//
// Index rather than `order` because index is unique by construction and `order` is
// an input: two fragments carrying the same `order` would be one page as far as
// ownership went, both get the same prefix, and their colliding ids stay collided β
// the one input that silently defeats this whole function. `order` is still what the
// prefix and the report SAY (see `labelFor`), since that is the page numbering
// everything else reports.
const claims = new Map<string, number[]>();
for (const [i, page] of pages.entries()) {
// The same ATTR_SEP class pass 2's reference sniff uses. A false negative is
// worse here than there: the page contributes nothing to `claims`, so the collision is
// not merely unrepointed but never DETECTED, and the whole join no-ops on a document
// that ships two `id="fn-1"` with an empty report. Lint's `duplicate-id` still catches
// that one, which is the difference between a reported defect and a silent one, but
// the fix belongs here.
if (!new RegExp(`${ATTR_SEP}id\\s*=`, "i").test(page.innerHtml)) {
doms.push(null);
owned.push(new Set());
continue;
}
const { dom, rewritable } = parseFragment(page.innerHtml);
// A page that cannot be REWRITTEN still owns its ids: it is delivered as written, so they
// are in the document either way, and recording nothing would be a false statement with
// teeth (see `sourceIds`). Read from the tree whenever there is one β exact, and available
// even for a too-deep page, since `querySelectorAll` does not recurse. The source scan is
// only for a page that has no tree at all.
const ids = dom ? ownedIds(dom.window.document) : sourceIds(page.innerHtml);
if (!rewritable) unrewritable.add(i);
doms.push(dom);
owned.push(ids);
for (const id of ids) claims.set(id, [...(claims.get(id) ?? []), i]);
}
const collisions = [...claims].filter(([, owners]) => owners.length > 1).map(([id]) => id);
// Pages pass 1 did not parse because they own no id, parsed in pass 2 for their
// references. Declared out here so `finally` closes them along with pass 1's.
const reportOnly: (JSDOM | null)[] = [];
try {
if (collisions.length === 0) {
// The overwhelmingly common case: nothing to rename, so nothing is parsed and
// reserialized and every page is delivered exactly as its agent wrote it.
//
// A page that used one id twice BY ITSELF is deliberately not a collision here:
// ids are collected per page as a set, and no prefix could fix it anyway since
// both copies would get the same one. It is answered one step earlier instead β
// `duplicateIds` above runs at the page step, where the model that wrote the ids
// can still renumber them β and lint's `duplicate-id` / `duplicate-id-active`
// remain the backstop on the assembled document for the page that arrives here
// with one anyway.
return { pages: pages.map((p) => p.innerHtml), report: EMPTY_REPORT };
}
const colliding = new Set(collisions);
const report: AnchorReport = {
collisions: collisions.sort(),
pinned_ids: [],
ambiguous: [],
unrepointed: [],
skipped_pages: [],
};
const out = pages.map((p) => p.innerHtml);
// The prefix has to be one no id in the document already uses, or the rename
// manufactures the very collision it exists to remove β and does it silently.
//
// `p1-total`, `p2-name` and the like are exactly what a paginated form or worksheet
// emits, and the page agent has no idea the assembler reserves that shape. Given
// `id="x"` on pages 1 and 2 plus a working `<label for="p1-x">`/`<input id="p1-x">`
// pair on page 2, page 1's `x` becomes `p1-x` and now two elements own it. Page 2's
// label, which named its own field correctly before assembly touched anything,
// resolves to page 1's `<p>` β not a labelable element, so the field loses its
// accessible name (1.3.1 / 4.1.2). Nothing in the report says so either: page 2
// owns `p1-x`, so the reference is not ambiguous, and the page was not skipped. The
// only symptom is a duplicate-id violation on a document that was clean before.
//
// So the separator is grown until no page claims anything starting with it. One
// extra `-` per round terminates: `claims` is finite, each round is strictly longer,
// and a document would have to contain a literal `p1--β¦-x` for every length below
// the winner to push it far.
//
// The label a page contributes to its prefix is its `order` β the page numbering
// the Reader, the `assembly_anchors` log and `fragments.json` all use, so a
// delivered `p3-fn-1` names the page a human can find. But the prefix has to be
// unique per page or the rename is a no-op for the pages that share one, and
// `order` is caller-supplied, so a repeat is deduplicated here rather than trusted.
const labels: string[] = [];
const usedLabels = new Set<string>();
for (const page of pages) {
let label = `p${page.order}`;
for (let n = 2; usedLabels.has(label); n++) label = `p${page.order}_${n}`;
usedLabels.add(label);
labels.push(label);
}
let sep = "-";
const taken = [...claims.keys()];
while (taken.some((id) => labels.some((label) => id.startsWith(`${label}${sep}`)))) sep += "-";
const prefixFor = (index: number) => `${labels[index]}${sep}`;
// Pass 2: which pages the join has to touch, and which of those it must not.
//
// A page has work to do if it owns a colliding id (its ids get prefixed) or if it
// references one it does NOT own (that reference has to be repointed at whatever
// the owner's id became). The second kind is easy to miss and is the shape that
// breaks: a page carrying only the footnote markers, with the notes collected at
// the back, owns nothing at all β so a version that only visited owners would
// leave precisely those references dangling.
//
// A page needing work is parsed if pass 1 did not already do it, and then checked
// with `wouldChangeMarkup`. Deciding the skips HERE, before anything is rewritten, is
// what lets the rename targets below be computed once: a skipped page keeps its
// bare ids, so what a reference to it should say depends on the skip decision.
const work: { index: number; dom: JSDOM; mine: Set<string>; refs: Set<string> }[] = [];
// Indexes, not `order`s: `skipped_pages` is the report and reports in `order`, but
// the skip decision is consulted per page below and two pages can share an `order`.
const skipped = new Set<number>();
// Colliding ids that a skipped page REFERS to but does not own β the references that
// are stuck in their bare form, whatever the rest of the document does. See `pinned`.
const refsOfSkipped = new Map<number, Set<string>>();
// Per page, the colliding ids it BOTH owns and links to itself β a reciprocal pair
// written by one agent looking at one image, e.g. a footnote marker beside its own
// note. Consulted by `resolve` to decide whether another page's link to that id may be
// aimed at this one. Only links count, not `for`/`headers`/`aria-*`: those are not
// reciprocal, and the whole point of the rule below is a target that already has its
// partner.
const selfLinked = new Map<number, Set<string>>();
const recordSelfLinks = (index: number, dom: JSDOM, mine: Set<string>) => {
const own = new Set<string>();
for (const el of dom.window.document.querySelectorAll("[href^='#']")) {
const token = el.getAttribute("href")!.slice(1);
if (token && colliding.has(token) && mine.has(token)) own.add(token);
}
if (own.size > 0) selfLinked.set(index, own);
};
for (const [i, page] of pages.entries()) {
const mine = owned[i];
const ownsCollision = [...mine].some((id) => colliding.has(id));
let dom = doms[i];
if (!dom && !unrewritable.has(i)) {
// No `id=` at all, so pass 1 skipped it. It can still hold a reference, so it is
// parsed unless the source contains nothing that could be one.
//
// Deliberately over-inclusive, like `sourceSequence`: the attribute alternatives match
// ordinary prose too (`<p>see the headers for details</p>`), and the cost of a
// false positive is one parse whose reference set comes back empty. A false
// NEGATIVE would leave a page's references unrepointed, so the test has to be
// impossible to fail in that direction β and an earlier version was not.
//
// It required a QUOTE after `href=`, so a page owning no `id=` whose only
// cross-page reference was `href=#fn-1` was skipped here and never repointed,
// while the pages owning `fn-1` were renamed out from under it. That link had
// resolved to the wrong note before assembly; afterwards it resolved to nothing,
// and nothing said so β the page appears in neither `ambiguous` nor
// `skipped_pages`, so no `assembly_anchors` line names it, and axe has no rule for
// a broken same-document anchor. Exactly the trade this file's header rejects,
// reached through the one branch that claimed it could not be.
//
// So the quote after `href=` is optional, and the attribute separator is the
// ATTR_SEP class rather than `\s`. Both of those were bugs found one at a time,
// each one a page whose references went unrepointed.
if (!new RegExp(`href\\s*=\\s*["']?#|${ATTR_SEP}(?:for|form|list|headers|aria-)`, "i").test(page.innerHtml)) continue;
const parsed = parseFragment(page.innerHtml);
dom = parsed.dom;
if (!parsed.rewritable) unrewritable.add(i);
reportOnly.push(dom);
}
if (unrewritable.has(i)) {
// This page cannot be rewritten β the parse threw, or its tree is deeper than
// `MAX_NESTING` β so it is delivered byte-for-byte. That is the same OUTCOME as
// tripping the reserialization guard below, so it joins the same set. Two rules read
// `skipped` that way: `resolve` keeps a reference to a skipped owner bare, and the pin
// refuses to fire when an owner was skipped.
//
// Membership in `skipped` is not by itself enough to reach either reader, which was
// the bug in the first version of this branch: both reach a page THROUGH `claims`,
// so a page absent from `claims` is invisible as an owner no matter what this set
// says. Hence pass 1 records its ids either way. The record here is one of three halves.
//
// References come from the TREE when there is one, even though it is too deep to
// rewrite: `querySelectorAll` does not recurse, so the reading is exact. Only a page
// whose parse threw has no tree, and there the source scan is the sole option β
// without it the page's frozen `for="q1"` would be unknown to the pin, every owner of
// `q1` would be renamed, and the reference would name nothing. That is the mirror
// defect the pin exists to prevent, fixed for the guard route and left open for this
// one. What this branch must NOT do is fall through to `wouldChangeMarkup`, which
// walks the tree recursively and is one of the steps that overflows at this depth.
//
// Recorded only when the page is relevant to the join: it owns a colliding id, or it
// refers to one it does not own. `skipped_pages` is documented as "may still carry a
// collision or a stranded reference" and a human is asked to act on it, so a page
// that cannot be rewritten while doing neither is noise there.
// A page delivered as written still OWNS its ids, so it can still be the owner a
// link is aimed at, and its own marker still speaks for its own note (#233). Read
// from its tree, which a too-deeply-nested page keeps β `querySelectorAll` does not
// recurse, the same argument the comment above makes for its references. Without
// this, such a page reads as free and the link is repointed at its already-marked
// note, which is precisely the shape the rule exists to avoid.
//
// A page whose PARSE threw is the one case left out. Its self-links would have to
// come from a fourth source scan, and `sourceRefs` cannot say which attribute a
// reference came from, so the rule's "links only" boundary is not available there.
// Such a page therefore reads as free and its notes are treated as they were before
// #233 β a reference aimed at one still resolves; it may resolve to a note that has
// its own marker.
if (dom) recordSelfLinks(i, dom, mine);
const readRefs = dom ? referencesIn(dom.window.document) : [...sourceRefs(page.innerHtml)];
const frozenRefs = new Set(readRefs.filter((r) => colliding.has(r) && !mine.has(r)));
if (!ownsCollision && frozenRefs.size === 0) continue;
for (const ref of frozenRefs) report.ambiguous.push({ page: page.order, ref });
skipped.add(i);
report.skipped_pages.push(page.order);
if (frozenRefs.size > 0) refsOfSkipped.set(i, frozenRefs);
continue;
}
if (!dom) continue;
const { document } = dom.window;
recordSelfLinks(i, dom, mine);
const refs = new Set(referencesIn(document).filter((r) => colliding.has(r) && !mine.has(r)));
if (!ownsCollision && refs.size === 0) continue;
// Reported whether or not the page can be rewritten β an ambiguity a skipped page
// has to live with is still one a human should see.
for (const ref of refs) report.ambiguous.push({ page: page.order, ref });
if (wouldChangeMarkup(page.innerHtml, document)) {
report.skipped_pages.push(page.order);
skipped.add(i);
if (refs.size > 0) refsOfSkipped.set(i, refs);
continue;
}
work.push({ index: i, dom, mine, refs });
}
// Colliding ids whose FIRST owner must keep its bare form, because a page that could
// not be rewritten holds a reference to it.
//
// The skip guard leaves a page byte-for-byte as its agent wrote it, which means a
// reference on that page keeps pointing at a bare id. If that id's owners are all
// renamed anyway, the reference names nothing β and for `for`/`headers`/`aria-*` a
// no-target reference is a 1.3.1/4.1.2 failure, where the wrong-target one it replaced
// at least gave the field a name. That is the trade this file's header refuses, and
// `resolve` already refuses it in the mirror direction (a reference TO a skipped page
// stays bare). This is the same rule applied to a reference FROM one.
//
// Only the first owner is pinned, not every owner and not the whole id. First owner is
// what a browser resolved the bare reference to in the concatenated document, so the
// skipped page keeps exactly the association it had; the remaining owners are still
// renamed, so the duplicate is still fixed for everyone else. Pinning the whole id
// would abandon the collision entirely on account of one unrewritable page.
//
// And nothing is pinned when an OWNER of the id was itself skipped, which is the
// condition that makes this rule safe rather than self-defeating. A skipped owner keeps
// its bare id by definition β it is delivered byte-for-byte as written β so the frozen
// reference already finds a real element, and pinning a second copy on top of that
// manufactures the duplicate id this whole module exists to remove. Without this check
// a form continued across a page break (two pages claiming `q1`, one of them holding
// orphaned `<tr>`s the guard will not rewrite) shipped two `id="q1"`, which axe reports
// as `duplicate-id-aria`. The premise "the frozen reference can only ever find the bare
// one" is what needs the qualification: when an owner is skipped, the bare one is
// already there.
const pinned = new Set<string>();
for (const i of skipped) {
for (const ref of refsOfSkipped.get(i) ?? []) {
if ((claims.get(ref) ?? []).some((owner) => skipped.has(owner))) continue;
pinned.add(ref);
}
}
// Reported, because a pinned id is a colliding id that was deliberately NOT renamed.
// `collisions` alone would say it was, so the run log could not tell an intentional
// bare id from namespacing that silently failed.
report.pinned_ids = [...pinned].sort();
// What each colliding id becomes, from the point of view of the page naming it.
//
// If the page OWNS the id, it means its own copy: the reference and its target were
// written together by one agent looking at one image, so no other page's copy can
// have been meant β whatever the concatenation happened to resolve it to.
//
// Otherwise the reference is ambiguous, and it is repointed at the FIRST page in
// document order that claims the id. Leaving it alone was the first answer and it
// was the wrong one: consider a form whose `<label for="q1">` is on page 1 while
// pages 2 and 3 each carry an `<input id="q1">`. Every owner gets renamed, so the
// label β which named the right control before assembly touched anything β points
// at an id no element has, the field loses its accessible name, and axe reports
// `label` on a document a plain concatenation passed. Same shape for a notes page
// back-referencing `#fnref-1` while two body pages both carry a marker numbered 1.
// First-owner is what a browser resolved the bare reference to before any of this
// ran: arbitrary between the owners, but exactly as arbitrary as the behaviour it
// replaces, and it keeps the association `for`/`headers`/`aria-*` depend on rather
// than destroying it.
// One exception, and it is the only place this file prefers a reference that lands
// nowhere: a LINK aimed by document order at a target whose owner links to it itself
// (#233). Measured, not supposed β in one bench round, 18 of 63 footnote markers in the
// delivered documents pointed at another page's note. Page 6 transcribed two markers
// and no notes, so its `href="#fn-1"` was repointed at page 3's `fn-1`, which page 3's
// own marker already refers to. The reader following footnote 1 on page 6 arrives at
// page 3's note, whose back-reference returns them to page 3. Nothing catches it: the
// link resolves, axe has no rule for a link that lands on the wrong element, and
// namespacing already removed the duplicate id.
//
// A reciprocal pair is the evidence. A marker and its note are written together by one
// agent looking at one image, so an id whose owner links to it is spoken for and no
// other page's link can have meant it. Where an owner does NOT link to its own copy β
// a note continued from an earlier page, a real shape in this corpus β the reference is
// repointed exactly as before, because there the outside link is the only claim on it.
//
// Which is why a link takes the first owner that is not spoken for, rather than checking
// only the first owner and giving up. Consulting only the first was the first version of
// this rule and it threw away right answers: with page 1 owning a self-linked `fn-1`,
// page 2 owning the note it continues (no marker of its own), and page 3 carrying the
// marker for page 2's note, page 3's link is the ONLY claim on page 2's note β the case
// the paragraph above says is still repointed β and first-owner-only left it bare with
// page 2's note unreachable from anywhere. Only when every owner already has a marker is
// there nothing to aim at.
//
// Links only. For `for`/`headers`/`aria-*` this file's header refuses precisely this
// trade, and rightly: a no-target `for` is a 1.3.1/4.1.2 failure where the wrong-target
// one at least gave the field a name. A link is different in kind β a wrong target
// silently misinforms, where no target is at least a link that visibly does nothing β
// and since #234 it is also COUNTED, on the delivered bytes, as
// `links_unresolved_rate`. That is what makes leaving it bare an improvement rather
// than a shrug: before that check, this choice would have traded a confidently wrong
// document for an invisibly broken one.
const reportedUnrepointed = new Set<string>();
const resolve = (index: number, mine: Set<string>, token: string, viaLink = false) => {
if (!colliding.has(token)) return token; // includes `href="#"`, whose token is ""
const owners = claims.get(token)!;
// The owner this reference is aimed at: the page's own copy when it has one, otherwise
// the first in document order β except for a link, which passes over every owner that
// already links to its own copy (#233) and takes the first that does not. `undefined`
// means every owner is spoken for, which is the only case with nothing to aim at. A
// link never selects its own page here: if this page owned the id, the first branch
// took it.
const owner = mine.has(token)
? index
: viaLink
? owners.find((o) => !selfLinked.get(o)?.has(token))
: owners[0];
if (owner === undefined) {
// Deduplicated per page and token, the way `ambiguous` is: this runs once per
// element, and a page carrying three markers numbered 1 would otherwise report the
// same fact three times. The count that matters β how many links the reader can
// follow to nothing β is on the delivered bytes, where links.ts measures it.
const key = `${index} ${token}`;
if (!reportedUnrepointed.has(key)) {
reportedUnrepointed.add(key);
report.unrepointed.push({ page: pages[index].order, ref: token });
}
return token;
}
// A page left as written kept its bare ids, so a reference to it must stay bare.
//
// One inexactness, stated rather than chased: if the chosen owner is such a page AND an
// EARLIER owner is delivered as written too, the bare reference resolves to that earlier
// copy rather than to this one, because a browser takes the first bare id in document
// order. It needs a colliding id with two owners the reserialization guard refused, and
// the reference lands on a real copy of the note either way.
if (skipped.has(owner)) return token;
// And the first owner of a pinned id keeps its bare form for a skipped page's
// reference, so a reference resolved TO that owner has to stay bare as well β
// otherwise this page's reference is the one left naming nothing. Compared against
// `owners[0]` specifically: a link repointed to a LATER owner takes that owner's prefix,
// since the pin holds only the first owner's id bare and renames all the rest.
if (pinned.has(token) && owner === owners[0]) return token;
return `${prefixFor(owner)}${token}`;
};
// Whether this page's copy of a colliding id gets renamed. False only for the first
// owner of a pinned id β the one a skipped page's bare reference has to keep finding.
const renames = (index: number, id: string) =>
!(pinned.has(id) && index === claims.get(id)![0]);
// Pass 3: rewrite. One loop, because by now every decision has been made.
for (const { index, dom, mine } of work) {
const { document } = dom.window;
for (const el of document.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
if (id && colliding.has(id) && renames(index, id)) el.setAttribute("id", `${prefixFor(index)}${id}`);
}
for (const el of document.querySelectorAll("[href^='#']")) {
const target = el.getAttribute("href")!.slice(1);
const next = resolve(index, mine, target, true);
if (next !== target) el.setAttribute("href", `#${next}`);
}
for (const attr of IDREF_ATTRS) {
for (const el of document.querySelectorAll(`[${attr}]`)) {
const value = el.getAttribute(attr)!;
// Treated as a token list throughout: `headers` and the aria-* plural
// attributes genuinely are, and a single-valued attribute whose value
// contains a space was never a valid reference anyway.
const next = value
.split(/\s+/)
.filter((t) => t.length > 0)
.map((t) => resolve(index, mine, t))
.join(" ");
if (next !== value) el.setAttribute(attr, next);
}
}
out[index] = document.body.innerHTML;
}
report.ambiguous.sort((a, b) => a.page - b.page || a.ref.localeCompare(b.ref));
report.unrepointed.sort((a, b) => a.page - b.page || a.ref.localeCompare(b.ref));
report.skipped_pages.sort((a, b) => a - b);
return { pages: out, report };
} finally {
for (const dom of [...doms, ...reportOnly]) {
// `close()` walks the tree recursively, so it overflows on a page kept for reading but
// too deep to rewrite. A throw from a `finally` would replace this function's return
// value with a `RangeError`, turning a document the caller had already been handed into
// a failed run β so cleanup is allowed to fail. What it releases early is otherwise left
// to the collector.
try {
dom?.window.close();
} catch {
// Deliberately empty: see above.
}
}
}
}