@gjsify/adwaita-web 0.62.0 → 0.63.0

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.
@@ -5,6 +5,8 @@
5
5
  // (gtkwindow.c:1274-1287).
6
6
  import { describe, expect, it } from '@gjsify/unit';
7
7
 
8
+ import { driveWindowVectors, WINDOW_VECTORS, type WindowSubject } from '@gjsify/adwaita-core/conformance';
9
+
8
10
  import type { GtkWindow } from './elements/gtk-window.js';
9
11
 
10
12
  function mount(attrs: Record<string, string> = {}): { el: GtkWindow; host: HTMLElement } {
@@ -199,4 +201,124 @@ export const GtkWindowTest = async () => {
199
201
  host.remove();
200
202
  });
201
203
  });
204
+
205
+ // The element's native close contract is `preventDefault()`; a GTK handler "returns true". The
206
+ // adapter below is that mapping, and it also stops later listeners, which is what the handled
207
+ // accumulator does. So the "third never runs" row exercises the adapter plus the element's
208
+ // sequence, not DOM dispatch alone.
209
+ const windows = new Map<object, HTMLElement>();
210
+ const webSubject: WindowSubject = {
211
+ name: '<gtk-window>',
212
+ isOracle: false,
213
+ capabilities: { maximizable: true, observesFocus: true, hideOnClose: true },
214
+ create({ hideOnClose }) {
215
+ // Never in a page: not realized, which is GTK's state for a window before `present()`.
216
+ const el = document.createElement('gtk-window') as GtkWindow;
217
+ if (hideOnClose) el.setAttribute('hide-on-close', 'true');
218
+ el.append(document.createElement('button'), document.createElement('button'));
219
+ return el;
220
+ },
221
+ show(w) {
222
+ const host = document.createElement('div');
223
+ document.body.appendChild(host);
224
+ host.appendChild(w as GtkWindow);
225
+ windows.set(w, host);
226
+ (w as GtkWindow).present();
227
+ },
228
+ connect(w, signal, handler) {
229
+ (w as GtkWindow).addEventListener(signal, (event) => {
230
+ if (signal === 'close-request') {
231
+ if (event.defaultPrevented) return;
232
+ if (handler()) {
233
+ event.preventDefault();
234
+ event.stopImmediatePropagation();
235
+ }
236
+ return;
237
+ }
238
+ handler();
239
+ });
240
+ return 1;
241
+ },
242
+ close: (w) => void (w as GtkWindow).close(),
243
+ maximize: (w) => (w as GtkWindow).maximize(),
244
+ unmaximize: (w) => (w as GtkWindow).unmaximize(),
245
+ isMaximized: (w) => (w as GtkWindow).is_maximized(),
246
+ isVisible: (w) => (w as GtkWindow).isConnected && !(w as GtkWindow).hidden,
247
+ focusables: (w) => [...(w as GtkWindow).querySelectorAll('button')] as unknown as [Element, Element],
248
+ setFocus: (w, widget) => (w as GtkWindow).set_focus(widget as Element | null),
249
+ getFocus: (w) => (w as GtkWindow).get_focus(),
250
+ };
251
+ await driveWindowVectors(webSubject, { describe, it, expect }, WINDOW_VECTORS);
252
+ for (const host of windows.values()) host.remove();
253
+
254
+ await describe('<gtk-window> state members', async () => {
255
+ await it('a frame that was never connected is not realized: close() does nothing', async () => {
256
+ const el = document.createElement('gtk-window') as GtkWindow;
257
+ let seen = 0;
258
+ el.addEventListener('close-request', () => void seen++);
259
+ expect(el.close()).toBe(false);
260
+ expect(seen).toBe(0);
261
+ });
262
+
263
+ await it('is-active follows the document focus and notifies only on change', async () => {
264
+ const { el, host } = mount();
265
+ const seen: boolean[] = [];
266
+ el.addEventListener('notify::is-active', (e) => seen.push((e as CustomEvent).detail['is-active']));
267
+ const before = el.isActive;
268
+ expect(before).toBe(document.hasFocus());
269
+ el.hidden = true;
270
+ expect(el.is_active()).toBe(false);
271
+ el.present();
272
+ expect(el.isActive).toBe(document.hasFocus());
273
+ // Every transition above is one notification at most, and none repeats a value.
274
+ for (let i = 1; i < seen.length; i++) expect(seen[i]).not.toBe(seen[i - 1]);
275
+ host.remove();
276
+ expect(el.isActive).toBe(false);
277
+ });
278
+
279
+ await it('is-active has no setter: GTK declares it read-only', async () => {
280
+ const { el, host } = mount();
281
+ const descriptor =
282
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el.constructor.prototype), 'isActive') ??
283
+ Object.getOwnPropertyDescriptor(el.constructor.prototype, 'isActive');
284
+ expect(descriptor?.set).toBe(undefined);
285
+ host.remove();
286
+ });
287
+
288
+ await it('set_focus refuses an element outside the frame, by name', async () => {
289
+ const { el, host } = mount();
290
+ const outside = document.createElement('button');
291
+ document.body.append(outside);
292
+ let message = '';
293
+ try {
294
+ el.set_focus(outside);
295
+ } catch (error) {
296
+ message = (error as Error).message;
297
+ }
298
+ expect(message.includes('set_focus')).toBe(true);
299
+ outside.remove();
300
+ host.remove();
301
+ });
302
+
303
+ await it('focus-widget notifies with the widget in its detail', async () => {
304
+ const { el, host } = mount();
305
+ const button = document.createElement('button');
306
+ el.append(button);
307
+ await Promise.resolve();
308
+ let detail: unknown;
309
+ el.addEventListener('notify::focus-widget', (e) => (detail = (e as CustomEvent).detail['focus-widget']));
310
+ el.set_focus(button);
311
+ expect(detail).toBe(button);
312
+ expect(el.focusWidget).toBe(button);
313
+ host.remove();
314
+ });
315
+
316
+ await it('is_maximized() reads the frame state', async () => {
317
+ const { el, host } = mount();
318
+ expect(el.is_maximized()).toBe(false);
319
+ el.maximize();
320
+ expect(el.is_maximized()).toBe(true);
321
+ host.remove();
322
+ });
323
+ });
202
324
  };
