@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.
- package/README.md +31 -0
- package/lib/types/elements/gtk-window.d.ts +33 -0
- package/lib/types/pango-markup.d.ts +10 -0
- package/lib/types/pango-markup.spec.d.ts +1 -0
- package/lib/types/source-view/copy-glyph.d.ts +6 -0
- package/lib/types/source-view/copy-glyph.spec.d.ts +1 -0
- package/package.json +11 -11
- package/src/application.spec.ts +33 -0
- package/src/application.ts +16 -0
- package/src/elements/adw-alert-dialog.ts +8 -5
- package/src/elements/adw-banner.ts +14 -9
- package/src/elements/gtk-window.ts +146 -11
- package/src/gtk-window.spec.ts +122 -0
- package/src/pango-markup.spec.ts +257 -0
- package/src/pango-markup.ts +266 -0
- package/src/source-view/adw-source-view.ts +2 -8
- package/src/source-view/copy-glyph.spec.ts +46 -0
- package/src/source-view/copy-glyph.ts +52 -0
- package/src/test.browser.mts +4 -0
package/src/gtk-window.spec.ts
CHANGED
|
@@ -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"; 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
|
+
}
|