Commit e17180a

HPCesia <me@hpcesia.com>
2025-10-12 09:46:41
feat: async rehype component
1 parent 2237bda
src/plugins/rehype-components-async.ts
@@ -0,0 +1,114 @@
+import { isElement } from 'hast-util-is-element';
+import type { Element, ElementContent, Parent, Properties, Root, RootContent } from 'hast';
+
+interface ComponentContext {
+  tree: Root;
+  vfile: unknown;
+  processor: unknown;
+}
+
+type ComponentResult =
+  | ElementContent
+  | ElementContent[]
+  | null
+  | undefined
+  | void
+  | Promise<ElementContent | ElementContent[] | null | undefined | void>;
+
+type ComponentFn = (
+  props: Properties,
+  children: ElementContent[],
+  context: ComponentContext
+) => ComponentResult;
+
+interface Options {
+  components?: Record<string, ComponentFn>;
+}
+
+export function rehypeComponentsAsync(options: Options = {}) {
+  const { components = {} } = options;
+
+  return async function transformer(this: unknown, tree: Root, vfile: unknown) {
+    const context: ComponentContext = {
+      tree,
+      vfile,
+      processor: this,
+    };
+
+    await transformNode(tree, components, context);
+  };
+}
+
+async function transformNode(
+  node: Parent,
+  components: Record<string, ComponentFn>,
+  context: ComponentContext
+): Promise<void> {
+  if (!Array.isArray(node.children)) return;
+
+  for (let index = 0; index < node.children.length; index++) {
+  const child = node.children[index] as RootContent;
+    if (!child) continue;
+
+    if (isElement(child)) {
+      const element = child as Element;
+      const component = components[element.tagName];
+
+      if (component) {
+        const props = (element.properties ?? {}) as Properties;
+        const originalChildren = (element.children ?? []) as ElementContent[];
+
+        let returned = component(props, originalChildren, context);
+        if (isPromiseLike(returned)) {
+          returned = await returned;
+        }
+
+        const normalized = normalizeElementContent(returned);
+
+        if (!normalized.every(isElementContentValue)) {
+          throw new Error(
+            `rehype-components: Component function is expected to return ElementContent or an array of ElementContent, but got ${JSON.stringify(normalized)}.`
+          );
+        }
+
+        node.children.splice(index, 1, ...normalized);
+        index += normalized.length - 1;
+        continue;
+      }
+    }
+
+    if (hasChildren(child)) {
+      await transformNode(child, components, context);
+    }
+  }
+}
+
+function normalizeElementContent(
+  value: ElementContent | ElementContent[] | null | undefined | void
+): ElementContent[] {
+  if (value == null) return [];
+
+  return Array.isArray(value)
+    ? value.filter((item): item is ElementContent => item != null)
+    : [value];
+}
+
+function isPromiseLike<T>(value: unknown): value is Promise<T> {
+  return !!value && typeof (value as Promise<T>).then === 'function';
+}
+
+function hasChildren(value: RootContent): value is Parent & RootContent {
+  if (typeof value !== 'object' || value == null) return false;
+  return Array.isArray((value as Parent).children);
+}
+
+function isElementContentValue(value: unknown): value is ElementContent {
+  if (isElement(value)) return true;
+
+  if (typeof value === 'object' && value !== null && 'type' in value) {
+    const type = (value as { type: string }).type;
+    return type === 'text' || type === 'comment';
+  }
+
+  return false;
+}
astro.config.mjs
@@ -2,6 +2,7 @@
 import { buildConfig, siteConfig } from './src/config.ts';
 import { CDN } from './src/constants/cdn.ts';
 import { pluginLanguageBadge } from './src/plugins/expressive-code-lang-badget.ts';
+import { rehypeComponentsAsync } from './src/plugins/rehype-components-async.ts';
 import { rehypeComponentsList } from './src/plugins/rehype-components-list.ts';
 import { rehypeWrapTables } from './src/plugins/rehype-wrap-tables.ts';
 import { remarkArticleReferences } from './src/plugins/remark-article-references';
@@ -23,7 +24,6 @@ import icon from 'astro-icon';
 import pagefind from 'astro-pagefind';
 import { defineConfig } from 'astro/config';
 import rehypeAutolinkHeadings from 'rehype-autolink-headings';
-import rehypeComponents from 'rehype-components';
 import rehypeMathJaxCHtml from 'rehype-mathjax/chtml';
 import remarkDirective from 'remark-directive';
 import remarkDirectiveRehype from 'remark-directive-rehype';
@@ -111,7 +111,7 @@ export default defineConfig({
         },
       ],
       rehypeWrapTables,
-      [rehypeComponents, { components: rehypeComponentsList }],
+  [rehypeComponentsAsync, { components: rehypeComponentsList }],
     ],
   },
   vite: {
package.json
@@ -47,6 +47,7 @@
     "daisyui": "^5.2.3",
     "dayjs": "^1.11.18",
     "hast-util-from-html": "^2.0.3",
+    "hast-util-is-element": "^3.0.0",
     "hastscript": "^9.0.1",
     "markdown-it": "^14.1.0",
     "mdast-util-to-string": "^4.0.0",
pnpm-lock.yaml
@@ -110,6 +110,9 @@ importers:
       hast-util-from-html:
         specifier: ^2.0.3
         version: 2.0.3
+      hast-util-is-element:
+        specifier: ^3.0.0
+        version: 3.0.0
       hastscript:
         specifier: ^9.0.1
         version: 9.0.1