master
1import { isElement } from 'hast-util-is-element';
2import type { Element, ElementContent, Parent, Properties, Root, RootContent } from 'hast';
3
4interface ComponentContext {
5 tree: Root;
6 vfile: unknown;
7 processor: unknown;
8}
9
10type ComponentResult =
11 | ElementContent
12 | ElementContent[]
13 | null
14 | undefined
15 | void
16 | Promise<ElementContent | ElementContent[] | null | undefined | void>;
17
18type ComponentFn = (
19 props: Properties,
20 children: ElementContent[],
21 context: ComponentContext
22) => ComponentResult;
23
24interface Options {
25 components?: Record<string, ComponentFn>;
26}
27
28export function rehypeComponentsAsync(options: Options = {}) {
29 const { components = {} } = options;
30
31 return async function transformer(this: unknown, tree: Root, vfile: unknown) {
32 const context: ComponentContext = {
33 tree,
34 vfile,
35 processor: this,
36 };
37
38 await transformNode(tree, components, context);
39 };
40}
41
42async function transformNode(
43 node: Parent,
44 components: Record<string, ComponentFn>,
45 context: ComponentContext
46): Promise<void> {
47 if (!Array.isArray(node.children)) return;
48
49 for (let index = 0; index < node.children.length; index++) {
50 const child = node.children[index] as RootContent;
51 if (!child) continue;
52
53 if (isElement(child)) {
54 const element = child as Element;
55 const component = components[element.tagName];
56
57 if (component) {
58 const props = (element.properties ?? {}) as Properties;
59 const originalChildren = (element.children ?? []) as ElementContent[];
60
61 let returned = component(props, originalChildren, context);
62 if (isPromiseLike(returned)) {
63 returned = await returned;
64 }
65
66 const normalized = normalizeElementContent(returned);
67
68 if (!normalized.every(isElementContentValue)) {
69 throw new Error(
70 `rehype-components: Component function is expected to return ElementContent or an array of ElementContent, but got ${JSON.stringify(normalized)}.`
71 );
72 }
73
74 node.children.splice(index, 1, ...normalized);
75 index += normalized.length - 1;
76 continue;
77 }
78 }
79
80 if (hasChildren(child)) {
81 await transformNode(child, components, context);
82 }
83 }
84}
85
86function normalizeElementContent(
87 value: ElementContent | ElementContent[] | null | undefined | void
88): ElementContent[] {
89 if (value == null) return [];
90
91 return Array.isArray(value)
92 ? value.filter((item): item is ElementContent => item != null)
93 : [value];
94}
95
96function isPromiseLike<T>(value: unknown): value is Promise<T> {
97 return !!value && typeof (value as Promise<T>).then === 'function';
98}
99
100function hasChildren(value: RootContent): value is Parent & RootContent {
101 if (typeof value !== 'object' || value == null) return false;
102 return Array.isArray((value as Parent).children);
103}
104
105function isElementContentValue(value: unknown): value is ElementContent {
106 if (isElement(value)) return true;
107
108 if (typeof value === 'object' && value !== null && 'type' in value) {
109 const type = (value as { type: string }).type;
110 return type === 'text' || type === 'comment';
111 }
112
113 return false;
114}