Skip to content

Commit a230fa9

Browse files
committed
Updated theme switcher cookie logic.
switch-dark-mode.js had been calculating the domain for its cookie by removing everything up to the first dot in the hostname, so the theme choice would be shared between docs.djangoproject.com, code.djangoproject.com, etc. But that doesn't work for hostname=127.0.0.1 or localhost, and it's not ideal for PR preview domains like pr-12345.django.readthedocs.build. It also wouldn't work correctly if we ever wanted to use djangoproject.com (without a subdomain). Updated to determine the shared cookie domain from an allowlist of base domains and fall back to a host-only cookie anywhere else. In the process, also moved all related configuration to the top of the script, reduced duplicated code and use of inline magic values, tried to use consistent naming, and updated to newer JavaScript where helpful. (The script remains fully compatible with MDN baseline.)
1 parent 814c343 commit a230fa9

1 file changed

Lines changed: 75 additions & 71 deletions

File tree

Lines changed: 75 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,89 @@
1-
let prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
1+
// To prevent a flash of un-themed content, this script must be loaded in the
2+
// <head> and cannot be marked async, defer, or type=module.
23

3-
function setTheme(mode) {
4-
if (mode !== 'light' && mode !== 'dark' && mode !== 'auto') {
5-
console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);
6-
mode = 'auto';
4+
const themes = ['auto', 'light', 'dark'];
5+
const defaultTheme = themes[0];
6+
7+
const cookieName = 'theme';
8+
const cookieMaxAgeMs = 365 * 24 * 60 * 60 * 1000; // 1 year
9+
const cookieDomain = getCookieDomain(window.location.hostname, [
10+
// Share the cookie between all subdomains (code, docs, www, etc.) in
11+
// these base domains. More-specific bases must be listed first.
12+
'preview.djangoproject.com',
13+
'djangoproject.com',
14+
'djangoproject.localhost',
15+
'djangoproject.local',
16+
// Any other hostname (localhost, 127.0.0.1, pr-123.readthedocs.build, etc.)
17+
// will use a host-only cookie.
18+
]);
19+
const cookieSecure = window.location.protocol === 'https:';
20+
const cookieSameSite = 'Lax';
21+
22+
const prefersDarkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
23+
let prefersDark = prefersDarkMediaQuery.matches;
24+
25+
function setTheme(theme) {
26+
if (themes.indexOf(theme) < 0) {
27+
console.error(`Invalid theme: '${theme}'. Resetting to '${defaultTheme}'.`);
28+
theme = defaultTheme;
729
}
8-
document.documentElement.dataset.theme = mode;
9-
// trim host to get base domain name for set in cookie domain name for subdomain access
10-
const arrHost = window.location.hostname.split('.');
11-
const prefix = arrHost.shift();
12-
const host = arrHost.join('.');
13-
setCookie('theme', mode, host);
30+
document.documentElement.dataset.theme = theme;
31+
setThemeCookie(theme);
1432
}
1533

1634
function cycleTheme() {
17-
let currentTheme = document.documentElement.dataset.theme;
18-
if (currentTheme !== 'light' && currentTheme !== 'dark' && currentTheme !== 'auto') {
19-
currentTheme = 'auto';
20-
}
21-
22-
if (prefersDark) {
23-
// Auto (dark) -> Light -> Dark
24-
if (currentTheme === 'auto') {
25-
setTheme('light');
26-
} else if (currentTheme === 'light') {
27-
setTheme('dark');
28-
} else {
29-
setTheme('auto');
30-
}
31-
} else {
32-
// Auto (light) -> Dark -> Light
33-
if (currentTheme === 'auto') {
34-
setTheme('dark');
35-
} else if (currentTheme === 'dark') {
36-
setTheme('light');
37-
} else {
38-
setTheme('auto');
39-
}
40-
}
35+
// If prefersDark, cycle Auto (dark) -> Light -> Dark;
36+
// otherwise, cycle Auto (light) -> Dark -> Light.
37+
const currentThemeIndex = Math.max(
38+
0,
39+
themes.indexOf(document.documentElement.dataset.theme),
40+
);
41+
const direction = prefersDark ? 1 : -1;
42+
const newThemeIndex =
43+
(currentThemeIndex + direction + themes.length) % themes.length;
44+
setTheme(themes[newThemeIndex]);
4145
}
4246

4347
function initTheme() {
44-
// set theme defined in localStorage if there is one, or fallback to auto mode
45-
const currentTheme = getCookie('theme');
46-
currentTheme ? setTheme(currentTheme) : setTheme('auto');
48+
// Set theme stored in cookie if there is one, or fallback to auto mode.
49+
const currentTheme = getThemeCookie() || defaultTheme;
50+
setTheme(currentTheme);
4751
}
4852

49-
function setupTheme() {
50-
// Attach event handlers for toggling themes
51-
let buttons = document.getElementsByClassName('theme-toggle');
52-
for (let i = 0; i < buttons.length; i++) {
53-
buttons[i].addEventListener('click', cycleTheme);
53+
function setupThemeToggle() {
54+
// Attach event handlers for theme toggle buttons.
55+
for (const button of document.getElementsByClassName('theme-toggle')) {
56+
button.addEventListener('click', cycleTheme);
5457
}
5558
}
5659

57-
function setCookie(cname, cvalue, domain) {
58-
const d = new Date();
59-
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year
60-
let expires = 'expires=' + d.toUTCString();
61-
// change the SameSite attribute if it's on development or production
62-
const sameSiteAttribute =
63-
domain === 'localhost'
64-
? 'SameSite=Lax;'
65-
: `Domain=${domain}; SameSite=None; Secure;`;
66-
document.cookie = `${cname}=${cvalue}; ${sameSiteAttribute} ${expires}; path=/;`;
60+
function setThemeCookie(theme) {
61+
const expires = new Date(Date.now() + cookieMaxAgeMs).toUTCString();
62+
const attributes = [
63+
`${cookieName}=${encodeURIComponent(theme)}`,
64+
'Path=/',
65+
`Expires=${expires}`,
66+
`SameSite=${cookieSameSite}`,
67+
cookieDomain ? `Domain=${cookieDomain}` : '',
68+
cookieSecure ? 'Secure' : '',
69+
];
70+
document.cookie = attributes.filter(Boolean).join('; ');
6771
}
6872

69-
function getCookie(cname) {
70-
let name = cname + '=';
71-
let decodedCookie = decodeURIComponent(document.cookie);
72-
let ca = decodedCookie.split(';');
73-
for (let i = 0; i < ca.length; i++) {
74-
let c = ca[i];
75-
while (c.charAt(0) === ' ') {
76-
c = c.substring(1);
77-
}
78-
if (c.indexOf(name) === 0) {
79-
return c.substring(name.length, c.length);
73+
function getThemeCookie() {
74+
// This must be synchronous to avoid a flash of un-themed content on load,
75+
// so cannot use the Cookie Store API.
76+
const cookie = document.cookie
77+
.split(/;\s*/g)
78+
.find((cookie) => cookie.startsWith(`${cookieName}=`));
79+
return cookie ? decodeURIComponent(cookie.slice(cookieName.length + 1)) : '';
80+
}
81+
82+
function getCookieDomain(hostname, baseDomains) {
83+
hostname = hostname.toLowerCase();
84+
for (const baseDomain of baseDomains) {
85+
if (hostname === baseDomain || hostname.endsWith(`.${baseDomain}`)) {
86+
return baseDomain;
8087
}
8188
}
8289
return '';
@@ -85,12 +92,9 @@ function getCookie(cname) {
8592
initTheme();
8693

8794
document.addEventListener('DOMContentLoaded', function () {
88-
setupTheme();
95+
setupThemeToggle();
8996
});
9097

91-
// reset theme and release image if auto mode activated and os preferences have changed
92-
window
93-
.matchMedia('(prefers-color-scheme: dark)')
94-
.addEventListener('change', function (e) {
95-
prefersDark = e.matches;
96-
});
98+
prefersDarkMediaQuery.addEventListener('change', (e) => {
99+
prefersDark = e.matches;
100+
});

0 commit comments

Comments
 (0)