starlight-theme-bejamas 0.1.18 → 0.2.3
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/CHANGELOG.md +32 -0
- package/package.json +8 -7
- package/src/config.test.ts +40 -0
- package/src/config.ts +4 -3
- package/src/global.d.ts +4 -2
- package/src/index.ts +0 -7
- package/src/lib/theme-choice.test.ts +251 -0
- package/src/lib/theme-choice.ts +346 -0
- package/src/lib/theme-stylesheet-runtime.test.ts +246 -0
- package/src/lib/theme-stylesheet.test.ts +16 -0
- package/src/lib/theme-stylesheet.ts +123 -0
- package/src/overrides/MobileTableOfContents.astro +6 -6
- package/src/overrides/PageFrame.astro +40 -83
- package/src/overrides/PageTitle.astro +2 -2
- package/src/overrides/TableOfContents/TableOfContentsList.astro +1 -1
- package/src/overrides/ThemeProvider.astro +101 -31
- package/src/overrides/ThemeSelect.astro +32 -47
- package/src/styles/theme.css +42 -36
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
export const STARLIGHT_THEME_STORAGE_KEY = "starlight-theme";
|
|
2
|
+
export const THEME_TOGGLE_CHANGED_EVENT = "theme-toggle-changed";
|
|
3
|
+
|
|
4
|
+
export type ThemeChoice = "auto" | "dark" | "light";
|
|
5
|
+
export type ThemeMode = "dark" | "light";
|
|
6
|
+
|
|
7
|
+
export interface ThemeRoot {
|
|
8
|
+
dataset: Record<string, string | undefined>;
|
|
9
|
+
classList: {
|
|
10
|
+
add: (...tokens: string[]) => void;
|
|
11
|
+
remove: (...tokens: string[]) => void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ThemeClassList = ThemeRoot["classList"] & {
|
|
16
|
+
contains?: (token: string) => boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ThemeRootElement = ThemeRoot & {
|
|
20
|
+
classList: ThemeClassList;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ThemeStorageReader = Pick<Storage, "getItem"> | null | undefined;
|
|
24
|
+
type ThemeStorageWriter = Pick<Storage, "setItem"> | null | undefined;
|
|
25
|
+
|
|
26
|
+
const THEME_CHOICE_TAB_SELECTOR = '[data-slot="tabs"][data-theme-choice-tabs]';
|
|
27
|
+
|
|
28
|
+
function getElementsWithSelf<T extends Element>(
|
|
29
|
+
root: ParentNode,
|
|
30
|
+
selector: string,
|
|
31
|
+
): T[] {
|
|
32
|
+
const matches: T[] = [];
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
typeof Element !== "undefined" &&
|
|
36
|
+
root instanceof Element &&
|
|
37
|
+
root.matches(selector)
|
|
38
|
+
) {
|
|
39
|
+
matches.push(root as T);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
matches.push(...Array.from(root.querySelectorAll<T>(selector)));
|
|
43
|
+
|
|
44
|
+
return matches;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseStoredThemeChoice(value: unknown): ThemeChoice {
|
|
48
|
+
return value === "auto" || value === "dark" || value === "light"
|
|
49
|
+
? value
|
|
50
|
+
: "auto";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadStoredThemeChoice(storage: ThemeStorageReader): ThemeChoice {
|
|
54
|
+
try {
|
|
55
|
+
return parseStoredThemeChoice(
|
|
56
|
+
storage?.getItem(STARLIGHT_THEME_STORAGE_KEY),
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
return "auto";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolveThemeMode(
|
|
64
|
+
themeChoice: ThemeChoice,
|
|
65
|
+
prefersLight: boolean,
|
|
66
|
+
): ThemeMode {
|
|
67
|
+
if (themeChoice === "light" || themeChoice === "dark") {
|
|
68
|
+
return themeChoice;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return prefersLight ? "light" : "dark";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyThemeModeToRoot(
|
|
75
|
+
root: ThemeRoot,
|
|
76
|
+
themeChoice: ThemeChoice,
|
|
77
|
+
themeMode: ThemeMode,
|
|
78
|
+
): void {
|
|
79
|
+
root.dataset.theme = themeMode;
|
|
80
|
+
root.dataset.themeChoice = themeChoice;
|
|
81
|
+
root.classList.remove("light", "dark");
|
|
82
|
+
root.classList.add(themeMode);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function storeThemeChoice(
|
|
86
|
+
storage: ThemeStorageWriter,
|
|
87
|
+
themeChoice: ThemeChoice,
|
|
88
|
+
): void {
|
|
89
|
+
try {
|
|
90
|
+
storage?.setItem(
|
|
91
|
+
STARLIGHT_THEME_STORAGE_KEY,
|
|
92
|
+
themeChoice === "light" || themeChoice === "dark" ? themeChoice : "",
|
|
93
|
+
);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getThemeChoiceFromRoot(
|
|
98
|
+
root: Pick<ThemeRoot, "dataset">,
|
|
99
|
+
): ThemeChoice {
|
|
100
|
+
return parseStoredThemeChoice(root.dataset.themeChoice);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getThemeModeFromRoot(
|
|
104
|
+
root: ThemeRootElement,
|
|
105
|
+
fallbackMode: ThemeMode,
|
|
106
|
+
): ThemeMode {
|
|
107
|
+
if (root.dataset.theme === "light" || root.dataset.theme === "dark") {
|
|
108
|
+
return root.dataset.theme;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (root.classList.contains?.("dark")) {
|
|
112
|
+
return "dark";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (root.classList.contains?.("light")) {
|
|
116
|
+
return "light";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return fallbackMode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function syncThemeChoiceTabControls(
|
|
123
|
+
themeChoice: ThemeChoice,
|
|
124
|
+
root: ParentNode,
|
|
125
|
+
): void {
|
|
126
|
+
const tabsRoots = new Set<HTMLElement>();
|
|
127
|
+
|
|
128
|
+
for (const tabsRoot of getElementsWithSelf<HTMLElement>(
|
|
129
|
+
root,
|
|
130
|
+
THEME_CHOICE_TAB_SELECTOR,
|
|
131
|
+
)) {
|
|
132
|
+
tabsRoots.add(tabsRoot);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const picker of getElementsWithSelf<HTMLElement>(
|
|
136
|
+
root,
|
|
137
|
+
"starlight-theme-select",
|
|
138
|
+
)) {
|
|
139
|
+
const tabsRoot = picker.querySelector<HTMLElement>(THEME_CHOICE_TAB_SELECTOR);
|
|
140
|
+
if (tabsRoot) {
|
|
141
|
+
tabsRoots.add(tabsRoot);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const tabsRoot of tabsRoots) {
|
|
146
|
+
tabsRoot.dataset.defaultValue = themeChoice;
|
|
147
|
+
tabsRoot.dataset.value = themeChoice;
|
|
148
|
+
tabsRoot.dispatchEvent(
|
|
149
|
+
new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function syncLegacyThemeChoiceSelectControls(
|
|
155
|
+
themeChoice: ThemeChoice,
|
|
156
|
+
root: ParentNode,
|
|
157
|
+
): void {
|
|
158
|
+
for (const picker of getElementsWithSelf<HTMLElement>(
|
|
159
|
+
root,
|
|
160
|
+
"starlight-theme-select",
|
|
161
|
+
)) {
|
|
162
|
+
const nativeSelect = picker.querySelector<HTMLSelectElement>("select");
|
|
163
|
+
if (nativeSelect) {
|
|
164
|
+
nativeSelect.value = themeChoice;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const selectRoot = picker.querySelector<HTMLElement>(
|
|
168
|
+
'#starlight-theme-select, [data-slot="select"]',
|
|
169
|
+
);
|
|
170
|
+
if (!selectRoot) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
selectRoot.dataset.defaultValue = themeChoice;
|
|
175
|
+
selectRoot.dataset.value = themeChoice;
|
|
176
|
+
selectRoot.dispatchEvent(
|
|
177
|
+
new CustomEvent("select:set", { detail: { value: themeChoice } }),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function syncThemeChoiceControls(
|
|
183
|
+
themeChoice: ThemeChoice,
|
|
184
|
+
root: ParentNode = document,
|
|
185
|
+
): void {
|
|
186
|
+
syncThemeChoiceTabControls(themeChoice, root);
|
|
187
|
+
syncLegacyThemeChoiceSelectControls(themeChoice, root);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function dispatchThemeToggleChange(
|
|
191
|
+
target: EventTarget,
|
|
192
|
+
themeMode: ThemeMode,
|
|
193
|
+
themeChoice: ThemeChoice,
|
|
194
|
+
): void {
|
|
195
|
+
target.dispatchEvent(
|
|
196
|
+
new CustomEvent(THEME_TOGGLE_CHANGED_EVENT, {
|
|
197
|
+
detail: {
|
|
198
|
+
theme: themeMode,
|
|
199
|
+
themeChoice,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function setThemeChoice(
|
|
206
|
+
themeChoice: ThemeChoice,
|
|
207
|
+
options: {
|
|
208
|
+
storage?: ThemeStorageWriter;
|
|
209
|
+
root?: ThemeRootElement;
|
|
210
|
+
prefersLight?: boolean;
|
|
211
|
+
controlsRoot?: ParentNode;
|
|
212
|
+
dispatchTarget?: EventTarget;
|
|
213
|
+
emitEvent?: boolean;
|
|
214
|
+
} = {},
|
|
215
|
+
): ThemeMode {
|
|
216
|
+
const root = options.root ?? (document.documentElement as HTMLElement);
|
|
217
|
+
const effectiveTheme = resolveThemeMode(
|
|
218
|
+
themeChoice,
|
|
219
|
+
options.prefersLight ??
|
|
220
|
+
window.matchMedia("(prefers-color-scheme: light)").matches,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
applyThemeModeToRoot(root, themeChoice, effectiveTheme);
|
|
224
|
+
storeThemeChoice(options.storage ?? localStorage, themeChoice);
|
|
225
|
+
syncThemeChoiceControls(themeChoice, options.controlsRoot ?? document);
|
|
226
|
+
|
|
227
|
+
if (options.emitEvent !== false) {
|
|
228
|
+
dispatchThemeToggleChange(
|
|
229
|
+
options.dispatchTarget ?? window,
|
|
230
|
+
effectiveTheme,
|
|
231
|
+
themeChoice,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return effectiveTheme;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function buildThemeChoiceBootstrapInlineScript(): string {
|
|
239
|
+
return `(function () {
|
|
240
|
+
const storageKey = ${JSON.stringify(STARLIGHT_THEME_STORAGE_KEY)};
|
|
241
|
+
const eventName = ${JSON.stringify(THEME_TOGGLE_CHANGED_EVENT)};
|
|
242
|
+
const parseStoredThemeChoice = (value) =>
|
|
243
|
+
value === "auto" || value === "dark" || value === "light" ? value : "auto";
|
|
244
|
+
const loadStoredThemeChoice = () => {
|
|
245
|
+
try {
|
|
246
|
+
return parseStoredThemeChoice(
|
|
247
|
+
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return "auto";
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const resolveThemeMode = (themeChoice, prefersLight) => {
|
|
254
|
+
if (themeChoice === "light" || themeChoice === "dark") {
|
|
255
|
+
return themeChoice;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return prefersLight ? "light" : "dark";
|
|
259
|
+
};
|
|
260
|
+
const applyThemeModeToRoot = (themeChoice) => {
|
|
261
|
+
const themeMode = resolveThemeMode(
|
|
262
|
+
themeChoice,
|
|
263
|
+
matchMedia("(prefers-color-scheme: light)").matches,
|
|
264
|
+
);
|
|
265
|
+
const root = document.documentElement;
|
|
266
|
+
|
|
267
|
+
root.dataset.theme = themeMode;
|
|
268
|
+
root.dataset.themeChoice = themeChoice;
|
|
269
|
+
root.classList.remove("light", "dark");
|
|
270
|
+
root.classList.add(themeMode);
|
|
271
|
+
|
|
272
|
+
return themeMode;
|
|
273
|
+
};
|
|
274
|
+
const syncThemeChoiceControls = (themeChoice) => {
|
|
275
|
+
document
|
|
276
|
+
.querySelectorAll('[data-slot="tabs"][data-theme-choice-tabs]')
|
|
277
|
+
.forEach((tabsRoot) => {
|
|
278
|
+
tabsRoot.dataset.defaultValue = themeChoice;
|
|
279
|
+
tabsRoot.dataset.value = themeChoice;
|
|
280
|
+
tabsRoot.dispatchEvent(
|
|
281
|
+
new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
document.querySelectorAll("starlight-theme-select").forEach((picker) => {
|
|
286
|
+
const nativeSelect = picker.querySelector("select");
|
|
287
|
+
if (nativeSelect instanceof HTMLSelectElement) {
|
|
288
|
+
nativeSelect.value = themeChoice;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const selectRoot = picker.querySelector(
|
|
292
|
+
'#starlight-theme-select, [data-slot="select"]',
|
|
293
|
+
);
|
|
294
|
+
if (!(selectRoot instanceof HTMLElement)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
selectRoot.dataset.defaultValue = themeChoice;
|
|
299
|
+
selectRoot.dataset.value = themeChoice;
|
|
300
|
+
selectRoot.dispatchEvent(
|
|
301
|
+
new CustomEvent("select:set", { detail: { value: themeChoice } }),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
const dispatchThemeToggleChange = (themeMode, themeChoice) => {
|
|
306
|
+
window.dispatchEvent(
|
|
307
|
+
new CustomEvent(eventName, {
|
|
308
|
+
detail: {
|
|
309
|
+
theme: themeMode,
|
|
310
|
+
themeChoice,
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
const syncThemeChoice = (themeChoice, emitEvent) => {
|
|
316
|
+
const themeMode = applyThemeModeToRoot(themeChoice);
|
|
317
|
+
syncThemeChoiceControls(themeChoice);
|
|
318
|
+
if (emitEvent) {
|
|
319
|
+
dispatchThemeToggleChange(themeMode, themeChoice);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const syncStoredThemeChoice = (emitEvent) => {
|
|
323
|
+
syncThemeChoice(loadStoredThemeChoice(), emitEvent);
|
|
324
|
+
};
|
|
325
|
+
const mediaQuery = matchMedia("(prefers-color-scheme: light)");
|
|
326
|
+
const onMediaChange = () => {
|
|
327
|
+
if (loadStoredThemeChoice() === "auto") {
|
|
328
|
+
syncThemeChoice("auto", true);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
syncStoredThemeChoice(false);
|
|
333
|
+
|
|
334
|
+
window.addEventListener("storage", (event) => {
|
|
335
|
+
if (event.key === storageKey) {
|
|
336
|
+
syncStoredThemeChoice(true);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
341
|
+
mediaQuery.addEventListener("change", onMediaChange);
|
|
342
|
+
} else if (typeof mediaQuery.addListener === "function") {
|
|
343
|
+
mediaQuery.addListener(onMediaChange);
|
|
344
|
+
}
|
|
345
|
+
})();`;
|
|
346
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { refreshCurrentThemeStylesheet } from "./theme-stylesheet";
|
|
3
|
+
|
|
4
|
+
const ACTIVE_SELECTOR = "link[data-current-theme-stylesheet]";
|
|
5
|
+
const PENDING_SELECTOR = "link[data-pending-current-theme-stylesheet]";
|
|
6
|
+
|
|
7
|
+
type Listener = (event: Event) => void;
|
|
8
|
+
|
|
9
|
+
class FakeLinkElement {
|
|
10
|
+
href = "";
|
|
11
|
+
rel = "stylesheet";
|
|
12
|
+
private attributes = new Map<string, string>();
|
|
13
|
+
private listeners = new Map<string, Set<Listener>>();
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly document: FakeDocument,
|
|
17
|
+
href = "",
|
|
18
|
+
) {
|
|
19
|
+
this.href = href;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
cloneNode() {
|
|
23
|
+
const clone = new FakeLinkElement(this.document, this.href);
|
|
24
|
+
clone.rel = this.rel;
|
|
25
|
+
|
|
26
|
+
for (const [name, value] of this.attributes) {
|
|
27
|
+
clone.attributes.set(name, value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return clone;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setAttribute(name: string, value: string) {
|
|
34
|
+
this.attributes.set(name, value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
removeAttribute(name: string) {
|
|
38
|
+
this.attributes.delete(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hasAttribute(name: string) {
|
|
42
|
+
return this.attributes.has(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addEventListener(type: string, listener: Listener) {
|
|
46
|
+
const listeners = this.listeners.get(type) ?? new Set<Listener>();
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
this.listeners.set(type, listeners);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
removeEventListener(type: string, listener: Listener) {
|
|
52
|
+
this.listeners.get(type)?.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
dispatchEvent(event: Event) {
|
|
56
|
+
this.listeners.get(event.type)?.forEach((listener) => listener(event));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
insertAdjacentElement(position: InsertPosition, element: FakeLinkElement) {
|
|
61
|
+
if (position !== "afterend") {
|
|
62
|
+
throw new Error(`Unsupported insert position: ${position}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.document.insertAfter(this, element);
|
|
66
|
+
return element;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
remove() {
|
|
70
|
+
this.document.remove(this);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class FakeDocument {
|
|
75
|
+
links: FakeLinkElement[] = [];
|
|
76
|
+
|
|
77
|
+
querySelector<T extends FakeLinkElement>(selector: string) {
|
|
78
|
+
return (this.querySelectorAll(selector)[0] ?? null) as T | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
querySelectorAll<T extends FakeLinkElement>(selector: string) {
|
|
82
|
+
if (selector === ACTIVE_SELECTOR) {
|
|
83
|
+
return this.links.filter((link) =>
|
|
84
|
+
link.hasAttribute("data-current-theme-stylesheet"),
|
|
85
|
+
) as T[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (selector === PENDING_SELECTOR) {
|
|
89
|
+
return this.links.filter((link) =>
|
|
90
|
+
link.hasAttribute("data-pending-current-theme-stylesheet"),
|
|
91
|
+
) as T[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (selector === "link") {
|
|
95
|
+
return [...this.links] as T[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [] as T[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
append(link: FakeLinkElement) {
|
|
102
|
+
this.links.push(link);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
insertAfter(target: FakeLinkElement, link: FakeLinkElement) {
|
|
106
|
+
const index = this.links.indexOf(target);
|
|
107
|
+
if (index === -1) {
|
|
108
|
+
this.links.push(link);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.links.splice(index + 1, 0, link);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
remove(link: FakeLinkElement) {
|
|
116
|
+
this.links = this.links.filter((entry) => entry !== link);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const originalDocument = globalThis.document;
|
|
121
|
+
const originalWindow = globalThis.window;
|
|
122
|
+
|
|
123
|
+
function createEnvironment() {
|
|
124
|
+
const document = new FakeDocument();
|
|
125
|
+
const window = {
|
|
126
|
+
location: { origin: "https://ui.bejamas.com" },
|
|
127
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
|
128
|
+
callback(0);
|
|
129
|
+
return 1;
|
|
130
|
+
},
|
|
131
|
+
setTimeout,
|
|
132
|
+
} as unknown as Window & typeof globalThis;
|
|
133
|
+
|
|
134
|
+
globalThis.document = document as unknown as Document;
|
|
135
|
+
globalThis.window = window;
|
|
136
|
+
|
|
137
|
+
return { document };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendActiveStylesheet(document: FakeDocument, href?: string) {
|
|
141
|
+
const stylesheet = new FakeLinkElement(
|
|
142
|
+
document,
|
|
143
|
+
href ?? "https://ui.bejamas.com/r/themes/current-theme.css",
|
|
144
|
+
);
|
|
145
|
+
stylesheet.setAttribute("data-current-theme-stylesheet", "");
|
|
146
|
+
document.append(stylesheet);
|
|
147
|
+
return stylesheet;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe("refreshCurrentThemeStylesheet", () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
createEnvironment();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
globalThis.document = originalDocument;
|
|
157
|
+
globalThis.window = originalWindow;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("keeps the current stylesheet active until the replacement loads", async () => {
|
|
161
|
+
const document = globalThis.document as unknown as FakeDocument;
|
|
162
|
+
const currentStylesheet = appendActiveStylesheet(document);
|
|
163
|
+
|
|
164
|
+
const refreshPromise = refreshCurrentThemeStylesheet();
|
|
165
|
+
const pendingStylesheet =
|
|
166
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR);
|
|
167
|
+
|
|
168
|
+
expect(refreshPromise).toBeInstanceOf(Promise);
|
|
169
|
+
expect(document.querySelectorAll("link")).toHaveLength(2);
|
|
170
|
+
expect(document.querySelector(ACTIVE_SELECTOR)).toBe(currentStylesheet);
|
|
171
|
+
expect(pendingStylesheet).not.toBeNull();
|
|
172
|
+
expect(pendingStylesheet?.href).not.toBe(currentStylesheet.href);
|
|
173
|
+
|
|
174
|
+
pendingStylesheet?.dispatchEvent(new Event("load"));
|
|
175
|
+
await refreshPromise;
|
|
176
|
+
|
|
177
|
+
const activeStylesheet =
|
|
178
|
+
document.querySelector<FakeLinkElement>(ACTIVE_SELECTOR);
|
|
179
|
+
expect(document.querySelectorAll("link")).toHaveLength(1);
|
|
180
|
+
expect(document.querySelector(PENDING_SELECTOR)).toBeNull();
|
|
181
|
+
expect(activeStylesheet).toBe(pendingStylesheet);
|
|
182
|
+
expect(activeStylesheet?.href).toContain("/r/themes/current-theme.css?v=");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("keeps the current stylesheet when the replacement fails to load", async () => {
|
|
186
|
+
const document = globalThis.document as unknown as FakeDocument;
|
|
187
|
+
const currentStylesheet = appendActiveStylesheet(document);
|
|
188
|
+
|
|
189
|
+
const refreshPromise = refreshCurrentThemeStylesheet();
|
|
190
|
+
const pendingStylesheet =
|
|
191
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR);
|
|
192
|
+
|
|
193
|
+
pendingStylesheet?.dispatchEvent(new Event("error"));
|
|
194
|
+
await refreshPromise;
|
|
195
|
+
|
|
196
|
+
expect(document.querySelectorAll("link")).toHaveLength(1);
|
|
197
|
+
expect(document.querySelector(ACTIVE_SELECTOR)).toBe(currentStylesheet);
|
|
198
|
+
expect(document.querySelector(PENDING_SELECTOR)).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("uses a unique cache-busting version for each refresh", async () => {
|
|
202
|
+
const document = globalThis.document as unknown as FakeDocument;
|
|
203
|
+
appendActiveStylesheet(document);
|
|
204
|
+
|
|
205
|
+
const firstRefresh = refreshCurrentThemeStylesheet();
|
|
206
|
+
const firstHref =
|
|
207
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR)?.href;
|
|
208
|
+
|
|
209
|
+
// A second refresh in the same tick (same Date.now()) must still produce a
|
|
210
|
+
// distinct URL, otherwise the browser can reuse a stale cached response.
|
|
211
|
+
const secondRefresh = refreshCurrentThemeStylesheet();
|
|
212
|
+
const secondPending =
|
|
213
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR);
|
|
214
|
+
|
|
215
|
+
expect(secondPending?.href).toContain("/r/themes/current-theme.css?v=");
|
|
216
|
+
expect(secondPending?.href).not.toBe(firstHref);
|
|
217
|
+
|
|
218
|
+
secondPending?.dispatchEvent(new Event("load"));
|
|
219
|
+
await Promise.all([firstRefresh, secondRefresh]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("cancels an older pending swap when a newer one starts", async () => {
|
|
223
|
+
const document = globalThis.document as unknown as FakeDocument;
|
|
224
|
+
appendActiveStylesheet(document);
|
|
225
|
+
|
|
226
|
+
const firstRefresh = refreshCurrentThemeStylesheet();
|
|
227
|
+
const firstPendingStylesheet =
|
|
228
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR);
|
|
229
|
+
|
|
230
|
+
const secondRefresh = refreshCurrentThemeStylesheet();
|
|
231
|
+
const secondPendingStylesheet =
|
|
232
|
+
document.querySelector<FakeLinkElement>(PENDING_SELECTOR);
|
|
233
|
+
|
|
234
|
+
expect(secondPendingStylesheet).not.toBe(firstPendingStylesheet);
|
|
235
|
+
expect(document.querySelectorAll(PENDING_SELECTOR)).toHaveLength(1);
|
|
236
|
+
|
|
237
|
+
secondPendingStylesheet?.dispatchEvent(new Event("load"));
|
|
238
|
+
await Promise.all([firstRefresh, secondRefresh]);
|
|
239
|
+
|
|
240
|
+
expect(document.querySelectorAll("link")).toHaveLength(1);
|
|
241
|
+
expect(document.querySelector(ACTIVE_SELECTOR)).toBe(
|
|
242
|
+
secondPendingStylesheet,
|
|
243
|
+
);
|
|
244
|
+
expect(document.querySelector(PENDING_SELECTOR)).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCurrentThemeStylesheetHref } from "./theme-stylesheet";
|
|
3
|
+
|
|
4
|
+
describe("current theme stylesheet helpers", () => {
|
|
5
|
+
test("adds a cache-busting query param to relative hrefs", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCurrentThemeStylesheetHref("/r/themes/current-theme.css", 123),
|
|
8
|
+
).toBe("/r/themes/current-theme.css?v=123");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("preserves existing query params when cache-busting", () => {
|
|
12
|
+
expect(
|
|
13
|
+
buildCurrentThemeStylesheetHref("/r/themes/current-theme.css?foo=bar", 123),
|
|
14
|
+
).toBe("/r/themes/current-theme.css?foo=bar&v=123");
|
|
15
|
+
});
|
|
16
|
+
});
|