Skip to content

Commit abc40cf

Browse files
Merge pull request #174 from OneBusAway/push-notifications
Add OBACloud project page with Mermaid diagram support
2 parents 5a486f8 + a508096 commit abc40cf

5 files changed

Lines changed: 466 additions & 1 deletion

File tree

Gemfile.lock

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ GEM
6666
faraday (>= 1, < 3)
6767
faraday-net_http (3.4.0)
6868
net-http (>= 0.5.0)
69-
ffi (1.17.0)
69+
ffi (1.17.4-arm64-darwin)
70+
ffi (1.17.4-x64-mingw-ucrt)
71+
ffi (1.17.4-x86_64-linux-gnu)
7072
hash_with_dot_access (1.2.0)
7173
activesupport (>= 5.0.0, < 8.0)
7274
i18n (1.14.6)

frontend/javascript/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "./page_navigation";
1717

1818
import { enableDocSearch } from "./search";
19+
import { renderMermaidDiagrams } from "./mermaid_diagrams";
1920

2021
// Import all JavaScript & CSS files from src/_components
2122
import components from "$components/**/*.{js,jsx,js.rb,css}"
@@ -24,6 +25,7 @@ console.info("Bridgetown is loaded!")
2425

2526
document.addEventListener("DOMContentLoaded", function(event) {
2627
copyHeadingDirectLinks();
28+
renderMermaidDiagrams(); // must run before highlighting so mermaid fences aren't treated as code blocks
2729
enableCodeHighlighting();
2830
insertCodeSnippetCopyButtons();
2931
initializeJSONEditor();
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Mermaid is loaded on demand from a CDN (like hljs and Alpine in _head.erb)
2+
// so pages without diagrams don't pay for the ~500KB library.
3+
const MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
4+
5+
const ZOOM_MIN = 0.25;
6+
const ZOOM_MAX = 4;
7+
const ZOOM_STEP = 1.25;
8+
9+
function currentTheme() {
10+
return document.documentElement.classList.contains("dark") ? "dark" : "default";
11+
}
12+
13+
function buildDiagramFigure(code) {
14+
const figure = document.createElement("div");
15+
figure.className = "mermaid-figure relative my-6 rounded-md border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800";
16+
17+
const container = document.createElement("pre");
18+
container.className = "mermaid !my-0 !bg-transparent !p-0";
19+
container.dataset.diagram = code.textContent.trim();
20+
container.textContent = container.dataset.diagram;
21+
22+
const fullScreenButton = document.createElement("button");
23+
fullScreenButton.type = "button";
24+
fullScreenButton.textContent = "Full Screen";
25+
fullScreenButton.className = "absolute top-2 right-2 rounded-md border border-slate-200 bg-white px-2 py-1 text-2xs font-medium text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600";
26+
fullScreenButton.addEventListener("click", () => openFullScreen(container));
27+
28+
figure.appendChild(container);
29+
figure.appendChild(fullScreenButton);
30+
return figure;
31+
}
32+
33+
function toolbarButton(label, title) {
34+
const button = document.createElement("button");
35+
button.type = "button";
36+
button.textContent = label;
37+
button.title = title;
38+
button.className = "rounded-md border border-slate-200 bg-white px-3 py-1 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600";
39+
return button;
40+
}
41+
42+
function openFullScreen(container) {
43+
const svg = container.querySelector("svg");
44+
if (!svg) return;
45+
46+
const overlay = document.createElement("div");
47+
overlay.className = "fixed inset-0 z-50 flex flex-col bg-slate-50 dark:bg-slate-900";
48+
49+
const toolbar = document.createElement("div");
50+
toolbar.className = "flex items-center justify-end gap-2 border-b border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800";
51+
52+
const zoomOut = toolbarButton("−", "Zoom out");
53+
const zoomLevel = document.createElement("span");
54+
zoomLevel.className = "w-14 text-center text-sm text-slate-600 dark:text-slate-300";
55+
const zoomIn = toolbarButton("+", "Zoom in");
56+
const reset = toolbarButton("Reset", "Reset zoom");
57+
const close = toolbarButton("Close", "Close full screen (Esc)");
58+
59+
toolbar.append(zoomOut, zoomLevel, zoomIn, reset, close);
60+
61+
const scroller = document.createElement("div");
62+
scroller.className = "flex-1 overflow-auto p-6";
63+
64+
const stage = document.createElement("div");
65+
stage.className = "flex min-h-full min-w-full items-center justify-center w-max";
66+
67+
const clone = svg.cloneNode(true);
68+
// Mermaid caps the inline SVG with a max-width style; drop it so the clone
69+
// can be sized freely for zooming.
70+
const baseWidth = parseFloat(clone.style.maxWidth) || svg.getBoundingClientRect().width;
71+
clone.style.maxWidth = "none";
72+
73+
let zoom = 1;
74+
const applyZoom = () => {
75+
clone.style.width = `${baseWidth * zoom}px`;
76+
zoomLevel.textContent = `${Math.round(zoom * 100)}%`;
77+
};
78+
applyZoom();
79+
80+
zoomIn.addEventListener("click", () => {
81+
zoom = Math.min(zoom * ZOOM_STEP, ZOOM_MAX);
82+
applyZoom();
83+
});
84+
zoomOut.addEventListener("click", () => {
85+
zoom = Math.max(zoom / ZOOM_STEP, ZOOM_MIN);
86+
applyZoom();
87+
});
88+
reset.addEventListener("click", () => {
89+
zoom = 1;
90+
applyZoom();
91+
});
92+
93+
const dismiss = () => {
94+
document.removeEventListener("keydown", onKeyDown);
95+
overlay.remove();
96+
document.body.style.overflow = "";
97+
};
98+
const onKeyDown = (event) => {
99+
if (event.key === "Escape") dismiss();
100+
};
101+
close.addEventListener("click", dismiss);
102+
document.addEventListener("keydown", onKeyDown);
103+
104+
stage.appendChild(clone);
105+
scroller.appendChild(stage);
106+
overlay.append(toolbar, scroller);
107+
document.body.appendChild(overlay);
108+
document.body.style.overflow = "hidden";
109+
}
110+
111+
export function renderMermaidDiagrams() {
112+
// Kramdown renders ```mermaid fences as <pre><code class="language-mermaid">.
113+
// Swap them for the <pre class="mermaid"> containers mermaid.run() expects
114+
// synchronously, before hljs and the copy-button code see them.
115+
const blocks = document.querySelectorAll("pre > code.language-mermaid");
116+
blocks.forEach((code) => {
117+
code.parentNode.replaceWith(buildDiagramFigure(code));
118+
});
119+
120+
if (blocks.length === 0) return;
121+
122+
import(MERMAID_URL)
123+
.then(({ default: mermaid }) => {
124+
let renderedTheme = null;
125+
const run = async () => {
126+
if (renderedTheme === currentTheme()) return;
127+
renderedTheme = currentTheme();
128+
mermaid.initialize({ startOnLoad: false, theme: renderedTheme });
129+
document.querySelectorAll("pre.mermaid").forEach((el) => {
130+
el.removeAttribute("data-processed");
131+
el.textContent = el.dataset.diagram;
132+
});
133+
await mermaid.run();
134+
};
135+
136+
run();
137+
138+
// Re-render when the Alpine theme toggle flips the `dark` class on <html>.
139+
new MutationObserver((mutations) => {
140+
if (mutations.some((m) => m.attributeName === "class")) run();
141+
}).observe(document.documentElement, { attributes: true });
142+
})
143+
.catch((error) => {
144+
// Leave the raw diagram text visible as a fallback.
145+
console.error("Failed to load mermaid:", error);
146+
});
147+
}

src/_partials/_navbar.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<%= render(NavigationSection.new(
3131
"Projects",
3232
children: {
33+
"OBACloud" => '/projects/obacloud',
3334
"onebusaway-gtfs-modules" => '/projects/onebusaway-gtfs-modules'
3435
},
3536
current_path: resource.relative_url

0 commit comments

Comments
 (0)