Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"katex": "^0.16.21",
"kleur": "^4.1.5",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.5.0",
"nanostores": "^0.11.4",
"octokit": "^4.1.2",
"react": "^19.0.0",
Expand Down
853 changes: 839 additions & 14 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,11 @@ astro-island[component-export="GraphQLEditor"] {
}
}

/* Using attribute selector instead of :has() for better compatibility */
figure pre[data-language="mermaid"] {
display: none;
}

header.header {
padding-top: 0;
padding-bottom: 0;
Expand Down
80 changes: 80 additions & 0 deletions src/lib/mermaid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { cssId } from "./random/cssId";
import { ensureNonNullable } from "~/lib/ensureNonNullable";
import type { Theme } from "~/lib/theme";
import { $theme } from "~/stores/theme";
import { createIntersectionObserverAtom } from "~/lib/nanostores/createIntersectionObserverAtom";
import { effect } from "~/lib/nanostores/effect";

interface MermaidOptions {
id: string;
container: HTMLElement;
graph: string;
theme: Theme;
}

async function renderMermaid({ id, container, graph, theme }: MermaidOptions) {
const { default: mermaid } = await import("mermaid");

try {
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
fontFamily: "inherit",
themeCSS: "margin: 1.5rem auto 0;",
theme: theme === "dark" ? "dark" : "default",
});

container.innerHTML = (await mermaid.render(id, graph)).svg;
} catch (error) {
// eslint-disable-next-line no-console -- show error
console.error("Error while rendering mermaid", error);
}
}

function upsertGraphContainer(element: HTMLDivElement) {
let container: HTMLDivElement | null = element.querySelector(".mermaid-graph");

if (!container) {
container = document.createElement("div");
container.classList.add("mermaid-graph");
element.appendChild(container);
}

return container;
}

export function processMermaidExpressiveCodeBlock() {
const expressiveCodeBlocks = document.querySelectorAll<HTMLDivElement>(".expressive-code");

for (const codeBlock of expressiveCodeBlocks) {
const { $atom: $diagramIsVisible, intersectionObserver } = createIntersectionObserverAtom({
element: codeBlock,
initialValue: false,
mapper: (entry) => entry.isIntersecting,
observerOptions: {
rootMargin: "0px 0px 100% 0px", // Trigger when it is on the next screen
},
});
const id = cssId();

effect([$diagramIsVisible, $theme], (diagramIsVisible, theme) => {
if (!diagramIsVisible) {
return;
}

// Disconnect the observer once the diagram is visible
// $diagramIsVisible will never change after this point
intersectionObserver?.disconnect();

const container = upsertGraphContainer(codeBlock);
const copyButton = ensureNonNullable(
codeBlock.querySelector<HTMLButtonElement>(".copy button"),
);
const graphData = (copyButton.dataset.code ?? "")
.replace(/\u007F/g, "\n")
.replaceAll("\\n", "\n");

void renderMermaid({ id, theme, graph: graphData, container });
});
}
}
49 changes: 49 additions & 0 deletions src/lib/nanostores/createIntersectionObserverAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { atom, onMount, type ReadableAtom } from "nanostores";

import { effect } from "./effect";
import { type ToAtom, toAtom } from "./toAtom";

interface IntersectionObserverAtomOptions<E extends HTMLElement, Mapped> {
element: ToAtom<E | null>;
initialValue: Mapped;
mapper: (entry: IntersectionObserverEntry) => Mapped;
observerOptions?: ReadableAtom<IntersectionObserverInit> | IntersectionObserverInit;
}

export function createIntersectionObserverAtom<E extends HTMLElement, Mapped>({
element,
initialValue,
mapper,
observerOptions,
}: IntersectionObserverAtomOptions<E, Mapped>): {
$atom: ReadableAtom<Mapped>;
intersectionObserver: IntersectionObserver | null;
} {
const $result = atom(initialValue);
const $element = toAtom(element);
const $observerOptions = toAtom(observerOptions);
let intersectionObserver: IntersectionObserver | null = null;

onMount($result, () => {
return effect([$element, $observerOptions], (element, observerOptions) => {
if (!element) {
return;
}

intersectionObserver = new IntersectionObserver((entries) => {
const entry = entries[0];

if (entry) {
$result.set(mapper(entry));
}
}, observerOptions);
intersectionObserver.observe(element);

return () => {
intersectionObserver?.disconnect();
};
});
});

return { $atom: $result, intersectionObserver };
}
42 changes: 42 additions & 0 deletions src/lib/nanostores/effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import type { AnyStore, Store, StoreValue } from "nanostores";

