diff --git a/src/components/index.ts b/src/components/index.ts
index 9719c58..11b8faf 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -4,7 +4,7 @@ import "./md-table";
import "./md-link";
import "./md-pins";
import "./md-bg";
-import "./md-emfont";
+import "./md-font";
import "./md-deck";
import "./md-commander/index";
import "./md-yarn-spinner";
@@ -22,7 +22,7 @@ export { FileTreeNode, HeadingNode } from "./FileTree";
export type { DiceProps } from "./md-dice";
export type { TableProps } from "./md-table";
export type { BgProps } from "./md-bg";
-export type { EmfontProps } from "./md-emfont";
+export type { FontProps } from "./md-font";
export type { YarnSpinnerProps } from "./md-yarn-spinner";
export type { TokenProps } from "./md-token";
diff --git a/src/components/md-bg.tsx b/src/components/md-bg.tsx
index 707393c..d501dbf 100644
--- a/src/components/md-bg.tsx
+++ b/src/components/md-bg.tsx
@@ -1,43 +1,39 @@
import { customElement, noShadowDOM } from "solid-element";
-import {createEffect, onCleanup} from "solid-js";
+import { onCleanup } from "solid-js";
import { resolvePath } from "./utils/path";
+import { registerStyle } from "./utils/article-style-manager";
export interface BgProps {
- fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
+ fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
}
customElement("md-bg", { fit: "cover" }, (props, { element }) => {
noShadowDOM();
- // 从 element 的 textContent 获取图片路径
- const rawSrc = element?.textContent?.trim() || '';
+ const rawSrc = element?.textContent?.trim() || "";
- // 隐藏原始文本内容
if (element) {
element.textContent = "";
}
- // 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
- const articleEl = element?.closest('article') as HTMLElement;
- const articlePath = element?.closest('article[data-src]')?.getAttribute('data-src') || '';
+ const articleEl = element?.closest("article") as HTMLElement;
+ const articlePath =
+ element?.closest("article[data-src]")?.getAttribute("data-src") || "";
- // 解析相对路径
const resolvedSrc = resolvePath(articlePath, rawSrc);
- createEffect(() => {
- if(!articleEl) return;
- articleEl.dataset.mdbg = resolvedSrc;
- articleEl.style.backgroundImage = `url(${resolvedSrc})`;
- articleEl.style.backgroundSize = props.fit || 'cover';
- articleEl.style.backgroundPosition = 'center';
- articleEl.style.backgroundRepeat = 'no-repeat';
- });
-
- onCleanup(() => {
- if(articleEl?.dataset?.mdbg !== resolvedSrc)
- return;
- articleEl.style.backgroundImage = '';
+ const handle = registerStyle({
+ key: "backgroundImage",
+ article: articleEl,
+ styles: {
+ backgroundImage: `url(${resolvedSrc})`,
+ backgroundSize: props.fit || "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ } as any,
});
+ onCleanup(() => handle.dispose());
+
return null;
});
diff --git a/src/components/md-emfont.tsx b/src/components/md-emfont.tsx
deleted file mode 100644
index 649326b..0000000
--- a/src/components/md-emfont.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { customElement, noShadowDOM } from "solid-element";
-import { createEffect, onCleanup } from "solid-js";
-
-export interface EmfontProps {
- weight?: string;
-}
-
-customElement("md-emfont", { weight: "400" }, (props, { element }) => {
- noShadowDOM();
-
- // 从 element 的 textContent 获取字体名称
- const font = element?.textContent?.trim() || "";
-
- // 隐藏原始文本内容
- if (element) {
- element.textContent = "";
- }
-
- // 从父节点 article 获取当前 article 元素
- const articleEl = element?.closest("article") as HTMLElement;
-
- // 构建 emfont CSS URL
- const weight = props.weight || "400";
- const linkHref = `https://font.emtech.cc/css/${font}?weight=${weight}`;
-
- // 避免重复加载:检查是否已有相同字体的
- let linkEl = document.head.querySelector(
- `link[data-md-emfont="${font}"]`,
- ) as HTMLLinkElement | null;
-
- if (!linkEl) {
- linkEl = document.createElement("link");
- linkEl.rel = "stylesheet";
- linkEl.href = linkHref;
- linkEl.dataset.mdEmfont = font;
- document.head.appendChild(linkEl);
- }
-
- createEffect(() => {
- if (!articleEl) return;
- articleEl.dataset.mdEmfont = font;
- articleEl.style.fontFamily = font;
- });
-
- onCleanup(() => {
- // 清理 article 上的样式
- if (articleEl?.dataset?.mdEmfont === font) {
- delete articleEl.dataset.mdEmFont;
- }
- });
-
- return null;
-});
diff --git a/src/components/md-font.tsx b/src/components/md-font.tsx
new file mode 100644
index 0000000..ac558e1
--- /dev/null
+++ b/src/components/md-font.tsx
@@ -0,0 +1,63 @@
+import { customElement, noShadowDOM } from "solid-element";
+import { onCleanup } from "solid-js";
+import { registerStyle } from "./utils/article-style-manager";
+
+export interface FontProps {
+ /** Font source: "google" (Google Fonts), "emfont" (emtech.cc),
+ * or "local" (already loaded, just set fontFamily) */
+ source?: "google" | "emfont" | "local";
+ /** Font weight (for google and emfont sources) */
+ weight?: string;
+}
+
+function buildLinkHref(
+ font: string,
+ source: FontProps["source"],
+ weight: string,
+): string | null {
+ if (source === "google") {
+ const family = font.replace(/\s+/g, "+");
+ return `https://fonts.googleapis.com/css2?family=${family}:wght@${weight}&display=swap`;
+ }
+ if (source === "emfont") {
+ return `https://font.emtech.cc/css/${font}?weight=${weight}`;
+ }
+ return null;
+}
+
+customElement(
+ "md-font",
+ { source: "local", weight: "400" },
+ (props, { element }) => {
+ noShadowDOM();
+
+ const font = element?.textContent?.trim() || "";
+
+ // Hide original text content
+ if (element) {
+ element.textContent = "";
+ }
+
+ const articleEl = element?.closest("article") as HTMLElement;
+ if (!articleEl) return null;
+
+ const source = (props.source as FontProps["source"]) || "google";
+ const weight = props.weight || "400";
+
+ const href = buildLinkHref(font, source, weight);
+ const links = href
+ ? [{ href, dataset: { mdFontSource: source, mdFontName: font } }]
+ : [];
+
+ const handle = registerStyle({
+ key: "fontFamily",
+ article: articleEl,
+ styles: { fontFamily: `"${font}", sans-serif` },
+ links,
+ });
+
+ onCleanup(() => handle.dispose());
+
+ return null;
+ },
+);
diff --git a/src/components/utils/article-style-manager.ts b/src/components/utils/article-style-manager.ts
new file mode 100644
index 0000000..121c1b4
--- /dev/null
+++ b/src/components/utils/article-style-manager.ts
@@ -0,0 +1,181 @@
+/**
+ * ArticleStyleManager — centralized lifecycle manager for article-level
+ * style mutations (fontFamily, backgroundImage, etc.) and external
+ * stylesheet injection with reference counting.
+ *
+ * Each md-* component registers a StyleRegistration and gets back a
+ * StyleHandle. On disposal, only that component's contribution is
+ * removed; if a previous registration for the same key is still active,
+ * its styles are restored.
+ */
+
+type StyleKey = string;
+
+interface StyleRegistration {
+ /** Unique namespace key (e.g. "fontFamily", "backgroundImage") */
+ key: StyleKey;
+ /** Target article element */
+ article: HTMLElement;
+ /** CSS properties to apply to the article */
+ styles: Partial;
+ /**
+ * External stylesheets to inject into .
+ * Reference-counted: removed when no registrations reference it.
+ */
+ links?: Array<{ href: string; dataset: Record }>;
+ /** Called when this registration becomes active */
+ onActivate?: () => void;
+ /** Called when this registration is disposed */
+ onDeactivate?: () => void;
+}
+
+interface StyleHandle {
+ dispose(): void;
+}
+
+/** Per-key stack of registrations for a single article */
+interface ArticleRegistry {
+ article: HTMLElement;
+ stacks: Map;
+}
+
+const articleRegistries = new Map();
+/** href -> number of active registrations referencing it */
+const linkRefCounts = new Map();
+/** href -> element */
+const linkElements = new Map();
+
+function getRegistry(article: HTMLElement): ArticleRegistry {
+ let reg = articleRegistries.get(article);
+ if (!reg) {
+ reg = { article, stacks: new Map() };
+ articleRegistries.set(article, reg);
+ }
+ return reg;
+}
+
+function applyStyles(article: HTMLElement, styles: Partial) {
+ for (const [prop, value] of Object.entries(styles)) {
+ if (value !== undefined && value !== null) {
+ (article.style as any)[prop] = value;
+ }
+ }
+}
+
+function clearStyles(article: HTMLElement, styles: Partial) {
+ for (const prop of Object.keys(styles)) {
+ (article.style as any)[prop] = "";
+ }
+}
+
+function incrementLinkRef(href: string, dataset: Record) {
+ const count = linkRefCounts.get(href) || 0;
+ if (count === 0) {
+ const linkEl = document.createElement("link");
+ linkEl.rel = "stylesheet";
+ linkEl.href = href;
+ for (const [key, val] of Object.entries(dataset)) {
+ linkEl.dataset[key] = val;
+ }
+ document.head.appendChild(linkEl);
+ linkElements.set(href, linkEl);
+ }
+ linkRefCounts.set(href, count + 1);
+}
+
+function decrementLinkRef(href: string) {
+ const count = linkRefCounts.get(href);
+ if (count === undefined) return;
+ if (count <= 1) {
+ const linkEl = linkElements.get(href);
+ linkEl?.remove();
+ linkRefCounts.delete(href);
+ linkElements.delete(href);
+ } else {
+ linkRefCounts.set(href, count - 1);
+ }
+}
+
+// Expose debug info on articles
+function updateDebugData(registry: ArticleRegistry) {
+ const activeKeys: string[] = [];
+ for (const [key, stack] of registry.stacks) {
+ if (stack.length > 0) {
+ activeKeys.push(key);
+ }
+ }
+ if (activeKeys.length > 0) {
+ registry.article.dataset.mdStyles = activeKeys.join(",");
+ } else {
+ delete registry.article.dataset.mdStyles;
+ if (registry.stacks.size === 0) {
+ articleRegistries.delete(registry.article);
+ }
+ }
+}
+
+export function registerStyle(reg: StyleRegistration): StyleHandle {
+ const registry = getRegistry(reg.article);
+ let stack = registry.stacks.get(reg.key);
+ if (!stack) {
+ stack = [];
+ registry.stacks.set(reg.key, stack);
+ }
+
+ stack.push(reg);
+
+ // Increment link refcounts
+ for (const link of reg.links || []) {
+ incrementLinkRef(link.href, link.dataset);
+ }
+
+ // If this is the active (top) registration, apply its styles
+ if (stack[stack.length - 1] === reg) {
+ applyStyles(reg.article, reg.styles);
+ reg.onActivate?.();
+ }
+
+ updateDebugData(registry);
+
+ let disposed = false;
+
+ return {
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+
+ // Remove this registration from its stack
+ const currentStack = registry.stacks.get(reg.key);
+ if (!currentStack) return;
+
+ const idx = currentStack.lastIndexOf(reg);
+ if (idx === -1) return;
+
+ const wasActive = idx === currentStack.length - 1;
+ currentStack.splice(idx, 1);
+
+ if (currentStack.length === 0) {
+ registry.stacks.delete(reg.key);
+ }
+
+ // If this was the active registration, restore previous or clear
+ if (wasActive) {
+ clearStyles(reg.article, reg.styles);
+ const prev = currentStack.length > 0 ? currentStack[currentStack.length - 1] : null;
+ if (prev) {
+ applyStyles(reg.article, prev.styles);
+ prev.onActivate?.();
+ }
+ }
+
+ reg.onDeactivate?.();
+
+ // Decrement link refcounts
+ for (const link of reg.links || []) {
+ decrementLinkRef(link.href);
+ }
+
+ updateDebugData(registry);
+ },
+ };
+}