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
736import { runAxe, lintErrorFields, lintDebrisFields, isKnownLanguage, type LintResult } from "./lint.ts";
import { cutPoints } from "./sections.ts";
import { namespaceAnchors, type AnchorReport } from "./anchors.ts";
import { stripDeprecatedRoles, stripInvalidRoles, type RoleStrip, type InvalidRoleStrip } from "./roles.ts";
import { stripNestedMain, type MainStrip } from "./landmarks.ts";
import { joinContinuedTables } from "./tables.ts";
import { joinPageBreakProse, type ProseJoinReport } from "./prose.ts";
import { stripPositionalMarkers, type MarkerReport } from "./markers.ts";
import type { Fragment } from "./fragment.ts";
import type { PipelineContext } from "./context.ts";
export interface AssemblyResult {
html: string; // full document (shell + body)
body: string; // body content only (what the review loop edits)
lint: LintResult;
}
// Join page fragments in order into clean body content โ no provenance comments
// in the delivered HTML. Per-page provenance is preserved in fragments.json.
//
// Ids that more than one page claimed are namespaced as the pages are joined (see
// anchors.ts). This is the only place that can do it: a page is extracted alone and
// concurrently, so it cannot know that another page also numbered its first footnote
// "1", and this is the first moment the whole document exists. The prefix comes from
// `order` rather than the array index so the ids in a delivered document are stable
// across runs and match the page numbering everything else reports (the Reader's
// `pages`, the `assembly_anchors` log, `fragments.json`).
export function assembleBody(fragments: Fragment[]): string {
return assembleBodyWithReport(fragments).body;
}
// Same join, with what the namespacing did. Split out rather than folded in because
// `assembleBody` has callers that only want the body (the review loop's re-lint, the
// re-extraction baseline) and a returned report they ignore would be one more thing
// to thread through.
//
// A deprecated ARIA role redundant with its host element is dropped here too (roles.ts,
// issue #187) โ for the same reason the namespacing happens here and not in a page: it is a
// rewrite with no judgement in it that no model call should be spent on. It is done on the
// joined body rather than per page because there is nothing per-page about it, and a body
// with no such role comes back the same string.
//
// A `<main>` a page emitted for itself goes the same way (landmarks.ts, issue #251), and it has
// to happen after the join for a reason of its own: `wrapDocument` below is what supplies the
// document's `main`, so whether a fragment's own one is a duplicate is a fact about the
// assembled document rather than about the page that wrote it.
//
// A sentence the source printed across a page turn is mended here too (prose.ts, issue #248), and
// this is the one stage that CAN, for two facts that exist here and nowhere downstream. A page that
// came back empty is dropped from the body and is then visible only as a hole in `order`, and the
// rule must decline across it โ that page may be holding the middle of the sentence. And a page the
// namespacing had to skip is being delivered byte for byte, which only `report.skipped_pages` says.
// Everything after this point sees one string with the pages' provenance already spent.
//
// A page that FAILED extraction is not one of those facts, which is worth saying because it looks
// like it should be: it ships a `@page-failed` comment (extraction.ts), so `order` stays contiguous
// and the comment itself stands between the halves as a node the join declines at.
export function assembleBodyWithReport(fragments: Fragment[]): {
body: string;
anchors: AnchorReport;
deprecatedRoles: RoleStrip;
invalidRoles: InvalidRoleStrip;
mains: MainStrip;
prose: ProseJoinReport;
markers: MarkerReport;
} {
const ordered = [...fragments].sort((a, b) => a.order - b.order);
const { pages, report } = namespaceAnchors(ordered.map((f) => ({ order: f.order, innerHtml: f.innerHtml.trim() })));
// Empty pages are dropped before the prose join rather than after, so a page that came back with
// nothing is a hole in the numbering the join can see โ the same shape a failed page leaves, and
// it wants the same answer.
//
// A page the namespacing had to skip is passed through as untouchable: it is being delivered byte
// for byte because the parser and its bytes disagree about its structure, and a pass that reads
// that structure to find the paragraph at the page's edge would be reading the half of the
// disagreement a browser will not honour.
const skipped = new Set(report.skipped_pages);
const kept = pages
.map((html, i) => ({
order: ordered[i]!.order,
// The filename as the page agent was told it, which is the positional number a marker's label
// is checked against (markers.ts).
name: ordered[i]!.image,
html,
asWritten: skipped.has(ordered[i]!.order),
}))
.filter((p) => p.html.length > 0);
// A marker naming the page's position in the file rather than the number the page prints loses its
// label here (markers.ts, issue #333), and this is the only stage that can do it: the offset that
// convicts one label is derived from every other page's marker, and after the join there is one
// string with the pages' provenance spent. It runs before the prose join because that join reads the
// marker as a boundary โ it matches the role and not the label, so the two do not interact, and
// running first keeps this pass looking at what the page agent wrote.
//
// A page being delivered byte for byte (`skipped_pages`) is edited too, unlike the prose join, which
// declines on one. The reason the join declines does not apply: this is a splice inside a start tag
// with no reserialization anywhere near it, the same class of edit as the role strips that already
// run over those bytes further down.
const markers = stripPositionalMarkers(kept);
const prose = joinPageBreakProse(kept.map((p, i) => ({ ...p, html: markers.pages[i]! })));
const joined = stripDeprecatedRoles(prose.pages.join("\n\n"));
// And a role that is not a role at all, on the same argument one step further (roles.ts, #345).
// After the deprecated pass rather than before it only for reading order: the two look at
// disjoint sets of tokens โ every role ARIA deprecates is still a valid role โ so neither pass
// can take work from the other, whichever runs first.
const invalid = stripInvalidRoles(joined.html);
const mains = stripNestedMain(invalid.html);
return {
body: mains.html,
anchors: report,
deprecatedRoles: joined,
invalidRoles: invalid,
mains,
prose: prose.report,
markers: markers.report,
};
}
// The language the shell declares, read off the body instead of assumed. `lang="en"` on a document
// assembled from Korean pages is not merely unhelpful: WCAG 3.1.1 is about the default human language
// of the page, a screen reader picks its voice from this attribute, and axe cannot see the mistake
// because `html-has-lang` and `html-lang-valid` are both satisfied by a confident wrong answer
// (issue #163). The page agent is told to put `lang` on every top-level element it emits for a page
// wholly in another language (see `agents/page.md`), so the value is here to be read.
//
// It is derived only where the whole body agrees: every top-level element carries a `lang` and they
// all carry the same one. Anything else keeps `en` โ a multilingual document has no single primary
// language to declare, and a body whose pages said nothing gives nothing to derive from. The root
// declaration follows the content and never guesses ahead of it, which is why the two halves of #163
// were kept apart: this is only as good as the fragments.
// The start tag opening a top-level segment, anchored: a segment that does not BEGIN with one is not
// an element and is not asked (see below). Group 2 is the attribute list, read attribute by attribute
// rather than searched for ` lang=`, because a search finds one inside another attribute's value โ
// `<section title="see lang=fr note">` is a French document by that reading.
const START_TAG = /^\s*<([a-zA-Z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/;
const ATTRS = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g;
// A comment, a doctype or a processing instruction: `cutPoints` gives each its own segment, it bears
// no text, and it is what a failed page is in the body as (`@page-failed`).
const OPAQUE_SEGMENT = /^\s*<[!?]/;
function attrValue(attrs: string, name: string): string | null {
ATTRS.lastIndex = 0;
for (let m = ATTRS.exec(attrs); m; m = ATTRS.exec(attrs)) {
// The first spelling wins, which is what a parser does with a repeated attribute.
if (m[1]!.toLowerCase() === name) return m[2] ?? m[3] ?? m[4] ?? "";
}
return null;
}
// The shape of a language tag, not a registry lookup: `lang="Korean"` and `lang="ko_KR"` are the two
// things a model writes when it means `ko`, and either would be a well-formed nothing. The primary
// subtag is held to the two and three letter ISO 639 forms rather than the grammar's 2-8, because that
// is what closes the gap between "shaped like a tag" and "is a language" โ `Korean` is a well-formed
// 6-letter subtag and is not a language, while every code a page could honestly be in fits in three.
const LANG_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i;
// Shape is not enough, because the value goes on the one element in the document that the linter
// checks: an unrecognizable `lang` on the root trades a silent 3.1.1 failure for a loud
// `html-lang-valid` one, which is a regression bought with a fix. Measured with this repo's `runAxe`,
// axe validates against the registry's PREFERRED values, so it refuses exactly the tags that have a
// preferred form โ `kor`, `spa`, `fra`, `deu`, `eng`, `zho` and the rest of ISO 639-2/B all fail
// `html-lang-valid`, while `haw`, `chr`, `fil`, `yue`, `ceb` and the other three-letter codes with no
// two-letter equivalent are clean. So the primary subtag cannot simply be narrowed to two letters:
// that would refuse the derivation for every language that only HAS a three-letter code, which is the
// same 3.1.1 defect for a smaller set of readers.
//
// A tag with a preferred form is therefore delivered IN that form rather than refused. `Intl`'s
// canonicalization is the registry's own alias data โ `kor` โ `ko`, `iw` โ `he`, `art-lojban` โ `jbo`,
// and `ko` โ `ko` untouched โ so a page that answered "use the BCP 47 tag" with `kor` still gets a
// Korean root, and it gets the spelling axe and a screen reader both accept. A value the
// canonicalizer refuses outright (`ko_KR`, `ko-x`, `x-klingon`) is not a tag at all and keeps `en`.
// Rewriting a page's answer is worth it only here, at the root: the fragment keeps whatever it wrote,
// where `valid-lang` reports it as a body issue the review loop can correct.
// Tags that are well formed and are not an answer to "what language is this document in": the
// registry's own placeholders for undetermined (`und`), no linguistic content (`zxx`), multiple
// languages (`mul`), uncoded (`mis`) and the private-use range `qaa`โ`qtz`. axe accepts every one of
// them, which is precisely why they are refused here rather than left to the gate: as a document's
// DEFAULT HUMAN LANGUAGE they are the same kind of non-answer as `lang="Korean"`, and a screen reader
// given one falls back to its own default anyway. `en` is at least a language a voice can be chosen
// for, and `mul` is the case the unanimity rule already has an answer to.
const NOT_AN_ANSWER = /^(?:und|zxx|mul|mis|q[a-t][a-z])(?:-|$)/i;
// The third question, and the one neither of the two above can answer: is this a language AT ALL.
// Canonicalization is a syntax check plus the registry's ALIAS table, so a subtag with a preferred
// form gets repaired (`kor` โ `ko`, which is what the alias table is here for) while one that is in
// no table at all has nothing to look up and passes through untouched onto the root. That is the
// gap #196 measured: `cn`, `jp`, `cz`, `dk`, `gr`, `ua`, `vn` โ the country code written where the
// language code belongs, the commonest wrong-but-well-formed `lang` in real HTML and a plausible
// answer to "use the BCP 47 tag" from a model looking at a Chinese page โ and `xxy`, `zzz`, shaped
// like a tag and not a language. Each of them put a SERIOUS `html-lang-valid` on the one element
// this file writes: the exact regression the shape check exists to prevent, arriving through the
// part of the question shape cannot answer.
//
// So the question is put to the gate's own list (`isKnownLanguage`, lint.ts) rather than to another
// approximation of it. Three named exceptions had each closed the instances that had been
// demonstrated to them; this closes the class, because "is it a language" is now answered by the
// thing that will be asked.
//
// It is asked about the primary subtag of the CANONICAL value, not the whole tag: `ko-KOREAN` is
// axe-clean and is not a whole tag any list holds. And it does NOT replace NOT_AN_ANSWER โ the
// registry lists `und`, `zxx`, `mul`, `mis` and `qaa`โ`qtz`, which is precisely why they are refused
// here: they are well-formed, axe is right to accept them, and they are still not a language a
// screen reader can choose a voice for.
function preferredTag(value: string): string | null {
if (!LANG_TAG.test(value) || NOT_AN_ANSWER.test(value)) return null;
let canonical: string;
try {
canonical = Intl.getCanonicalLocales(value)[0] ?? "";
} catch {
return null;
}
if (!isKnownLanguage(canonical.split("-")[0] ?? "")) return null;
if (canonical.toLowerCase() === value.toLowerCase()) return value;
return LANG_TAG.test(canonical) ? canonical : null;
}
// Elements with no text of their own, which therefore have no language and are not asked for one.
// The page-break separator the page prompt prescribes โ `<hr role="doc-pagebreak" aria-label="Page
// 5">` โ sits at top level between every pair of pages in a multi-page document, so without this a
// Korean document whose pages all declared `ko` would still be delivered as English: the marker
// carries no `lang`, one disagreement is enough, and the commonest document in the system would
// never derive anything. `aria-label` is not text in the element's language for this purpose
// either; it is generated by the extractor and is English by construction.
//
// The set is these three and not "void elements": a top-level `<img alt="โฆ">` or `<input>` label IS
// text of the page, in the page's language, and a bare one with no `lang` is a page that did not
// answer. That refuses the derivation, which is the safe direction and costs a glance.
const NO_TEXT_OF_ITS_OWN = new Set(["hr", "br", "wbr"]);
// `cutPoints` drops a boundary that lands on the last character, since a cut there would open an
// empty section โ so a body that ends properly and a body whose last element was never closed both
// come back with no boundary at the end. The distinction is the whole of the guard below, so the scan
// is run over the body with a comment appended: every real node end is then before the end of the
// string and survives, and the appended comment's own boundary is the one dropped.
const CLOSED = "<!---->";
// Exported for the tests, which read the derivation directly: reaching an interesting case through
// `wrapDocument` means asserting on a whole document shell to learn one attribute.
export function bodyLang(body: string): string | null {
const boundaries = cutPoints(body + CLOSED).filter((p) => p <= body.length);
let start = 0;
let agreed: string | null = null;
// Anything after the last boundary is a top-level element that was never closed โ and an unclosed
// element swallows everything after it, so the ONE tag this scan would read for that whole run is
// the first page's. `<section lang="ko"><p>๊ฐ</p>` followed by three English pages would be read as
// a Korean document: one page's answer promoted to the root of a document mostly not in it, which
// is the failure this whole derivation is built to avoid. It is not decidable from here whether the
// run holds one element or five, so it is refused. The cost is a body whose last element omits its
// end tag losing the derivation โ `en`, a glance, and the fragments keep their own `lang`.
if (body.slice(boundaries.at(-1) ?? 0).trim() !== "") return null;
for (const boundary of boundaries) {
const segment = body.slice(start, boundary);
start = boundary;
if (segment.trim() === "" || OPAQUE_SEGMENT.test(segment)) continue;
// A segment that does not begin with a start tag begins with something else at top level: stray
// prose between two fragments, or an end tag matching nothing. Both mean text that no element
// claims, so no `lang` covers it and nothing here can speak for the document.
const tag = START_TAG.exec(segment);
if (!tag) return null;
if (NO_TEXT_OF_ITS_OWN.has(tag[1]!.toLowerCase())) continue;
const lang = preferredTag((attrValue(tag[2]!, "lang") ?? "").trim());
if (!lang) return null;
if (agreed && agreed.toLowerCase() !== lang.toLowerCase()) return null;
agreed ??= lang;
}
return agreed;
}
// The `@editor-truncated` marker for a round whose reply was read as far as it got (#295).
//
// Its own function because it is three statements about two different parts of one document, and
// which of the three applies depends on what was left over: a reply that reached the last block
// left nothing to ask for again, a remainder that was corrected a section at a time carries two
// kinds of correction, and a remainder that could not be sectioned is text this round never
// touched. Written out rather than assembled from clauses โ a marker that a person reads to find
// out what happened to their document is worth three plain paragraphs.
//
// A fourth reading, and the one a reader would otherwise be told the opposite of: the round can be
// salvaged with NO edits in it, where the reply's list of changes closed empty and the ceiling was
// reached in what the model wrote afterwards. That document is unchanged because the editor found
// nothing to change, which is not what "a round could not be completed" means, so it says so in its
// own words rather than as `named 0 of them`.
//
// That paragraph says no part of the document was asked for again, so it is conditional on there
// being no sections as well as no edits. The one route that produces `edits: 0` today leaves no
// remainder by construction (`salvageRound`'s closed-empty return, where `used === 0` on the ordinary
// path is `all_refused` and declines) โ but a zero-edit prefix with a corrected tail would be a
// document this paragraph describes wrongly, and the three paragraphs below describe it correctly.
//
// `cutBack` (#317) changes the FIRST paragraph, because on a retreat the boundary is not the
// ceiling's. The reply answered past `blocks` โ possibly to the end of the document, where its edits
// list closed โ and Iris stopped there instead, at the first change that would have taken content
// out of a block. "The answer hit the ceiling partway through" is then the one thing that did not
// happen at that point, which is the same distinction the zero-edit paragraph above exists to make.
// It also earns a fourth paragraph: the trade the retreat accepts is a possible DUPLICATE, and this
// marker is the only place a person reading the document is told where to look for one, since no
// later pass in the run can see it (`salvageRound`).
function salvagedNote(
salvaged: { edits: number; blocks: number; of: number; cutBack?: boolean },
sections?: { of: number; corrected: number },
): string {
const head = `\n<!-- @editor-truncated blocks ${salvaged.blocks} of ${salvaged.of}`;
if (salvaged.edits === 0 && !sections) {
return (
head +
`\n` +
` A correction round hit the model's output ceiling, and what it had already said was read:\n` +
` the copy editor listed no changes to make. It had considered all ${salvaged.of} of this\n` +
` document's top-level blocks and named none of them, with the whole document in view, so the\n` +
` ceiling was reached in the remarks it went on to write rather than partway through the\n` +
` corrections. This document is therefore unchanged by that round because it was passed, not\n` +
` because the round was lost, and no part of it was asked for again.\n` +
` The review loop then stopped, so any issues listed below are the ones found BEFORE that\n` +
` round and were not looked for again. See the run log (editor_truncated, editor_salvaged)\n` +
` for the ceiling and the size of the response.\n-->`
);
}
const opening = salvaged.cutBack
? ` A correction round could not be completed in one response: the copy editor is asked for\n` +
` the whole document, and the answer hit the model's output ceiling. What it had already\n` +
` said was read, and then part of that was set aside here rather than by the ceiling: its\n` +
` next change would have left one of this document's blocks holding less than it came in\n` +
` with, which is also how the first half of a MOVE looks once the ceiling has cut off the\n` +
` half that puts the content back. Rather than drop text on that reading, the round was\n` +
` taken only as far as that change: it answered about the first ${salvaged.blocks} of this\n` +
` document's ${salvaged.of} top-level blocks and named ${salvaged.edits} of them, with the whole document\n` +
` in view.\n`
: ` A correction round could not be completed in one response: the copy editor is asked for\n` +
` the whole document, and the answer hit the model's output ceiling partway through. What it\n` +
` had already said was read and kept: it answered about the first ${salvaged.blocks} of this\n` +
` document's ${salvaged.of} top-level blocks and named ${salvaged.edits} of them, with the whole document\n` +
` in view.\n`;
const rest =
salvaged.blocks >= salvaged.of
? ` The reply named its last block before the ceiling cut it, so nothing was left to ask\n` +
` for again and every part of this document has been through that round.\n`
: sections
? ` The remaining ${salvaged.of - salvaged.blocks} blocks were asked for again a section at a time, and\n` +
` ${sections.corrected} of ${sections.of} sections came back corrected โ from requests that each saw one\n` +
` section and not the rest, so a problem spanning two of them may be untouched, and a\n` +
` section that did not come back is the text that entered the round.\n`
: ` The remaining ${salvaged.of - salvaged.blocks} blocks could not be asked for again a section at a\n` +
` time, so they are the text that entered the round, uncorrected.\n`;
// The trade, told to the one person who can act on it. Not hedged into the paragraph above,
// because "may appear twice" is a thing to go and look at rather than a thing to be reassured
// about, and this marker is the only notice of it: the run itself is over.
//
// Its last clause is the reason nothing found the duplicate, and there are two of them โ the
// remainder was asked for again and the request could not see the part above it, or the remainder
// could not be asked for again at all. The warning is equally true either way, since a duplicate
// comes from the landing edit shipping while the source block keeps what it had; only the
// explanation differs, and the sectioned one names a call that on the second route never happened.
const duplicate = !salvaged.cutBack
? ""
: ` Worth a look, and nothing later in this run could do it: if that change was carrying\n` +
` content BACKWARDS into the part above that was kept, then the content is in this document\n` +
` twice โ once where it was moved to, once where it started โ because ` +
(sections
? `what was asked for\n` + ` again saw only the text from that point on and could not know about the copy above it.\n`
: `nothing read that text\n` + ` again at all, and no pass in this run sees the two places at once.\n`) +
` Losing that content silently was the alternative, so it was left where it can be seen.\n`;
return (
head +
`\n` +
opening +
rest +
duplicate +
` The review loop then stopped, so any issues listed below are the ones found BEFORE all of\n` +
` this and were not looked for again; some may already be fixed. See the run log\n` +
` (editor_truncated, editor_salvaged, editor_sections) for the ceiling, the size of the\n` +
` response and what each half of the document got.\n-->`
);
}
// Wrap body content in a minimal accessible document shell. If issues remain when the
// review loop stops โ at its cap, or on a round that changed nothing โ they are recorded
// as an HTML comment (invisible to users, but in the document for tooling); the full list
// also persists in unresolved.md.
//
// `failedPages` is recorded the same way, and for a reason worth spelling out: the
// per-page marker `failedPage` writes lives INSIDE a fragment, so it is part of the body
// handed to the Copy Editor โ and the editor rewrites blocks of that body, so a round that
// returns the block this marker sits in may return it without the marker, leaving a document
// missing a page with nothing in it to say so, which is exactly what that marker exists to
// prevent. Injected here, after
// the loop, it is out of the editor's reach for the same reason @unresolved is. The
// in-body marker stays because it says WHERE the hole is; this one guarantees the
// document admits there is one.
// `uncorrectedPages` is the same statement about the opposite failure, and it is the one where
// Iris knows the most and said the least (issue #328). These pages ARE here: they were rendered,
// the fidelity check rejected them naming what was wrong, one correction pass was bought, and it
// repaired nothing โ so what those pages carry is content Iris named a defect in and never fixed.
// Not necessarily the rejected bytes: this marker is written after the review loop, and the Copy
// Editor may have rewritten a block on one of these pages since. What no later round can have done
// is ANSWER the check. Not for want of the image โ `imagesForIssues` hands the editor the source
// pages the Reader's issues name, so an uncorrected page the Reader happened to raise something
// about is one of the few places an image and the HTML sit side by side after extraction. It is
// that the editor is never asked the fidelity question and is deliberately forbidden to act on it:
// its instruction on a discrepancy it notices in an image is to REPORT it, because an edit made
// from one reading of a page reaches a reader as what the page says (#183). So the rejection stands
// whatever the markup became, and the only thing that could lift it is a re-extraction.
// Before this the document said nothing at all about them, which made the case Iris
// understands best the case it declared least: `@page-failed` above announces a page with no
// content, obviously incomplete to anyone who opens the file, while a page whose statistical
// table lost its six aggregate rows looks finished and no longer adds up (#324, `p26-50-p5`).
// Every marker here rests on a silent gap being worse than a declared one; this is that
// principle applied where the declaration is most useful.
//
// It is deliberately NOT the same claim as "this page might be wrong". A correction the pass DID
// adopt is not listed, even though replaying the check over 57 corrected pages put its pass rate
// at 26% (#288): that page is a repaired page, listing it would put most of an ordinary round's
// pages under this marker, and a marker that fires on most pages tells a reader nothing. What
// the absence of this marker means is exactly that no page shipped as the fragment its own
// verifier rejected โ not that every page was checked after correction, which is a sample
// (`page_correction_recheck`) and not a gate.
// `editorTruncated` is the fourth statement of the same kind, and it is about the loop
// rather than about the content: a correction round's response hit the model's output
// ceiling (issue #143). Without it, a document delivered this way is indistinguishable from
// one whose issues the editor tried and failed to fix โ and the difference is what a reader
// of `@unresolved` needs.
// `editorSections` says how that round then ended, and there are two ways (issue #165). With
// it, the round was re-made a section at a time and this document carries whatever those
// sections fixed โ so the `@unresolved` list is the reading that PRECEDED them and was never
// taken again. Without it, nothing was rescued: the round was abandoned and this document is
// the one that entered it, with those issues never worked on at all. Two different documents,
// and a reader who is told "a round was abandoned" about the first would go looking for
// corrections that are in fact there.
// `editorSalvaged` is a third way for that round to have ended, and it changes what BOTH of the
// numbers above mean (issue #295). The truncated reply was read as far as it got, so part of this
// document carries the round's own whole-document corrections โ and `editorSections` then counts
// the sections of what was LEFT, not of the document. Said as blocks because that is the unit the
// editor answers in: `blocks 17 of 24` is where the reply stopped, and it is also the boundary
// between the two kinds of correction in the document below, which is the thing a reader chasing a
// problem across it needs to know. `cutBack` says that boundary was Iris's and not the ceiling's
// (#317) โ the reply answered further and was believed only this far โ which the marker has to say
// out loud, since a reader told the ceiling stopped it there would go looking for a longer reply.
// `lintUnavailable` is the fifth statement of the same kind, and the one that is about
// the CHECKING rather than about the content: axe-core could not run on this document, so
// nothing here has been through the accessibility gate at all. It belongs in the document
// for the same reason the others do โ a document delivered this way is otherwise
// indistinguishable from one the linter cleared, and the person who receives it is the one
// who most needs to know which they have. It is the delivered half of #164: the log line
// says it to an operator, this says it to whoever opens the file.
// `reviewUnread` is the sixth, and it is about the other half of the checking: the REVIEWER
// did not answer about all of this document (issue #186). The document is read in windows and
// one of them came back with nothing usable, so there is no verdict on that part โ which
// matters most where it is least visible, in a document whose `@unresolved` list is empty. An
// empty list means "nothing was found", and only a fully read document makes that the same
// claim as "there is nothing to find".
export function wrapDocument(
body: string,
opts: {
unresolved?: string[];
failedPages?: number[];
uncorrectedPages?: number[];
editorTruncated?: boolean;
editorSections?: { of: number; corrected: number };
editorSalvaged?: { edits: number; blocks: number; of: number; cutBack?: boolean };
lintUnavailable?: string;
reviewUnread?: { windows: number; of: number };
} = {},
): string {
const unresolved = opts.unresolved?.length
? `\n<!-- @unresolved\n${opts.unresolved.map((u) => ` - ${u.replace(/--+/g, "โ")}`).join("\n")}\n-->`
: "";
const failed = opts.failedPages?.length
? `\n<!-- @page-failed ${opts.failedPages.join(", ")}\n` +
` This document is incomplete: the source pages above could not be extracted and\n` +
` none of their content is here. See the run log (page_extraction_failed) or the\n` +
` session's diagnostics (pages_failed) for why.\n-->`
: "";
// Not pointed at `pages_failed` in diagnostics, which is where the issue that asked for this
// marker guessed the pages would be: that field is the no-content set and these pages are
// deliberately not in it. The counts are in `verification.results` and `verification.triggers`
// there, which say how many corrections ended each way without saying which pages โ so the
// run log is where a reader goes for the page, and this marker is where they learn to.
const uncorrected = opts.uncorrectedPages?.length
? `\n<!-- @page-uncorrected ${opts.uncorrectedPages.join(", ")}\n` +
` The content of the source pages above IS in this document, and it never passed Iris's\n` +
` own fidelity check: the check named what was wrong with each of them, one correction\n` +
` pass was made against the source image, and it repaired nothing. No later step checks a\n` +
` page against its source again, so nothing after that point can have put right what the\n` +
` check named. Expect content to be missing or mis-structured on these pages; a table that\n` +
` lost rows is the shape this has been seen in. Every other page was either accepted as\n` +
` it was or repaired by a correction. See the run log (page_verify_failed for what was\n` +
` wrong, then page_correction_failed or page_corrected for how the correction ended) โ\n` +
` the pass either threw, answered with nothing, answered with the page it was given,\n` +
` answered at a fraction of its size and was refused, or answered with the same page\n` +
` written differently.\n-->`
: "";
const truncated = !opts.editorTruncated
? ""
: opts.editorSalvaged
? salvagedNote(opts.editorSalvaged, opts.editorSections)
: opts.editorSections
? `\n<!-- @editor-truncated sections ${opts.editorSections.corrected} of ${opts.editorSections.of}\n` +
` A correction round could not be completed in one response: the copy editor is asked\n` +
` for the whole document, and the answer hit the model's output ceiling. It was made\n` +
` again a section at a time, and the corrections above are what came back โ from\n` +
` requests that each saw one section of the document and not the rest of it, so a\n` +
` problem spanning two of them may be untouched. The review loop then stopped, so any\n` +
` issues listed below are the ones found BEFORE those corrections and were not looked\n` +
` for again; some may already be fixed. See the run log (editor_truncated,\n` +
` editor_sections) for the ceiling, the size of the response and the sections.\n-->`
: `\n<!-- @editor-truncated\n` +
` A correction round could not be completed: the copy editor is asked for the whole\n` +
` document, and its response hit the model's output ceiling, so that round was\n` +
` discarded and the review loop stopped. The content below is what entered that\n` +
` round; any issues listed below were not corrected. See the run log\n` +
` (editor_truncated, editor_sections_declined) for the ceiling, the size of the\n` +
` response and why it could not be corrected a section at a time.\n-->`;
// The message is axe's own, and it is the only text here that comes from outside this
// function โ `runAxe` builds it from an Error's `message`, so it can be long, can carry a
// newline, and would otherwise be able to close this comment early. Bounded like the
// extraction note, and `--` folded the way @unresolved folds it, taking any `>` with it so
// the fold reads as prose: a marker that says the gate did not run must not be a marker
// that breaks the document saying so.
const unlinted = opts.lintUnavailable
? `\n<!-- @lint-unavailable\n` +
` This document has NOT been checked for accessibility violations: axe-core could\n` +
` not run on it, so the absence of reported violations here is not evidence that\n` +
` there are none. Everything else in this document was produced and reviewed as\n` +
` usual. See the run log (assembly / lint_unavailable) for the failure.\n` +
` ${opts.lintUnavailable.slice(0, 300).replace(/\s+/g, " ").replace(/--+>?/g, "โ")}\n-->`
: "";
// Both numbers, because one of them alone is unreadable: "1 window" says nothing about how
// much of the document that is, and a document read in one window is the whole of it.
const unread = opts.reviewUnread?.windows
? `\n<!-- @review-unread ${opts.reviewUnread.windows} of ${opts.reviewUnread.of}\n` +
` Part of this document has NOT been reviewed. It is read in ${opts.reviewUnread.of} window(s) and\n` +
` ${opts.reviewUnread.windows} of them came back with no usable answer, so nothing in those windows was\n` +
` checked for reading order, semantics, duplication or missed WCAG requirements. An\n` +
` empty or absent @unresolved list therefore means nothing was FOUND, not that\n` +
` there is nothing to find. See the run log (reader_no_output) for the replies.\n-->`
: "";
const lang = bodyLang(body) ?? "en";
// The one English string in the shell, labelled where the document around it is not English โ
// otherwise the title inherits a root that is now telling the truth about the pages and lying
// about it (WCAG 3.1.2, and a screen reader reading the tab or the document title aloud).
// The label is about THIS string, and only survives as long as it does: `GET /output` replaces the
// title's text with the uploaded file's name, whose language nobody here can vouch for, and drops
// the attribute with it (`titledAs`, util/outputNames.ts).
const titleLang = /^en(?:-|$)/i.test(lang) ? "" : ` lang="en"`;
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title${titleLang}>Accessible document</title>
</head>
<body>
<main>
${body}
</main>${failed}${uncorrected}${truncated}${unlinted}${unread}${unresolved}
</body>
</html>
`;
}
export async function runAssembly(
ctx: PipelineContext,
fragments: Fragment[],
opts: { unresolved?: string[] } = {},
): Promise<AssemblyResult> {
const { body: joinedPages, anchors, deprecatedRoles, invalidRoles, mains, prose, markers } =
assembleBodyWithReport(fragments);
// A table the source printed across a page break arrives here as two tables, and this is the
// first moment both halves exist in one string โ each page was extracted alone, so the agent that
// wrote the second half had nothing to append to (#239). The join belongs on THIS side of the
// lint and of review: the linted document and the document the Reader reads should be the one
// that ships, and a joined table is a different document to both of them.
//
// Returns the body it was given whenever it cannot do better, so the failure mode of this stage
// is the output the pipeline had before it existed.
const body = await joinContinuedTables(ctx, joinedPages);
const html = wrapDocument(body, opts);
const lint = await runAxe(html);
// `lint_error` is logged because a gate that could not run has to be distinguishable
// from one that found nothing โ and for a while this line was the only thing that made
// it so, because `runAxe` reported the environment failure as `ok: true, violations: []`
// and the two readings of `lint_ok: true` came apart here. They no longer do: a lint that
// threw is `lint_ok: false` with no `violations` figure at all (#164, see LintResult). The
// fields below are still logged, and are still the useful half โ WHICH failure it was, on
// a line an operator reads without opening the document.
//
// The failure is reachable two ways, neither theoretical. `anchors.ts` delivers a page too
// deeply nested to rewrite, its nesting reaches the linted document, and axe overflows on
// it from a few thousand levels โ precisely the document whose delivered-as-written page
// may still carry the duplicate ids the join could not fix. And a single attribute name
// that begins with a digit anywhere in the document makes jsdom's selector engine emit
// JavaScript it cannot compile (see runAxe), which is what #144 and #164 hit on real
// output. Same disclosure argument as `pinned_ids` below.
//
// The message alone turned out not to be enough. The first real occurrence (#144) read
// "Octal escape sequences are not allowed in strict mode" โ a JavaScript SyntaxError,
// which is neither the overflow above nor anything anyone could reproduce from that
// sentence โ so `runAxe` now also reports which step threw, its error class and the
// first frames of its stack, and all three are logged here. The document that provoked
// it is recoverable too, without keeping a second copy of it: this lints
// `wrapDocument(assembleBody(fragments))`, both of which are pure, and
// `fragments.json` is written before this phase runs.
ctx.log.event("assembly", {
pages: fragments.length,
lint_ok: lint.ok,
// Omitted, not zeroed, when the lint did not run: the count of violations in a check
// that did not happen is unknown. `violations: 0` here was the specific value #164 was
// filed about โ read beside `lint_ok: true` it was a clean bill of health for a document
// axe had not looked at, and anything tallying these lines summed it as a real zero.
// Omission follows the convention the `lint_error*` fields above already use: a field
// that has nothing to say is absent, so a field that is present means something.
...(lint.violations ? { violations: lint.violations.length } : {}),
...lintErrorFields(lint),
// Attributes whose names no valid markup produces, taken out of the lint's copy of the document
// so that two of them cannot take the check offline for all of it (#257, see runAxe). Reported
// because the leak that makes them is otherwise invisible: the same JSON escaping that turns
// `aria-label=\"Page iii\"` into an attribute called `iii\"` also puts `\"doc-pagebreak\"` in the
// marker's `role` and `\"page-iii\"` in its `id`, so this number on an ordinary run's line is the
// earliest symptom of a class of defect that took #233 and #234 to find by reading documents.
...lintDebrisFields(lint),
});
// Logged only when the join actually had to do something, so the ordinary run adds
// no line. `ambiguous` is the one that matters to a human: a reference naming an id
// that two pages claimed is repointed at the first of them, which is what the
// un-namespaced document resolved it to, but no page vouches for that being the
// copy it meant โ worth an eye, and without this line there is no symptom at all.
// `skipped_pages` means a page was left exactly as written rather than risk losing
// markup on reserialization, so it may still carry a collision (lint's
// `duplicate-id` / `duplicate-id-active` names that) or a reference that others
// renamed away from. `pinned_ids` is the same kind of disclosure one level down: those
// ids collided and their FIRST owner was left bare on purpose, so that a reference
// frozen on an unrewritable page keeps resolving. Without it, `collisions` would claim
// an id was namespaced when it deliberately was not.
// Logged only when something was removed, like `assembly_anchors`. It is worth a line
// rather than being silent: this is the prompt's FOOTNOTES rule not being followed, and
// the log is the only place that fact survives โ the delivered document is clean and the
// lint that would have named the role now finds nothing. `roles` is the set and `nodes`
// the count, which is what `aria-deprecated-role` would have reported.
if (deprecatedRoles.nodes > 0) {
ctx.log.event("deprecated_roles_stripped", {
stage: "assembly",
roles: [...new Set(deprecatedRoles.stripped)].sort(),
nodes: deprecatedRoles.nodes,
});
}
// The same convention for a role that is not a role (roles.ts, #345), and the same reason to
// spend a line on it: the delivered document is clean, `aria-roles` finds nothing, and this is
// the only place the fact survives that a page agent invented a role name. Read it as harder
// evidence than the deprecated line beside it โ `doc-endnotes` is a real role reached for in the
// wrong place, and a name like `doc-footnotes` was never in any spec.
if (invalidRoles.nodes > 0) {
ctx.log.event("invalid_roles_stripped", {
stage: "assembly",
roles: [...new Set(invalidRoles.stripped)].sort(),
nodes: invalidRoles.nodes,
});
}
// Same convention again: logged only when a page had emitted one, so an ordinary run adds no
// line, and worth a line when it did because the delivered document is now clean and the gate
// that would have named it finds nothing. `declined` above zero is the case to read: an
// unclosed `<main>` was left in place, so the lint's `landmark-no-duplicate-main` should be
// reporting the document and this line says why it is. `dropped` is the opposite reading โ a
// stray `</main>` went, and no rule would have reported that one at all.
if (mains.unwrapped > 0 || mains.downgraded > 0 || mains.dropped > 0 || mains.declined > 0) {
ctx.log.event("page_main_stripped", {
stage: "assembly",
unwrapped: mains.unwrapped,
downgraded: mains.downgraded,
dropped: mains.dropped,
declined: mains.declined,
});
}
// Logged when at least one page turn looked like a sentence carrying on, joined or not โ not on
// every multi-page document, since `markers` alone says only that the pages were numbered. The
// declines are the half worth reading: `candidates` far above `joined` on a document means the
// rule is refusing work it could be doing, and each reason says which refusal it was. `markers`
// is here as the denominator, because "13 joins" means nothing without how many turns there were.
if (prose.candidates > 0) {
ctx.log.event("prose_joined", {
stage: "assembly",
markers: prose.markers,
candidates: prose.candidates,
joined: prose.joined,
unmarked: prose.unmarked,
word_splits: prose.wordSplits,
declined_interrupted: prose.declined.interrupted,
declined_not_continuing: prose.declined.notContinuing,
declined_page_gap: prose.declined.pageGap,
declined_no_cut: prose.declined.noCut,
declined_attrs_kept: prose.declined.attrsKept,
declined_lang_mismatch: prose.declined.langMismatch,
declined_as_written: prose.declined.asWritten,
declined_too_far: prose.declined.tooFar,
// Bounded and only when there were any, like the anchors line's lists: the words a page turn
// broke in two are the shape a human checks by eye, and a count of them cannot be checked
// against anything.
...(prose.wordSplitExamples.length ? { word_split_examples: prose.wordSplitExamples } : {}),
});
}
// Every document whose pages numbered themselves gets this line, not only the ones something was
// taken off โ which breaks the convention the strips above follow, for the reason `prose_joined`
// breaks it: `stripped: []` on a line that exists says this document's markers were checked and
// agreed, and no line at all says they could not be checked (too few, or no offset holding a
// majority). Without the denominators a round cannot tell those two apart, and would read every
// undecidable document as a clean one. `readable` is the population the check ran on, `systems` says
// what it derived, `off_mode` and `undecided` are the two things it saw and left.
if (markers.readable > 0) {
ctx.log.event("page_markers", {
stage: "assembly",
markers: markers.markers,
readable: markers.readable,
unreadable: markers.unreadable,
systems: markers.systems,
stripped: markers.stripped,
departures: markers.departures,
off_mode: markers.offMode,
undecided: markers.undecided,
unchecked: markers.unchecked,
});
}
if (anchors.collisions.length > 0 || anchors.ambiguous.length > 0) {
ctx.log.event("assembly_anchors", {
collisions: anchors.collisions,
pinned_ids: anchors.pinned_ids,
ambiguous: anchors.ambiguous.map((u) => `page ${u.page}: #${u.ref}`),
// The subset aimed at no owner at all, because every page claiming the id already
// links to its own copy (#233). Carried separately from `ambiguous` because the two ask
// different things of whoever reads this line: an ambiguous reference resolved
// somewhere and may well be right, while one of these is a link left bare โ the page
// that wrote it transcribed a marker whose note nothing in this document holds, which
// is a page worth looking at.
//
// "Left bare", not "dead": two shapes ship a bare copy of the id, and in both the link
// still resolves โ to a note that has its own marker, which is the defect, but not to
// nothing. One is an owner delivered as written (`skipped_pages`), which keeps every id
// it has; the other is a `pinned_ids` id, whose first owner is held bare on purpose so
// that a frozen reference elsewhere keeps finding it. Whether a reference lands is
// measured on the delivered bytes (`internal_links`), not here.
unrepointed: anchors.unrepointed.map((u) => `page ${u.page}: #${u.ref}`),
skipped_pages: anchors.skipped_pages,
});
}
return { html, body, lint };
}