export type StoreValues<Stores extends AnyStore[]> = {
[Index in keyof Stores]: StoreValue<Stores[Index]>;
};

export function effect<S extends Store>(
store: S,
callback: (value: StoreValue<S>) => VoidFunction | void,
): VoidFunction;
export function effect<Stores extends Store[]>(
stores: [...Stores],
callback: (...values: StoreValues<Stores>) => VoidFunction | void,
): VoidFunction;
export function effect<Stores extends Store[]>(
stores: [...Stores],
callback: (...values: StoreValues<Stores>) => VoidFunction | void,
): VoidFunction {
const storesArr = (Array.isArray(stores) ? stores : [stores]) as [...Stores];
let storesUnsubscribe: VoidFunction[] = [];
let callbackUnsubscribe: VoidFunction | void = undefined;

function runCallback() {
callbackUnsubscribe?.();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const values = storesArr.map((store) => store.get()) as StoreValues<Stores>;
callbackUnsubscribe = callback(...values);
}

function unsubscribe() {
for (const unsubscribe of storesUnsubscribe) {
unsubscribe();
}
callbackUnsubscribe?.();
}

storesUnsubscribe = storesArr.map((store) => store.listen(runCallback));
runCallback();

return unsubscribe;
}
11 changes: 11 additions & 0 deletions src/lib/nanostores/isAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Atom } from "nanostores";

export function isAtom(value: unknown): value is Atom<unknown> {
return (
typeof value === "object" &&
value !== null &&
"get" in value &&
"listen" in value &&
"subscribe" in value
);
}
9 changes: 9 additions & 0 deletions src/lib/nanostores/toAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type Atom, atom } from "nanostores";

import { isAtom } from "./isAtom";

export type ToAtom<V> = Atom<V> | V;

export function toAtom<V>(value: ToAtom<V>): Atom<V> {
return isAtom(value) ? value : atom(value);
}
12 changes: 12 additions & 0 deletions src/lib/random/cssId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const alphabet = "abcdefghijklmnopqrstuvwxyz";

export function cssId(length = 20) {
const randomArray = crypto.getRandomValues(new Uint8Array(length));
let id = "";

for (const i of randomArray) {
id += alphabet[i % alphabet.length] ?? "";
}

return id;
}
8 changes: 6 additions & 2 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type Theme = "light" | "dark";
export type Theme = "light" | "dark";

function getCurrentTheme(): Theme {
export function getCurrentTheme(): Theme {
return (document.documentElement.dataset.theme ?? "light") as Theme;
}

Expand All @@ -20,4 +20,8 @@ export function observeThemeChange(cb: (theme: Theme) => void) {

// Initial value
cb(getCurrentTheme());

return () => {
observer.disconnect();
};
}
6 changes: 6 additions & 0 deletions src/starlight-overrides/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ const ogImageUrl = await getImageUrl(new URL("/api/og-image.png", Astro.site), {
}
<meta name="twitter:title" content={entryData.title} />
{entryData.description && <meta name="twitter:description" content={entryData.description} />}
<script>
import { processMermaidExpressiveCodeBlock } from "~/lib/mermaid";

document.addEventListener("DOMContentLoaded", () => {
processMermaidExpressiveCodeBlock();
});
</script>
<!-- <style is:global>
::view-transition-old(root),
::view-transition-new(root) {
Expand Down
10 changes: 10 additions & 0 deletions src/stores/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { atom, onMount } from "nanostores";
import { getCurrentTheme, observeThemeChange } from "~/lib/theme";

export const $theme = atom(getCurrentTheme());

onMount($theme, () => {
return observeThemeChange((theme) => {
$theme.set(theme);
});
});