@saga-a2ui/lit 0.0.0-stage → 0.7.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,746 @@
1
+ import {
2
+ closesWithParagraph,
3
+ describeContentFailure,
4
+ FAITHFUL_CARD_STYLING,
5
+ requestLinkOpen,
6
+ type BlockNode,
7
+ type ImageConsent,
8
+ type ImageContext,
9
+ type InlineNode,
10
+ type InterpretedCard,
11
+ type ReadResult,
12
+ type FaithfulCardReader,
13
+ type TableCell,
14
+ } from '@saga-a2ui/components/faithful-card';
15
+ import {
16
+ describeLinkOutcome,
17
+ type LinkOpenOutcome,
18
+ } from '@saga-a2ui/core/host-links';
19
+ import {
20
+ LitElement,
21
+ html,
22
+ nothing,
23
+ type PropertyValues,
24
+ type TemplateResult,
25
+ } from 'lit';
26
+ import { adoptStyles, type StyleRoot } from './adopt-styles.js';
27
+ import { blockedFragment } from './blocked-fragment.js';
28
+ import { requestContentView, type ContentView } from './content-view.js';
29
+
30
+ // Renders a FaithfulCard. The components package has already reduced the Markdown to a closed set
31
+ // of nodes; every case below is a fixed template with text bindings. Nothing here builds markup
32
+ // from text (no unsafeHTML, innerHTML or equivalent), and a link or image is used only when the
33
+ // view's trusted content policy permits it.
34
+ //
35
+ // Under Strict the element also owns two reader controls. Reveal/Hide source changes visibility
36
+ // only. Show/Hide images grants or revokes consent for this card in this view; the grant is bound
37
+ // to the element, so a content change, a view-wide revocation, Hide images and disconnection each
38
+ // retire it, and nothing holding the old grant can bring images back.
39
+ //
40
+ // Styling: the element uses the light DOM, so an application styles it with ordinary CSS. It emits
41
+ // the styling surface of `FAITHFUL_CARD_STYLING` (the committed class names, the nine custom
42
+ // properties, each with a neutral fallback, and the closing marker), exactly as the React card
43
+ // does; docs/faithful-card-styling.md is its documentation. Other attributes, such as the
44
+ // `data-testid` and `data-annotation` hooks, are not contract.
45
+
46
+ /** What one render pass needs; assembled once so the node templates stay small. */
47
+ interface Rendering {
48
+ readonly view: ContentView;
49
+ readonly strict: boolean;
50
+ /** Whether blocked fragments are shown as source text rather than placeholders. */
51
+ readonly revealed: boolean;
52
+ readonly images: ImageContext;
53
+ /** The content revision this render shows; every control it renders is bound to it (see `current`). */
54
+ readonly revision: Revision;
55
+ readonly counters: { link: number; image: number; table: number };
56
+ }
57
+
58
+ /**
59
+ * Identifies the rendered card revision a control belongs to: the element's lifetime counter (it
60
+ * advances on new content, disconnection and reconnection) and the supplied values themselves, so a
61
+ * control that outlives its render is recognisable even before the next update has run.
62
+ */
63
+ interface Revision {
64
+ readonly generation: number;
65
+ readonly contractVersion: unknown;
66
+ readonly markdown: unknown;
67
+ }
68
+
69
+ /** The supplied values read once by one reader: the card, or why it was refused. */
70
+ type Interpretation = {
71
+ readonly contractVersion: unknown;
72
+ readonly markdown: unknown;
73
+ readonly reader: FaithfulCardReader;
74
+ } & ReadResult;
75
+
76
+ const STYLE = html`<style>
77
+ saga-faithful-card-content {
78
+ display: block;
79
+ }
80
+ .saga-card {
81
+ display: block;
82
+ box-sizing: border-box;
83
+ max-inline-size: var(--saga-card-reading-width, 70ch);
84
+ font-family: var(--saga-card-font-family, inherit);
85
+ font-size: var(--saga-card-font-size, inherit);
86
+ line-height: var(--saga-card-line-height, inherit);
87
+ color: var(--saga-card-text-color, inherit);
88
+ overflow-wrap: anywhere;
89
+ }
90
+ .saga-card .saga-card-link {
91
+ font: inherit;
92
+ color: var(--saga-card-accent-color, inherit);
93
+ background: none;
94
+ border: 0;
95
+ padding: 0;
96
+ text-decoration: underline;
97
+ cursor: pointer;
98
+ }
99
+ .saga-card .saga-card-link-host {
100
+ font-size: 0.85em;
101
+ color: var(--saga-card-muted-color, inherit);
102
+ }
103
+ .saga-card img {
104
+ max-inline-size: 100%;
105
+ height: auto;
106
+ }
107
+ /* A list item pads on the side its own text starts from, so a right-to-left item inside a left-to-right list
108
+ keeps its marker inside the card instead of outside the page edge. */
109
+ .saga-card ul,
110
+ .saga-card ol {
111
+ padding-inline: 0;
112
+ }
113
+ .saga-card li {
114
+ margin-inline-start: 2.5em;
115
+ }
116
+ .saga-card blockquote {
117
+ margin-inline: 1em;
118
+ padding-inline-start: 0.75em;
119
+ border-inline-start: 3px solid
120
+ var(--saga-card-accent-color, var(--saga-card-border-color, #999));
121
+ }
122
+ .saga-card pre {
123
+ max-inline-size: 100%;
124
+ overflow-x: auto;
125
+ padding: 0.5em;
126
+ background: var(--saga-card-code-background, rgba(127, 127, 127, 0.15));
127
+ }
128
+ .saga-card code {
129
+ background: var(--saga-card-code-background, rgba(127, 127, 127, 0.15));
130
+ }
131
+ .saga-card pre code {
132
+ background: none;
133
+ }
134
+ .saga-card hr {
135
+ border: 0;
136
+ border-top: 1px solid var(--saga-card-border-color, #999);
137
+ }
138
+ .saga-card .saga-card-table-scroll {
139
+ max-inline-size: 100%;
140
+ overflow-x: auto;
141
+ margin-block: 0.5em;
142
+ }
143
+ .saga-card table {
144
+ border-collapse: collapse;
145
+ overflow-wrap: normal;
146
+ }
147
+ .saga-card th,
148
+ .saga-card td {
149
+ border: 1px solid var(--saga-card-border-color, #999);
150
+ padding: 0.25em 0.5em;
151
+ text-align: start;
152
+ }
153
+ .saga-card [data-align='left'] {
154
+ text-align: left;
155
+ }
156
+ .saga-card [data-align='center'] {
157
+ text-align: center;
158
+ }
159
+ .saga-card [data-align='right'] {
160
+ text-align: right;
161
+ }
162
+ .saga-card .saga-card-source {
163
+ white-space: pre-wrap;
164
+ overflow-wrap: anywhere;
165
+ }
166
+ .saga-card [data-annotation]:not([data-annotation='link-host']),
167
+ .saga-card .saga-card-placeholder {
168
+ font-style: italic;
169
+ }
170
+ .saga-card .saga-card-controls {
171
+ display: flex;
172
+ flex-wrap: wrap;
173
+ align-items: center;
174
+ gap: 0.5em;
175
+ margin-block: 0.5em;
176
+ }
177
+ </style>`;
178
+
179
+ /**
180
+ * The FaithfulCard Lit element. It shows the supplied Markdown under the enclosing view's trusted
181
+ * reader and policy. Supplied values are untrusted: the view's reader validates them and the card
182
+ * shows an explicit failure, with nothing of any earlier content, when they are refused.
183
+ */
184
+ export class FaithfulCardContentElement extends LitElement {
185
+ // Reactive properties are declared with `static properties` and `declare`, and set in the
186
+ // constructor, not with decorators and field initializers: this package ships TypeScript source
187
+ // that each application compiles with its own settings, and those constructs are only correct
188
+ // under the settings the workspace happens to use.
189
+ static override properties = {
190
+ contractVersion: { attribute: false },
191
+ markdown: { attribute: false },
192
+ linkOutcomes: { state: true },
193
+ failedImages: { state: true },
194
+ sourceRevealed: { state: true },
195
+ consent: { state: true },
196
+ consentEnded: { state: true },
197
+ };
198
+
199
+ /** The contract version of the content, as supplied. Validated by the view's reader. */
200
+ declare contractVersion: unknown;
201
+ /** The Markdown to show, exactly as supplied. Validated by the view's reader. */
202
+ declare markdown: unknown;
203
+
204
+ declare private linkOutcomes: ReadonlyMap<number, LinkOpenOutcome>;
205
+ declare private failedImages: ReadonlySet<number>;
206
+ declare private sourceRevealed: boolean;
207
+ /** The reader's current consent to load images. Absent until Show images, and after any revocation. */
208
+ declare private consent?: ImageConsent;
209
+ /** True once the view has retired image consent for good (host teardown). No control can grant after it. */
210
+ declare private consentEnded: boolean;
211
+
212
+ constructor() {
213
+ super();
214
+ this.contractVersion = undefined;
215
+ this.markdown = undefined;
216
+ this.linkOutcomes = new Map();
217
+ this.failedImages = new Set();
218
+ this.sourceRevealed = false;
219
+ this.consentEnded = false;
220
+ }
221
+
222
+ private view?: ContentView;
223
+ /** Gives back the application's styles this card adopted into its root; see `adopt-styles.ts`. */
224
+ private releaseStyles?: () => void;
225
+ private interpreted?: Interpretation;
226
+ private revocationWatch?: { unsubscribe(): void };
227
+ /** Retires link results that arrive after the content changed or the element was removed. */
228
+ private generation = 0;
229
+ /** Retires image load results that arrive after consent ended or the content changed. */
230
+ private imageEpoch = 0;
231
+
232
+ protected override createRenderRoot(): HTMLElement {
233
+ return this;
234
+ }
235
+
236
+ override connectedCallback(): void {
237
+ super.connectedCallback();
238
+ this.view = requestContentView(this);
239
+ this.releaseStyles = adoptStyles(
240
+ this.getRootNode() as unknown as StyleRoot,
241
+ this.view?.styles ?? [],
242
+ );
243
+ this.consentEnded = this.view?.consent.retired === true;
244
+ this.revocationWatch = this.view?.consent.subscribe(() => {
245
+ this.retireImages();
246
+ this.consentEnded = this.view?.consent.retired === true;
247
+ });
248
+ // A reconnected card starts fresh, like new content: the policy's own presentation, no link
249
+ // results from before, and a render against the view it has now. Disconnecting rendered it
250
+ // without one, and nothing else would ask for another render.
251
+ this.generation += 1;
252
+ this.linkOutcomes = new Map();
253
+ this.sourceRevealed =
254
+ this.view?.policy.blockedPresentation === 'inert-text';
255
+ this.requestUpdate();
256
+ }
257
+
258
+ override disconnectedCallback(): void {
259
+ super.disconnectedCallback();
260
+ this.generation += 1;
261
+ this.revocationWatch?.unsubscribe();
262
+ this.revocationWatch = undefined;
263
+ this.releaseStyles?.();
264
+ this.releaseStyles = undefined;
265
+ this.retireImages();
266
+ this.view = undefined;
267
+ this.interpreted = undefined;
268
+ }
269
+
270
+ protected override willUpdate(changed: PropertyValues<this>): void {
271
+ if (changed.has('contractVersion') || changed.has('markdown')) {
272
+ this.generation += 1;
273
+ this.linkOutcomes = new Map();
274
+ // New content starts from the policy's own presentation and needs fresh consent.
275
+ this.sourceRevealed =
276
+ this.view?.policy.blockedPresentation === 'inert-text';
277
+ this.retireImages();
278
+ }
279
+ }
280
+
281
+ /** Ends the current consent and forgets image results that belong to it. Idempotent. */
282
+ private retireImages(): void {
283
+ this.imageEpoch += 1;
284
+ this.consent?.revoke();
285
+ this.consent = undefined;
286
+ if (this.failedImages.size > 0) this.failedImages = new Set();
287
+ }
288
+
289
+ /** Reads the current content once per reader, with the blockable fragments it references. */
290
+ private interpretation(view: ContentView): Interpretation {
291
+ const cached = this.interpreted;
292
+ if (
293
+ cached?.reader === view.reader &&
294
+ cached.contractVersion === this.contractVersion &&
295
+ cached.markdown === this.markdown
296
+ )
297
+ return cached;
298
+ const { contractVersion, markdown } = this;
299
+ this.interpreted = {
300
+ contractVersion,
301
+ markdown,
302
+ reader: view.reader,
303
+ ...view.reader.read({ contractVersion, markdown }),
304
+ };
305
+ return this.interpreted;
306
+ }
307
+
308
+ private imageContext(view: ContentView): ImageContext {
309
+ return {
310
+ enforcement: view.imageEnforcement,
311
+ consent: this.consent?.live === true ? 'granted' : 'not-granted',
312
+ };
313
+ }
314
+
315
+ private failure(reason: string, message: TemplateResult): TemplateResult {
316
+ return html`${STYLE}
317
+ <p
318
+ class="saga-card saga-card-failure"
319
+ role="alert"
320
+ data-testid="card-failure"
321
+ data-reason=${reason}
322
+ >
323
+ ${message}
324
+ </p>`;
325
+ }
326
+
327
+ protected override render(): TemplateResult {
328
+ const view = this.view;
329
+ // Without a view that supplies the trusted reader and policy, nothing is interpreted or shown.
330
+ if (view === undefined)
331
+ return this.failure(
332
+ 'no-view',
333
+ html`This content cannot be shown: no content view is available.`,
334
+ );
335
+ const interpretation = this.interpretation(view);
336
+ if (!interpretation.ok) {
337
+ const { failure } = interpretation;
338
+ return this.failure(
339
+ failure.kind,
340
+ html`${describeContentFailure(failure)}`,
341
+ );
342
+ }
343
+ const { card } = interpretation;
344
+ const rendering: Rendering = {
345
+ view,
346
+ strict: view.policy.level === 'strict',
347
+ revealed: this.sourceRevealed,
348
+ images: this.imageContext(view),
349
+ revision: this.revisionNow(),
350
+ counters: { link: 0, image: 0, table: 0 },
351
+ };
352
+ const closing = closesWithParagraph(card.blocks)
353
+ ? card.blocks.length - 1
354
+ : -1;
355
+ return html`${STYLE}
356
+ <div class="saga-card" data-testid="card-root">
357
+ ${rendering.strict ? this.controls(view, card) : nothing}
358
+ <div class="saga-card-content" data-testid="card-content">
359
+ ${card.blocks.map((block, index) =>
360
+ this.block(block, rendering, false, index === closing),
361
+ )}
362
+ </div>
363
+ </div>`;
364
+ }
365
+
366
+ /** Strict only. Both buttons keep one template, so focus stays on the control the reader used. */
367
+ private controls(view: ContentView, card: InterpretedCard): TemplateResult {
368
+ const { fragments } = card;
369
+ const blockable =
370
+ fragments.links + fragments.images.length + fragments.rawHtml > 0;
371
+ const granted = this.consent?.live === true;
372
+ const offer = view.policy.offerImages(
373
+ fragments.images,
374
+ view.imageEnforcement,
375
+ );
376
+ const unavailableReason =
377
+ !granted && !offer.available ? offer.reason : undefined;
378
+ const ended = !granted && this.consentEnded;
379
+ // The controls belong to the content revision they were rendered for; see `current`.
380
+ const revision = this.revisionNow();
381
+ return html`<div class="saga-card-controls" data-testid="card-controls">
382
+ ${
383
+ blockable
384
+ ? html`<button
385
+ type="button"
386
+ data-testid="source-toggle"
387
+ @click=${() => {
388
+ if (this.current(view, revision))
389
+ this.sourceRevealed = !this.sourceRevealed;
390
+ }}
391
+ >
392
+ ${this.sourceRevealed ? 'Hide source' : 'Reveal source'}
393
+ </button>`
394
+ : nothing
395
+ }
396
+ ${
397
+ fragments.images.length === 0
398
+ ? nothing
399
+ : html`<button
400
+ type="button"
401
+ data-testid="images-toggle"
402
+ ?disabled=${unavailableReason !== undefined || ended}
403
+ @click=${() => {
404
+ if (!this.current(view, revision)) return;
405
+ if (granted) this.retireImages();
406
+ else this.showImages(view);
407
+ }}
408
+ >
409
+ ${granted ? 'Hide images' : 'Show images'}
410
+ </button>
411
+ <span role="status" data-testid="images-status"
412
+ >${granted ? 'Images shown.' : nothing}</span
413
+ >
414
+ ${
415
+ ended
416
+ ? html`<span
417
+ data-testid="images-unavailable"
418
+ data-reason="view-ended"
419
+ >Images can no longer be shown: this view has ended.</span
420
+ >`
421
+ : nothing
422
+ }
423
+ ${
424
+ unavailableReason === undefined
425
+ ? nothing
426
+ : html`<span
427
+ data-testid="images-unavailable"
428
+ data-reason=${unavailableReason}
429
+ >Images in this content cannot be shown: none is permitted
430
+ by the content policy or this host.</span
431
+ >`
432
+ }`
433
+ }
434
+ </div>`;
435
+ }
436
+
437
+ /**
438
+ * Whether a control rendered for `view` at content revision `revision` still belongs to this
439
+ * element's current state. A control the reader (or old code) kept hold of after the content
440
+ * changed, or after the element was disconnected, is stale and must do nothing: consent is for
441
+ * the content the reader was shown, not whatever is current when it fires.
442
+ */
443
+ private current(view: ContentView, revision: Revision): boolean {
444
+ return (
445
+ this.view === view &&
446
+ this.generation === revision.generation &&
447
+ this.contractVersion === revision.contractVersion &&
448
+ this.markdown === revision.markdown
449
+ );
450
+ }
451
+
452
+ private revisionNow(): Revision {
453
+ return {
454
+ generation: this.generation,
455
+ contractVersion: this.contractVersion,
456
+ markdown: this.markdown,
457
+ };
458
+ }
459
+
460
+ /** Records consent for the current content. Callers have already checked the control is current. */
461
+ private showImages(view: ContentView): void {
462
+ const interpretation = this.interpretation(view);
463
+ if (this.view !== view || !interpretation.ok) return;
464
+ if (
465
+ !view.policy.offerImages(
466
+ interpretation.card.fragments.images,
467
+ view.imageEnforcement,
468
+ ).available
469
+ )
470
+ return;
471
+ this.imageEpoch += 1;
472
+ this.consent?.revoke();
473
+ const grant = view.consent.grant();
474
+ if (grant.live) this.consent = grant; // a retired view hands out only dead grants
475
+ }
476
+
477
+ private block(
478
+ node: BlockNode,
479
+ r: Rendering,
480
+ inContainer = false,
481
+ closing = false,
482
+ ): TemplateResult {
483
+ // A quote or list item sets the direction its own marker or rule follows from its text, and its
484
+ // paragraphs inherit it: automatic direction ignores any descendant that sets its own.
485
+ const dir = inContainer ? nothing : 'auto';
486
+ const marker = closing ? FAITHFUL_CARD_STYLING.closing.value : nothing;
487
+ switch (node.kind) {
488
+ case 'paragraph':
489
+ // Only the card's final top-level block can be closing; see `FAITHFUL_CARD_STYLING.closing`.
490
+ // Whitespace added inside the element would become part of its text, so it is not reformatted.
491
+ // prettier-ignore
492
+ return html`<p dir=${dir} data-saga-closing=${marker}>${this.inlines(node.children, r)}</p>`;
493
+ case 'heading': {
494
+ // The surrounding page owns the top levels; card headings start at h3.
495
+ const content = this.inlines(node.children, r);
496
+ if (node.level === 1) return html`<h3 dir=${dir}>${content}</h3>`;
497
+ if (node.level === 2) return html`<h4 dir=${dir}>${content}</h4>`;
498
+ if (node.level === 3) return html`<h5 dir=${dir}>${content}</h5>`;
499
+ return html`<h6 dir=${dir}>${content}</h6>`;
500
+ }
501
+ case 'quote':
502
+ return html`<blockquote dir="auto">
503
+ ${node.children.map((child) => this.block(child, r, true))}
504
+ </blockquote>`;
505
+ case 'list': {
506
+ const items = node.items.map(
507
+ (item) =>
508
+ html`<li dir="auto">
509
+ ${item.map((child) => this.block(child, r, true))}
510
+ </li>`,
511
+ );
512
+ return node.ordered
513
+ ? html`<ol start=${node.start}>
514
+ ${items}
515
+ </ol>`
516
+ : html`<ul>
517
+ ${items}
518
+ </ul>`;
519
+ }
520
+ case 'table':
521
+ return this.table(node, r);
522
+ case 'code-block':
523
+ return html`<pre dir="auto"><code>${node.text}</code></pre>`;
524
+ case 'raw-html-block':
525
+ return r.strict
526
+ ? blockedFragment({
527
+ kind: 'markup',
528
+ testId: 'md-markup',
529
+ reason: 'raw-html',
530
+ revealed: r.revealed,
531
+ placeholder: '[Markup blocked]',
532
+ source: () => html`${node.text}`,
533
+ block: true,
534
+ })
535
+ : html`<p class="saga-card-source" dir=${dir}>${node.text}</p>`;
536
+ case 'rule':
537
+ return html`<hr />`;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * A table scrolls inside a keyboard-focusable region so a narrow screen never forces the page
543
+ * to scroll sideways. Each cell keeps its own inline content, in source order.
544
+ */
545
+ private table(
546
+ node: Extract<BlockNode, { kind: 'table' }>,
547
+ r: Rendering,
548
+ ): TemplateResult {
549
+ const ordinal = ++r.counters.table;
550
+ const cell = (tag: 'th' | 'td', one: TableCell) => {
551
+ const content = this.inlines(one.children, r);
552
+ return tag === 'th'
553
+ ? html`<th scope="col" dir="auto" data-align=${one.align ?? nothing}>
554
+ ${content}
555
+ </th>`
556
+ : html`<td dir="auto" data-align=${one.align ?? nothing}>
557
+ ${content}
558
+ </td>`;
559
+ };
560
+ return html`<div
561
+ class="saga-card-table-scroll"
562
+ role="region"
563
+ tabindex="0"
564
+ aria-label=${`Table ${ordinal}`}
565
+ data-testid="md-table"
566
+ >
567
+ <table>
568
+ <thead>
569
+ <tr>
570
+ ${node.header.map((one) => cell('th', one))}
571
+ </tr>
572
+ </thead>
573
+ <tbody>
574
+ ${node.rows.map(
575
+ (row) =>
576
+ html`<tr>
577
+ ${row.map((one) => cell('td', one))}
578
+ </tr>`,
579
+ )}
580
+ </tbody>
581
+ </table>
582
+ </div>`;
583
+ }
584
+
585
+ private inlines(
586
+ nodes: readonly InlineNode[],
587
+ r: Rendering,
588
+ ): TemplateResult[] {
589
+ return nodes.map((node) => this.inline(node, r));
590
+ }
591
+
592
+ private inline(node: InlineNode, r: Rendering): TemplateResult {
593
+ switch (node.kind) {
594
+ case 'text':
595
+ return html`${node.text}`;
596
+ case 'code':
597
+ return html`<code>${node.text}</code>`;
598
+ case 'emphasis':
599
+ return html`<em>${this.inlines(node.children, r)}</em>`;
600
+ case 'strong':
601
+ return html`<strong>${this.inlines(node.children, r)}</strong>`;
602
+ case 'strikethrough':
603
+ return html`<s>${this.inlines(node.children, r)}</s>`;
604
+ case 'softbreak':
605
+ return html`${'\n'}`;
606
+ case 'hardbreak':
607
+ return html`<br />`;
608
+ case 'raw-html':
609
+ return r.strict
610
+ ? blockedFragment({
611
+ kind: 'markup',
612
+ testId: 'md-markup',
613
+ reason: 'raw-html',
614
+ revealed: r.revealed,
615
+ placeholder: '[Markup blocked]',
616
+ source: () => html`${node.text}`,
617
+ })
618
+ : html`${node.text}`;
619
+ case 'link':
620
+ return this.link(node, r);
621
+ case 'image':
622
+ return this.image(node, r);
623
+ }
624
+ }
625
+
626
+ private link(
627
+ node: Extract<InlineNode, { kind: 'link' }>,
628
+ r: Rendering,
629
+ ): TemplateResult {
630
+ const ordinal = r.counters.link++;
631
+ const label = this.inlines(node.children, r);
632
+ const decision = r.view.policy.decideLink(node.destination);
633
+ if (!decision.allowed) {
634
+ if (r.strict) {
635
+ // Strict never activates a link, even an otherwise valid one and even after image consent.
636
+ return blockedFragment({
637
+ kind: 'link',
638
+ testId: 'md-link-inert',
639
+ reason: decision.reason,
640
+ revealed: r.revealed,
641
+ placeholder: '[Link blocked]',
642
+ label: html`${label}`,
643
+ source: (text) => html`[${text}](${node.destination})`,
644
+ });
645
+ }
646
+ // Not activatable: the text and its destination are shown, and nothing can be invoked.
647
+ return html`<span
648
+ class="saga-card-link-inert"
649
+ data-testid="md-link-inert"
650
+ data-reason=${decision.reason}
651
+ >${label}
652
+ <span class="saga-card-link-destination"
653
+ >(${node.destination})</span
654
+ ></span
655
+ >`;
656
+ }
657
+ const outcome = this.linkOutcomes.get(ordinal);
658
+ // The hostname is inside the button, so it is both visible and part of the accessible name.
659
+ return html`<button
660
+ type="button"
661
+ class="saga-card-link"
662
+ data-testid="md-link"
663
+ @click=${() => void this.activate(ordinal, r.view, r.revision, decision.url)}
664
+ >
665
+ ${label}
666
+ <span
667
+ class="saga-card-link-host"
668
+ data-testid="md-link-host"
669
+ data-annotation="link-host"
670
+ >(${decision.hostname})</span
671
+ ></button
672
+ ><span
673
+ class="saga-card-link-status"
674
+ role="status"
675
+ data-testid="md-link-status"
676
+ data-outcome=${outcome ?? nothing}
677
+ >${outcome === undefined ? nothing : this.feedback(outcome, decision.url)}</span
678
+ >`;
679
+ }
680
+
681
+ /** A link that was not opened stays readable: its destination is shown as inert text. */
682
+ private feedback(outcome: LinkOpenOutcome, url: string): TemplateResult {
683
+ return html`${describeLinkOutcome(outcome, url)}`;
684
+ }
685
+
686
+ /**
687
+ * Asks the host to open a link, but only for the card revision the button was rendered for: a
688
+ * button kept after the content changed or the card was removed asks nothing, and an answer that
689
+ * arrives after that is not shown.
690
+ */
691
+ private async activate(
692
+ ordinal: number,
693
+ view: ContentView,
694
+ revision: Revision,
695
+ url: string,
696
+ ): Promise<void> {
697
+ if (!this.current(view, revision)) return;
698
+ const outcome = await requestLinkOpen(view.hostLinks, url);
699
+ if (!this.current(view, revision)) return;
700
+ this.linkOutcomes = new Map(this.linkOutcomes).set(ordinal, outcome);
701
+ }
702
+
703
+ private image(
704
+ node: Extract<InlineNode, { kind: 'image' }>,
705
+ r: Rendering,
706
+ ): TemplateResult {
707
+ const ordinal = r.counters.image++;
708
+ const decision = r.view.policy.decideImage(node.source, r.images);
709
+ if (decision.allowed && !this.failedImages.has(ordinal)) {
710
+ const epoch = this.imageEpoch;
711
+ // Under Strict the reader has just asked for images, so they load now rather than when scrolled near.
712
+ return html`<img
713
+ data-testid="md-image"
714
+ src=${decision.url}
715
+ alt=${node.alt}
716
+ loading=${r.strict ? 'eager' : 'lazy'}
717
+ decoding="async"
718
+ referrerpolicy="no-referrer"
719
+ @error=${() => {
720
+ // A result from before consent ended or the content changed is not this image's.
721
+ if (epoch === this.imageEpoch)
722
+ this.failedImages = new Set(this.failedImages).add(ordinal);
723
+ }}
724
+ />`;
725
+ }
726
+ const reason = decision.allowed ? 'load-failed' : decision.reason;
727
+ const alt = node.alt === '' ? 'no description' : node.alt;
728
+ if (r.strict) {
729
+ return blockedFragment({
730
+ kind: 'image',
731
+ testId: 'md-image-blocked',
732
+ reason,
733
+ revealed: r.revealed,
734
+ placeholder: `[Image blocked: ${alt}]`,
735
+ source: () => html`![${node.alt}](${node.source})`,
736
+ });
737
+ }
738
+ return html`<span
739
+ class="saga-card-placeholder"
740
+ data-testid="md-image-blocked"
741
+ data-reason=${reason}
742
+ data-annotation="image-not-loaded"
743
+ >[Image not loaded: ${alt}]</span
744
+ >`;
745
+ }
746
+ }