Skip to content

Commit 1e65ad5

Browse files
EthicalAd: switch between light/dark mode on Furo (#624)
Initial implementation to try out ad in dark mode on ethicalads: - It shows the ad in the defined style at the beginning - It listens to changes in the theme mode and switch ad theme as well - It only works on furo for now - The structure will be the same for other themes, we just need to implement minor details on them ### Sidebar ad Note that I had to use `style=image` instead of `style=readthedocs-sidebar` because the latter doesn't support dark mode. We should probably implement this in the ad client, and switch it back here. [Peek 2025-07-22 11-39.webm](https://github.com/user-attachments/assets/7c4d272c-d13e-44b7-a954-c142b148691e) ### Text ad at the bottom of the page [Peek 2025-07-22 11-40.webm](https://github.com/user-attachments/assets/e01d8718-85c9-44ff-9bf9-641f5e501766) ### Fixed footer text ad Note this is not adding in this PR because it needs this chunk of code to work: [`d7d3c9e` (#618)](d7d3c9e#diff-10e47ee23788d0e8d3d4e13cee726fd04c8fe035922a74ac1c7d432db1dfb9faR102). If we want to have it working, I can bring that code in here. [Peek 2025-07-22 11-46.webm](https://github.com/user-attachments/assets/9630eab2-3da1-469c-aa1a-a80c6e0d85ce) Related readthedocs/meta#192 --------- Co-authored-by: Eric Holscher <[email protected]> Co-authored-by: Eric Holscher <[email protected]>
1 parent 8c25bde commit 1e65ad5

File tree

3 files changed

+122
-4
lines changed

3 files changed

+122
-4
lines changed

src/constants.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ export const SPHINX_IMMATERIAL = "immaterial";
2020

2121
// API URLs
2222
export const EMBED_API_ENDPOINT = "/_/api/v3/embed/";
23+
24+
// Theme modes
25+
export const THEME_LIGHT_MODE = "light-mode";
26+
export const THEME_DARK_MODE = "dark-mode";
27+
export const THEME_UNKNOWN_MODE = "unknown-mode";

src/ethicalads.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AddonBase } from "./utils";
33
import { default as objectPath } from "object-path";
44
import styleSheet from "./ethicalads.css";
55
import { IS_TESTING, docTool } from "./utils.js";
6+
import { THEME_DARK_MODE } from "./constants.js";
67