@@ -0,0 +1,257 @@
1
+ // `renderPangoMarkup` — markup becomes NODES, and a string the browser never parses.
2
+ //
3
+ // The rows that matter here are the ones that used to be `innerHTML`: a `<b>` has to come
4
+ // out as an ELEMENT (that is the whole feature — the reviewer who flagged the innerHTML
5
+ // was right about the sink and wrong that the only alternative is plain text), and an
6
+ // `<img src=x onerror=…>` or a `<script>` has to come out as NOTHING an element could
7
+ // carry. Both run through the real elements where they can, because a helper tested alone
8
+ // only proves the helper.
9
+
10
+ import { describe, expect, it } from '@gjsify/unit';
11
+
12
+ import { renderPangoMarkup } from './pango-markup.js';
13
+ import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
14
+ import type { AdwBanner } from './elements/adw-banner.js';
15
+
16
+ /** Render markup into a detached target and hand both back. */
17
+ function render(markup: string): { target: HTMLElement; text: string } {
18
+ const target = document.createElement('span');
19
+ document.body.appendChild(target);
20
+ renderPangoMarkup(target, markup);
21
+ return { target, text: target.textContent ?? '' };
22
+ }
23
+
24
+ /** Computed `font-weight`, which is `bold` or `700` depending on the engine. */
25
+ function isBold(el: Element): boolean {
26
+ const weight = getComputedStyle(el).fontWeight;
27
+ return weight === 'bold' || Number.parseInt(weight, 10) >= 600;
28
+ }
29
+
30
+ function mount<T extends HTMLElement>(tag: string, attributes: Record<string, string>): { el: T; host: HTMLElement } {
31
+ const host = document.createElement('div');
32
+ document.body.appendChild(host);
33
+ const el = document.createElement(tag) as T;
34
+ for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value);
35
+ host.appendChild(el);
36
+ return { el, host };
37
+ }
38
+
39
+ const bannerTitle = (el: AdwBanner): HTMLElement => el.querySelector('.adw-banner-title') as HTMLElement;
40
+ const alertHeading = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-heading') as HTMLElement;
41
+ const alertBody = (el: AdwAlertDialog): HTMLElement => el.querySelector('.adw-alert-dialog-body') as HTMLElement;
42
+
43
+ export const PangoMarkupTest = async () => {
44
+ await describe('renderPangoMarkup — <b> is drawn bold, because the tree says so', async () => {
45
+ await it('<b>x</b> becomes a <b> ELEMENT, and it computes bold', () => {
46
+ const { target } = render('<b>x</b>');
47
+ const bold = target.querySelector('b');
48
+ expect(bold).not.toBe(null);
49
+ expect(bold?.textContent).toBe('x');
50
+ expect(isBold(bold!)).toBe(true);
51
+ target.remove();
52
+ });
53
+
54
+ // The four tags with no presentational UA rule, each spelled out as a style — the
55
+ // rows a "map the tag name to the same HTML element" port silently gets wrong,
56
+ // because <tt>, <big> and <small> exist in the HTML namespace and mean nothing.
57
+ await it('tt, big and small draw through styles, not through obsolete elements', () => {
58
+ const { target } = render('<tt>code</tt><big>big</big><small>small</small>');
59
+ const tt = target.querySelector('span')!;
60
+ expect(getComputedStyle(tt).fontFamily.toLowerCase()).toContain('monospace');
61
+ const big = target.children[1] as HTMLElement;
62
+ const small = target.children[2] as HTMLElement;
63
+ expect(Number.parseFloat(getComputedStyle(big).fontSize)).toBeGreaterThan(
64
+ Number.parseFloat(getComputedStyle(small).fontSize),
65
+ );
66
+ expect(big.tagName).toBe('SPAN');
67
+ expect(small.tagName).toBe('SPAN');
68
+ });
69
+
70
+ await it('the remaining tags become their own elements, nested as written', () => {
71
+ const { target } = render('<i>a<u>b</u></i><s>c</s><sub>d</sub><sup>e</sup>');
72
+ expect([...target.children].map((el) => el.tagName)).toStrictEqual(['I', 'S', 'SUB', 'SUP']);
73
+ expect(target.querySelector('i > u')?.textContent).toBe('b');
74
+ });
75
+
76
+ await it("<markup> draws its children — it is pango's implicit root, not a box", async () => {
77
+ const { target } = render('<markup><b>x</b></markup>');
78
+ expect(target.querySelector('b')?.textContent).toBe('x');
79
+ });
80
+ });
81
+
82
+ await describe('renderPangoMarkup — the rows that were the injection sink', async () => {
83
+ // Each of these was reachable as `banner.innerHTML = title` / `heading.innerHTML
84
+ // = heading`. There is no branch in the renderer that creates an element for a
85
+ // tag pango does not know, and no branch that sets an HTML attribute from a
86
+ // `<span>` attribute — so none of them can produce an element at all.
87
+ for (const markup of [
88
+ '<img src=x onerror="alert(1)">',
89
+ '<script>alert(1)</script>',
90
+ '<div onclick="alert(1)">x</div>',
91
+ '<iframe src="javascript:alert(1)"></iframe>',
92
+ '<a href="javascript:alert(1)">x</a>',
93
+ '<b onclick="alert(1)">x</b>',
94
+ ]) {
95
+ await it(`${JSON.stringify(markup)} → no element, the raw string as text`, () => {
96
+ const { target, text } = render(markup);
97
+ expect(text).toBe(markup);
98
+ expect(target.children.length).toBe(0);
99
+ expect(target.querySelector('img, script, div, iframe, a, b, span')).toBe(null);
100
+ expect(target.getAttribute('onerror')).toBe(null);
101
+ expect(target.getAttribute('onclick')).toBe(null);
102
+ });
103
+ }
104
+
105
+ // The one shape that DOES parse and could still carry an `on*`: `<span>` takes
106
+ // attributes, so an unknown one reaches the tree. It is dropped there and here —
107
+ // the run renders as plain text with no attribute on it, which is what makes the
108
+ // whitelist load-bearing rather than decoration.
109
+ await it('an unknown <span> attribute is dropped, never set on the element', () => {
110
+ const { target } = render('<span onmouseover="alert(1)">x</span>');
111
+ const span = target.querySelector('span')!;
112
+ expect(span.textContent).toBe('x');
113
+ expect(span.attributes.length).toBe(0);
114
+ expect((span as HTMLElement).style.cssText).toBe('');
115
+ });
116
+
117
+ await it('unparseable markup keeps the raw string, which is the C fallback', () => {
118
+ for (const markup of ['a < b', 'Tom & Jerry', '<b>unclosed', '<b>wrong</i>']) {
119
+ const { target, text } = render(markup);
120
+ expect(text).toBe(markup);
121
+ expect(target.children.length).toBe(0);
122
+ }
123
+ });
124
+
125
+ await it('no element carries an attribute derived from a <span> attribute', () => {
126
+ const { target } = render('<span weight="bold" font_family="Serif">x</span>');
127
+ const span = target.querySelector('span')!;
128
+ // `style` and nothing else: CSSOM reflects the declarations it holds, but a
129
+ // MARKUP attribute must never reach the DOM as one.
130
+ expect([...span.attributes].map((attribute) => attribute.name)).toStrictEqual(['style']);
131
+ expect(span.style.fontWeight).toBe('bold');
132
+ });
133
+ });
134
+
135
+ await describe('renderPangoMarkup — a <span> attribute reaches CSS, or nothing', async () => {
136
+ await it("weight and style take pango's NAMED values", () => {
137
+ const { target } = render('<span weight="bold" style="italic">x</span>');
138
+ const span = target.querySelector('span')!;
139
+ expect(span.style.fontWeight).toBe('bold');
140
+ expect(span.style.fontStyle).toBe('italic');
141
+ });
142
+
143
+ await it('an unmapped weight is DROPPED, never passed through to CSS', () => {
144
+ const { target } = render('<span weight="expression(alert(1))">x</span>');
145
+ expect(target.querySelector('span')!.style.fontWeight).toBe('');
146
+ });
147
+
148
+ await it('underline and strikethrough ADD, so a span carrying both keeps both', () => {
149
+ const { target } = render('<span underline="single" strikethrough="true">x</span>');
150
+ const decoration = target.querySelector('span')!.style.textDecorationLine;
151
+ expect(decoration).toContain('underline');
152
+ expect(decoration).toContain('line-through');
153
+ });
154
+
155
+ await it('underline="none" and strikethrough="false" draw neither', () => {
156
+ const { target } = render('<span underline="none" strikethrough="false">x</span>');
157
+ expect(target.querySelector('span')!.style.textDecorationLine).toBe('');
158
+ });
159
+
160
+ await it('a font family list keeps its order; an unparseable one is refused whole', () => {
161
+ const ok = render('<span font_family="Cantarell, sans-serif">x</span>');
162
+ expect(ok.target.querySelector('span')!.style.fontFamily).toContain('Cantarell');
163
+ const refused = render('<span font_family="a&quot;; color: red">x</span>');
164
+ expect(refused.target.querySelector('span')!.style.fontFamily).toBe('');
165
+ expect(refused.target.querySelector('span')!.style.color).toBe('');
166
+ });
167
+
168
+ await it('a size travels with its unit — a bare pango number is 1024ths of a point', () => {
169
+ const units = render('<span size="12288">x</span>');
170
+ expect(units.target.querySelector('span')!.style.fontSize).toBe('12pt');
171
+ const points = render('<span size="12.5pt">x</span>');
172
+ expect(points.target.querySelector('span')!.style.fontSize).toBe('12.5pt');
173
+ const relative = render('<span font_size="larger">x</span>');
174
+ expect(relative.target.querySelector('span')!.style.fontSize).toBe('larger');
175
+ const percent = render('<span size="150%">x</span>');
176
+ expect(percent.target.querySelector('span')!.style.fontSize).toBe('150%');
177
+ const named = render('<span size="x-small">x</span>');
178
+ expect(named.target.querySelector('span')!.style.fontSize).toBe('x-small');
179
+ });
180
+
181
+ await it("reads pango's aliases and a numeric weight", () => {
182
+ const { target } = render('<span font_weight="600" font_style="italic" face="monospace">x</span>');
183
+ const span = target.querySelector('span')!;
184
+ expect(span.style.fontWeight).toBe('600');
185
+ expect(span.style.fontStyle).toBe('italic');
186
+ expect(span.style.fontFamily).toBe('monospace');
187
+ });
188
+
189
+ await it('an attribute pango accepts but CSS cannot express is dropped', () => {
190
+ // `rise` and `letter_spacing` are pango attributes with no equivalent here.
191
+ const { target } = render('<span rise="4" letter_spacing="2">x</span>');
192
+ expect(target.querySelector('span')!.getAttribute('style')).toBe(null);
193
+ });
194
+ });
195
+
196
+ await describe('the elements render the same way', async () => {
197
+ await it('<adw-banner> draws its <b> bold when use-markup is set', () => {
198
+ const { el, host } = mount<AdwBanner>('adw-banner', {
199
+ title: '<b>Metered</b> connection',
200
+ revealed: '',
201
+ 'use-markup': '',
202
+ });
203
+ const bold = bannerTitle(el).querySelector('b');
204
+ expect(bold?.textContent).toBe('Metered');
205
+ expect(isBold(bold!)).toBe(true);
206
+ host.remove();
207
+ });
208
+
209
+ await it('<adw-banner> shows an unknown tag as text, never as an element', () => {
210
+ const { el, host } = mount<AdwBanner>('adw-banner', {
211
+ title: '<img src=x onerror="alert(1)">',
212
+ revealed: '',
213
+ 'use-markup': '',
214
+ });
215
+ expect(bannerTitle(el).querySelector('img')).toBe(null);
216
+ expect(bannerTitle(el).textContent).toBe('<img src=x onerror="alert(1)">');
217
+ host.remove();
218
+ });
219
+
220
+ await it('<adw-alert-dialog> draws heading AND body markup bold/italic', () => {
221
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
222
+ heading: '<b>Delete</b> file?',
223
+ 'heading-use-markup': '',
224
+ body: 'Really delete <i>notes.txt</i>?',
225
+ 'body-use-markup': '',
226
+ });
227
+ expect(isBold(alertHeading(el).querySelector('b')!)).toBe(true);
228
+ expect(getComputedStyle(alertBody(el).querySelector('i')!).fontStyle).toBe('italic');
229
+ host.remove();
230
+ });
231
+
232
+ await it('<adw-alert-dialog> shows an unknown tag in either slot as text', () => {
233
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
234
+ heading: '<script>alert(1)</script>',
235
+ 'heading-use-markup': '',
236
+ body: '<img src=x onerror="alert(1)">',
237
+ 'body-use-markup': '',
238
+ });
239
+ expect(alertHeading(el).querySelector('script')).toBe(null);
240
+ expect(alertHeading(el).textContent).toBe('<script>alert(1)</script>');
241
+ expect(alertBody(el).querySelector('img')).toBe(null);
242
+ expect(alertBody(el).textContent).toBe('<img src=x onerror="alert(1)">');
243
+ host.remove();
244
+ });
245
+
246
+ await it('without use-markup both slots stay literal text', () => {
247
+ const { el, host } = mount<AdwAlertDialog>('adw-alert-dialog', {
248
+ heading: '<b>Delete</b> file?',
249
+ body: '<i>notes.txt</i>',
250
+ });
251
+ expect(alertHeading(el).textContent).toBe('<b>Delete</b> file?');
252
+ expect(alertHeading(el).querySelector('b')).toBe(null);
253
+ expect(alertBody(el).querySelector('i')).toBe(null);
254
+ host.remove();
255
+ });
256
+ });
257
+ };
@@ -0,0 +1,266 @@
1
+ // Pango markup → DOM NODES. Never markup, never a string the browser parses.
2
+ //
3
+ // WHY THIS FILE EXISTS. `use-markup` used to be implemented as `element.innerHTML =
4
+ // markup`, in three elements: `<adw-banner>`'s title, `<adw-alert-dialog>`'s heading and
5
+ // body. That is an INJECTION SINK dressed as a renderer, and it is also wrong on the
6
+ // merits: Pango markup and HTML are different languages (eleven tags, `<span>`
7
+ // attributes, five entities, no scripting — versus the whole of HTML). Mozilla's add-on
8
+ // reviewer flagged it, correctly.
9
+ //
10
+ // THE THREE STEPS, and why they are in this order:
11
+ //
12
+ // 1. `parsePangoMarkup` (`@gjsify/adwaita-core`, headless) — the STRING becomes a tree
13
+ // of plain data, or `null`. It is shared with the search pipeline, so the two can
14
+ // never disagree about what pango accepts.
15
+ // 2. Anything the tree does not name becomes NOTHING. A tag outside pango's set is not
16
+ // markup at all: the parse fails, and {@link renderPangoMarkup} writes the RAW
17
+ // STRING as a text node — the same fallback `gtk_label_set_markup` takes. There is
18
+ // no branch anywhere below that creates an element from an unrecognised name, so
19
+ // `<img src=x onerror=…>` and `<script>` cannot become nodes at all.
20
+ // 3. `<span>` attributes become CSS DECLARATIONS, never HTML attributes. Each is matched
21
+ // against a whitelist below and written with `style.setProperty`, which REJECTS an
22
+ // invalid value outright — so an attribute pango accepts but CSS does not (an unknown
23
+ // `weight`, a `font_family` with a quote in it) leaves the run unstyled rather than
24
+ // smuggling a declaration past the parser. Nothing here reads an attribute name off
25
+ // the element.
26
+ //
27
+ // WHY ELEMENTS AND NOT THE TAG NAMES VERBATIM. Six of pango's eleven tags map onto an
28
+ // HTML element of the SAME name with the SAME meaning (`b`, `i`, `u`, `s`, `sub`, `sup`),
29
+ // and the remaining four (`tt`, `big`, `small`, and `<span>` itself) are all
30
+ // {@link TAG_STYLES} entries: a style declaration on a `<span>`, because a semantic
31
+ // element is a thing an author's CSS can then reach, and because `<b>`/`<i>` are what a
32
+ // reader of the DOM expects. `<markup>` is pango's IMPLICIT root and draws nothing, so it
33
+ // becomes a `display: contents` span: its children land where the root was, with no box of
34
+ // its own for a stylesheet to trip over.
35
+ //
36
+ // `<tt>`/`<big>`/`<small>` are real HTML elements but OBSOLETE ones: they carry no
37
+ // presentational meaning in a modern UA stylesheet, which is exactly why their styles are
38
+ // spelled out here.
39
+ //
40
+ // Reference: packages/web/adwaita-core/src/pango-markup.ts (the parse, and why it is
41
+ // shared with the search pipeline)
42
+ // Reference: refs/gtk/gtk/gtklabel.c (gtk_label_set_markup's warn-and-keep-the-string
43
+ // fallback, which is what the `null` branch reproduces)
44
+ // Reference: Mozilla add-on reviewer guidance on innerHTML (the report this answers)
45
+ // Copyright (c) 2025 gjsify contributors. MIT License.
46
+
47
+ import { parsePangoMarkup, type PangoMarkupNode } from '@gjsify/adwaita-core';
48
+
49
+ /**
50
+ * Pango tag → the HTML element that draws it, with the CSS it needs because no
51
+ * presentational UA rule covers it. Every entry is a CLOSED set: a tag missing here is a
52
+ * parse error, so there is no default branch to fall through.
53
+ */
54
+ const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Record<string, string>> }>> = {
55
+ b: { element: 'b' },
56
+ i: { element: 'i' },
57
+ u: { element: 'u' },
58
+ s: { element: 's' },
59
+ tt: { element: 'span', style: { 'font-family': 'monospace' } },
60
+ big: { element: 'span', style: { 'font-size': 'larger' } },
61
+ small: { element: 'span', style: { 'font-size': 'smaller' } },
62
+ sub: { element: 'sub' },
63
+ sup: { element: 'sup' },
64
+ markup: { element: 'span', style: { display: 'contents' } },
65
+ span: { element: 'span' },
66
+ };
67
+
68
+ /**
69
+ * `<span>` attribute → the CSS property it becomes, and the values accepted for it. An
70
+ * attribute NOT listed here is dropped — pango accepts more of them than a stylesheet can
71
+ * express (`rise`, `letter_spacing`, `lang`, `variant`, `stretch`, …), and dropping one is
72
+ * what keeps this from becoming a general-purpose attribute→style bridge.
73
+ */
74
+ const SPAN_STYLES: Readonly<Record<string, string>> = {
75
+ weight: 'font-weight',
76
+ style: 'font-style',
77
+ underline: 'text-decoration-line',
78
+ strikethrough: 'text-decoration-line',
79
+ font_family: 'font-family',
80
+ size: 'font-size',
81
+ };
82
+
83
+ /**
84
+ * pango's alternative spellings for the attributes above, which `<span>` documents as
85
+ * equivalent (`pango_markup.html`: "the `font_weight` attribute is an alias for `weight`",
86
+ * and so on). Resolved BEFORE {@link SPAN_STYLES}, so an alias reaches the same whitelist
87
+ * as the name it aliases — reading them as separate properties would let one of the pair
88
+ * through as an unstyled run while the other worked.
89
+ */
90
+ const SPAN_ALIASES: Readonly<Record<string, string>> = {
91
+ font_weight: 'weight',
92
+ font_style: 'style',
93
+ face: 'font_family',
94
+ font_size: 'size',
95
+ };
96
+
97
+ /**
98
+ * The pango attribute values each property accepts, by NAME. `font-weight` takes pango's
99
+ * weight names and, beside them, pango's own numbers — CSS numbers weights on the same
100
+ * 1–1000 scale, so `weight="600"` is the one value that needs no translation. `weight`
101
+ * and `style` are the only two with a table; `text-decoration-line` takes CSS keywords,
102
+ * which are NOT pango's underline / strikethrough booleans — `underline="single"` has to
103
+ * become `underline`, and that mapping is one line per attribute, not a parser.
104
+ */
105
+ const ATTRIBUTE_VALUES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
106
+ weight: {
107
+ ultralight: '100',
108
+ light: '300',
109
+ normal: '400',
110
+ medium: '500',
111
+ semibold: '600',
112
+ bold: 'bold',
113
+ ultrabold: '700',
114
+ heavy: '900',
115
+ },
116
+ style: { normal: 'normal', oblique: 'oblique', italic: 'italic' },
117
+ };
118
+
119
+ /**
120
+ * One node of the parsed tree, built. Returns `null` for a tag this file does not draw —
121
+ * the fail-closed half: an unrecognised tag DROPS its element (and pango's parser would
122
+ * have refused the string entirely anyway), rather than falling back to some catch-all
123
+ * that would put an arbitrary element into the tree.
124
+ */
125
+ function buildNode(node: PangoMarkupNode, doc: Document): Node | null {
126
+ if (node.kind === 'text') return doc.createTextNode(node.text);
127
+
128
+ const spec = TAG_STYLES[node.tag];
129
+ if (spec === undefined) return null;
130
+
131
+ const el = doc.createElement(spec.element);
132
+ if (spec.style !== undefined)
133
+ for (const [property, value] of Object.entries(spec.style)) el.style.setProperty(property, value);
134
+ if (node.tag === 'span') applySpanAttributes(el, node.attributes);
135
+
136
+ for (const child of node.children) {
137
+ const built = buildNode(child, doc);
138
+ if (built !== null) el.append(built);
139
+ }
140
+ return el;
141
+ }
142
+
143
+ /** Write the recognised `<span>` attributes onto `el` as style declarations. */
144
+ function applySpanAttributes(el: HTMLElement, attributes: Readonly<Record<string, string>>): void {
145
+ // Additive, so `<span underline="single" strikethrough="true">` draws BOTH —
146
+ // `text-decoration` is a shorthand that would drop the first one.
147
+ const decorations: string[] = [];
148
+ for (const [name, value] of Object.entries(attributes)) {
149
+ const attribute = SPAN_ALIASES[name] ?? name;
150
+ const property = SPAN_STYLES[attribute];
151
+ if (property === undefined) continue;
152
+
153
+ if (attribute === 'underline') {
154
+ // pango: any value but `none` underlines.
155
+ if (value !== 'none') decorations.push('underline');
156
+ continue;
157
+ }
158
+ if (attribute === 'strikethrough') {
159
+ if (value !== 'false' && value !== 'none') decorations.push('line-through');
160
+ continue;
161
+ }
162
+ if (attribute === 'font_family') {
163
+ const family = sanitizeFontFamily(value);
164
+ if (family !== null) el.style.setProperty(property, family);
165
+ continue;
166
+ }
167
+ if (attribute === 'size') {
168
+ const size = sanitizeFontSize(value);
169
+ if (size !== null) el.style.setProperty(property, size);
170
+ continue;
171
+ }
172
+
173
+ // pango also takes a numeric weight; CSS reads the same integers from 1 to 1000.
174
+ if (attribute === 'weight' && /^[0-9]{1,4}$/.test(value) && +value >= 1 && +value <= 1000) {
175
+ el.style.setProperty(property, value);
176
+ continue;
177
+ }
178
+
179
+ const named = ATTRIBUTE_VALUES[attribute]?.[value];
180
+ // An unmapped value is DROPPED rather than passed through: `setProperty` would
181
+ // reject an invalid declaration anyway, but an unvalidated string reaching CSS is
182
+ // the shape of bug this file exists to end, so it never gets there.
183
+ if (named !== undefined) el.style.setProperty(property, named);
184
+ }
185
+ if (decorations.length > 0) el.style.setProperty('text-decoration-line', decorations.join(' '));
186
+ }
187
+
188
+ /** The generic families CSS parses unquoted; everything else must be a plain name. */
189
+ const CSS_GENERIC_FAMILIES: ReadonlySet<string> = new Set([
190
+ 'serif',
191
+ 'sans-serif',
192
+ 'monospace',
193
+ 'cursive',
194
+ 'fantasy',
195
+ 'system-ui',
196
+ 'ui-serif',
197
+ 'ui-sans-serif',
198
+ 'ui-monospace',
199
+ 'ui-rounded',
200
+ ]);
201
+
202
+ /**
203
+ * A pango `font_family` list as a CSS `font-family`, or `null` when any entry cannot be
204
+ * one. Pango takes a bare comma-separated list; CSS additionally parses quotes and
205
+ * escapes, so an entry holding either is refused as a WHOLE list rather than escaped —
206
+ * refusing is the answer that cannot produce a family name CSS reads differently.
207
+ */
208
+ function sanitizeFontFamily(value: string): string | null {
209
+ const families: string[] = [];
210
+ for (const raw of value.split(',')) {
211
+ const family = raw.trim();
212
+ if (family === '') continue;
213
+ const generic = CSS_GENERIC_FAMILIES.has(family.toLowerCase());
214
+ // A quoted CSS identifier is the general form; a bare one is safe only when it is
215
+ // a plain name, so anything carrying a quote, backslash or semicolon is refused.
216
+ if (!generic && !/^["']?[A-Za-z0-9 _-]+["']?$/.test(family)) return null;
217
+ families.push(family);
218
+ }
219
+ return families.length > 0 ? families.join(', ') : null;
220
+ }
221
+
222
+ /** A pango `size` as a CSS `font-size`, or `null` when it cannot be one. */
223
+ function sanitizeFontSize(value: string): string | null {
224
+ const trimmed = value.trim();
225
+ // pango's named sizes, which CSS ALSO names — the same absolute ladder, plus pango's
226
+ // two relative steps, so the whole set needs no conversion at all.
227
+ const named: Readonly<Record<string, string>> = {
228
+ 'xx-small': 'xx-small',
229
+ 'x-small': 'x-small',
230
+ small: 'small',
231
+ medium: 'medium',
232
+ large: 'large',
233
+ 'x-large': 'x-large',
234
+ 'xx-large': 'xx-large',
235
+ smaller: 'smaller',
236
+ larger: 'larger',
237
+ };
238
+ const namedSize = named[trimmed];
239
+ if (namedSize !== undefined) return namedSize;
240
+ // A bare pango size is in 1024ths of a point (`size="12288"` is 12pt), so it is scaled,
241
+ // never passed on: CSS would read a bare number as no length at all.
242
+ if (/^[0-9]+$/.test(trimmed)) return `${+trimmed / 1024}pt`;
243
+ // Since pango 1.50: points with their unit, and a percentage of the parent.
244
+ if (/^[0-9]+(\.[0-9]+)?pt$/.test(trimmed)) return trimmed;
245
+ if (/^[0-9]+(\.[0-9]+)?%$/.test(trimmed)) return trimmed;
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Render Pango markup into `target`, replacing its content — the one door every
251
+ * `use-markup` surface in this package goes through.
252
+ *
253
+ * The `null` branch is the C fallback and is the SECURITY property: markup that does not
254
+ * parse is written as TEXT, so an unrecognised tag can never become an element. Callers
255
+ * that want markup only when a caller has asked for it read that from their own flag and
256
+ * pass plain text here — this function never decides whether markup is meant.
257
+ */
258
+ export function renderPangoMarkup(target: HTMLElement, markup: string): void {
259
+ const nodes = parsePangoMarkup(markup);
260
+ if (nodes === null) {
261
+ target.textContent = markup;
262
+ return;
263
+ }
264
+ const doc = target.ownerDocument ?? document;
265
+ target.replaceChildren(...nodes.map((node) => buildNode(node, doc)).filter((node): node is Node => node !== null));
266
+ }