78
// https://docs.readthedocs.io/en/stable/advertising/ad-customization.html#controlling-the-placement-of-an-ad
89
const EXPLICIT_PLACEMENT_SELECTORS = [
@@ -103,8 +104,12 @@ export class EthicalAdsAddon extends AddonBase {
103104
element = document.querySelector(selector);
104105

105106
if (this.elementAboveTheFold(element)) {
106-
placement.classList.add("ethical-alabaster");
107-
placement.setAttribute("data-ea-type", "readthedocs-sidebar");
107+
// NOTE: I'm using `image` instead of `readthedocs-sidebar` because
108+
// the later doesn't support dark mode.
109+
//
110+
// placement.classList.add("ethical-alabaster");
111+
// placement.setAttribute("data-ea-type", "readthedocs-sidebar");
112+
placement.setAttribute("data-ea-type", "image");
108113
knownPlacementFound = true;
109114
}
110115
} else if (docTool.isSphinxBookThemeLikeTheme()) {
@@ -149,8 +154,7 @@ export class EthicalAdsAddon extends AddonBase {
149154
placement.classList.add("ethical-alabaster");
150155
placement.classList.add("ethical-docusaurus");
151156

152-
placement.setAttribute("data-ea-type", "readthedocs-sidebar");
153-
placement.setAttribute("data-ea-style", "image");
157+
placement.setAttribute("data-ea-type", "image");
154158
knownPlacementFound = true;
155159
}
156160
} else if (docTool.isDocsify()) {
@@ -255,6 +259,11 @@ export class EthicalAdsAddon extends AddonBase {
255259
);
256260
}
257261

262+
// Add dark class to the ad when we detect dark mode from the beginning
263+
if (docTool.documentationThemeMode === THEME_DARK_MODE) {
264+
placement.classList.add("dark");
265+
}
266+
258267
if (placementStyle == "fixedfooter") {
259268
// Use a ``MutationObserver`` to listen to style changes in the fixed footer ad.
260269
// Grab the height of it and use to add some ``padding-bottom`` to the required elements.

src/utils.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
VITEPRESS,
1818
ANTORA,
1919
DOCSIFY,
20+
THEME_LIGHT_MODE,
21+
THEME_DARK_MODE,
22+
THEME_UNKNOWN_MODE,
2023
} from "./constants";
2124
import { EVENT_READTHEDOCS_URL_CHANGED } from "./events";
2225

@@ -412,7 +415,47 @@ export class DocumentationTool {
412415
constructor() {
413416
this.documentationTool = this.getDocumentationTool();
414417
this.documentationTheme = this.getDocumentationTheme();
418+
this.documentationThemeMode = this.getDocumentationThemeMode();
419+
415420
console.debug(`Documentation tool detected: ${this.documentationTool}`);
421+
console.debug(`Documentation theme detected: ${this.documentationTheme}`);
422+
console.debug(
423+
`Documentation theme mode detected: ${this.documentationThemeMode}`,
424+
);
425+
426+
this.connectEvents();
427+
}
428+
429+
/**
430+
* Connect all required events.
431+
*
432+
* There may be events that are doctool agnostic and some others that are
433+
* specific for paticular doctools or themes.
434+
*/
435+
connectEvents() {
436+
// Use a ``MutationObserver`` to listen to attribute changes in the `document.html` and `document.body` elements.
437+
// Different frameworks update different attributes:
438+
// - Furo Sphinx Theme: `document.body.data-theme`
439+
// - Docusaurus: `document.html.data-theme`
440+
// - VitePress: `document.html.class` ("dark" or nothing)
441+
// - PyData Sphinx Theme: `document.html.data-theme` and `document.html.data-mode`
442+
// - Shibuya Sphinx Theme: `document.html.data-color`
443+
// - mystmd: `document.html.class`
444+
445+
const config = { attributes: true, childList: false, subtree: false };
446+
const callback = (mutationList, observer) => {
447+
console.debug("Observed element mutated.", mutationList, observer);
448+
for (const mutation of mutationList) {
449+
if (mutation.type === "attributes") {
450+
this.updateAdThemeMode();
451+
}
452+
}
453+
};
454+
const observer = new MutationObserver(callback);
455+
// <html> element
456+
observer.observe(document.documentElement, config);
457+
// <body> element
458+
observer.observe(document.body, config);
416459
}
417460

418461
/**
@@ -566,6 +609,67 @@ export class DocumentationTool {
566609
return null;
567610
}
568611

612+
getDocumentationThemeMode() {
613+
let theme;
614+
const themeSelector =
615+
"html[data-theme], html[data-mode], html[data-color], body[data-theme]";
616+
const themes = {
617+
auto: THEME_UNKNOWN_MODE,
618+
light: THEME_LIGHT_MODE,
619+
dark: THEME_DARK_MODE,
620+
};
621+
622+
const prefersDarkMode = window.matchMedia(
623+
"(prefers-color-scheme: dark)",
624+
).matches;
625+
626+
for (const attribute of ["theme", "mode", "color"]) {
627+
theme = document.querySelector(themeSelector)?.dataset[attribute];
628+
if (theme) break;
629+
}
630+
if (theme === undefined) {
631+
theme = document.querySelector("html.dark") ? "dark" : undefined;
632+
}
633+
if (theme === undefined) {
634+
theme = document.querySelector("html.light") ? "light" : undefined;
635+
}
636+
637+
if (Object.keys(themes).includes(theme)) {
638+
if (theme !== "auto") {
639+
return themes[theme];
640+
} else if (prefersDarkMode) {
641+
return THEME_DARK_MODE;
642+
} else {
643+
return THEME_LIGHT_MODE;
644+
}
645+
}
646+
647+
return THEME_UNKNOWN_MODE;
648+
}
649+
650+
updateAdThemeMode() {
651+
let placement;
652+
// NOTE: can't be imported from `ethicalads.js` because cycle importing
653+
const EXPLICIT_PLACEMENT_SELECTORS = [
654+
"#ethical-ad-placement",
655+
"[data-ea-publisher]",
656+
];
657+
658+
for (const explicitSelector of EXPLICIT_PLACEMENT_SELECTORS) {
659+
placement = document.querySelector(explicitSelector);
660+
if (placement) break;
661+
}
662+
663+
if (!placement) return;
664+
665+
this.documentationThemeMode = this.getDocumentationThemeMode();
666+
if (this.documentationThemeMode === THEME_DARK_MODE) {
667+
placement.classList.add("dark");
668+
} else {
669+
placement.classList.remove("dark");
670+
}
671+
}
672+
569673
isSinglePageApplication() {
570674
const isSPA = DocumentationTool.SINGLE_PAGE_APPLICATIONS.includes(
571675
this.documentationTool,

0 commit comments

Comments
 (0)