-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
/
rehype-optimize-static.ts
302 lines (266 loc) · 11.3 KB
/
rehype-optimize-static.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import type { RehypePlugin } from '@astrojs/markdown-remark';
import { SKIP, visit } from 'estree-util-visit';
import type { Element, RootContent, RootContentMap } from 'hast';
import { toHtml } from 'hast-util-to-html';
import type { MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'mdast-util-mdx-jsx';
// This import includes ambient types for hast to include mdx nodes
import type {} from 'mdast-util-mdx';
// Alias as the main hast node
type Node = RootContent;
// Nodes that have the `children` property
type ParentNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast;
// Nodes that can have its children optimized as a single HTML string
type OptimizableNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast;
export interface OptimizeOptions {
ignoreElementNames?: string[];
}
interface ElementMetadata {
parent: ParentNode;
index: number;
}
const exportConstComponentsRe = /export\s+const\s+components\s*=/;
/**
* For MDX only, collapse static subtrees of the hast into `set:html`. Subtrees
* do not include any MDX elements.
*
* This optimization reduces the JS output as more content are represented as a
* string instead, which also reduces the AST size that Rollup holds in memory.
*/
export const rehypeOptimizeStatic: RehypePlugin<[OptimizeOptions?]> = (options) => {
return (tree) => {
// A set of non-static components to avoid collapsing when walking the tree
// as they need to be preserved as JSX to be rendered dynamically.
const ignoreElementNames = new Set<string>(options?.ignoreElementNames);
// Find `export const components = { ... }` and get it's object's keys to be
// populated into `ignoreElementNames`. This configuration is used to render
// some HTML elements as custom components, and we also want to avoid collapsing them.
for (const child of tree.children) {
if (child.type === 'mdxjsEsm' && exportConstComponentsRe.test(child.value)) {
const keys = getExportConstComponentObjectKeys(child);
if (keys) {
for (const key of keys) {
ignoreElementNames.add(key);
}
}
break;
}
}
// All possible elements that could be the root of a subtree
const allPossibleElements = new Set<OptimizableNode>();
// The current collapsible element stack while traversing the tree
const elementStack: Node[] = [];
// Metadata used by `findElementGroups` later
const elementMetadatas = new WeakMap<OptimizableNode, ElementMetadata>();
/**
* A non-static node causes all its parents to be non-optimizable
*/
const isNodeNonStatic = (node: Node) => {
return (
node.type.startsWith('mdx') ||
// @ts-expect-error `node` should never have `type: 'root'`, but in some cases plugins may inject it as children,
// which MDX will render as a fragment instead (an MDX fragment is a `mdxJsxFlowElement` type).
node.type === 'root' ||
// @ts-expect-error Access `.tagName` naively for perf
ignoreElementNames.has(node.tagName)
);
};
visit(tree as any, {
// @ts-expect-error Force coerce node as hast node
enter(node: Node, key, index, parents: ParentNode[]) {
// `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue
// if it's traversing the root, or the `children` key.
if (key != null && key !== 'children') return SKIP;
// Mutate `node` as a normal hast element node if it's a plain MDX node, e.g. `<kbd>something</kbd>`
simplifyPlainMdxComponentNode(node, ignoreElementNames);
// For nodes that are not static, eliminate all elements in the `elementStack` from the
// `allPossibleElements` set.
if (isNodeNonStatic(node)) {
for (const el of elementStack) {
allPossibleElements.delete(el as OptimizableNode);
}
// Micro-optimization: While this destroys the meaning of an element
// stack for this node, things will still work but we won't repeatedly
// run the above for other nodes anymore. If this is confusing, you can
// comment out the code below when reading.
elementStack.length = 0;
}
// For possible subtree root nodes, record them in `elementStack` and
// `allPossibleElements` to be used in the "leave" hook below.
if (node.type === 'element' || isMdxComponentNode(node)) {
elementStack.push(node);
allPossibleElements.add(node);
if (index != null && node.type === 'element') {
// Record metadata for element node to be used for grouping analysis later
elementMetadatas.set(node, { parent: parents[parents.length - 1], index });
}
}
},
// @ts-expect-error Force coerce node as hast node
leave(node: Node, key, _, parents: ParentNode[]) {
// `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue
// if it's traversing the root, or the `children` key.
if (key != null && key !== 'children') return SKIP;
// Do the reverse of the if condition above, popping the `elementStack`,
// and consolidating `allPossibleElements` as a subtree root.
if (node.type === 'element' || isMdxComponentNode(node)) {
elementStack.pop();
// Many possible elements could be part of a subtree, in order to find
// the root, we check the parent of the element we're popping. If the
// parent exists in `allPossibleElements`, then we're definitely not
// the root, so remove ourselves. This will work retroactively as we
// climb back up the tree.
const parent = parents[parents.length - 1];
if (allPossibleElements.has(parent)) {
allPossibleElements.delete(node);
}
}
},
});
// Within `allPossibleElements`, element nodes are often siblings and instead of setting `set:html`
// on each of the element node, we can create a `<Fragment set:html="...">` element that includes
// all element nodes instead, simplifying the output.
const elementGroups = findElementGroups(allPossibleElements, elementMetadatas, isNodeNonStatic);
// For all possible subtree roots, collapse them into `set:html` and
// strip of their children
for (const el of allPossibleElements) {
// Avoid adding empty `set:html` attributes if there's no children
if (el.children.length === 0) continue;
if (isMdxComponentNode(el)) {
el.attributes.push({
type: 'mdxJsxAttribute',
name: 'set:html',
value: toHtml(el.children),
});
} else {
el.properties['set:html'] = toHtml(el.children);
}
el.children = [];
}
// For each element group, we create a new `<Fragment />` MDX node with `set:html` of the children
// serialized as HTML. We insert this new fragment, replacing all the group children nodes.
// We iterate in reverse to avoid changing the index of groups of the same parent.
for (let i = elementGroups.length - 1; i >= 0; i--) {
const group = elementGroups[i];
const fragmentNode: MdxJsxFlowElementHast = {
type: 'mdxJsxFlowElement',
name: 'Fragment',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'set:html',
value: toHtml(group.children),
},
],
children: [],
};
group.parent.children.splice(group.startIndex, group.children.length, fragmentNode);
}
};
};
interface ElementGroup {
parent: ParentNode;
startIndex: number;
children: Node[];
}
/**
* Iterate through `allPossibleElements` and find elements that are siblings, and return them. `allPossibleElements`
* will be mutated to exclude these grouped elements.
*/
function findElementGroups(
allPossibleElements: Set<OptimizableNode>,
elementMetadatas: WeakMap<OptimizableNode, ElementMetadata>,
isNodeNonStatic: (node: Node) => boolean,
): ElementGroup[] {
const elementGroups: ElementGroup[] = [];
for (const el of allPossibleElements) {
// Non-static nodes can't be grouped. It can only optimize its static children.
if (isNodeNonStatic(el)) continue;
// Get the metadata for the element node, this should always exist
const metadata = elementMetadatas.get(el);
if (!metadata) {
throw new Error(
'Internal MDX error: rehype-optimize-static should have metadata for element node',
);
}
// For this element, iterate through the next siblings and add them to this array
// if they are text nodes or elements that are in `allPossibleElements` (optimizable).
// If one of the next siblings don't match the criteria, break the loop as others are no longer siblings.
const groupableElements: Node[] = [el];
for (let i = metadata.index + 1; i < metadata.parent.children.length; i++) {
const node = metadata.parent.children[i];
// If the node is non-static, we can't group it with the current element
if (isNodeNonStatic(node)) break;
if (node.type === 'element') {
// This node is now (presumably) part of a group, remove it from `allPossibleElements`
const existed = allPossibleElements.delete(node);
// If this node didn't exist in `allPossibleElements`, it's likely that one of its children
// are non-static, hence this node can also not be grouped. So we break out here.
if (!existed) break;
}
groupableElements.push(node);
}
// If group elements are more than one, add them to the `elementGroups`.
// Grouping is most effective if there's multiple elements in it.
if (groupableElements.length > 1) {
elementGroups.push({
parent: metadata.parent,
startIndex: metadata.index,
children: groupableElements,
});
// The `el` is also now part of a group, remove it from `allPossibleElements`
allPossibleElements.delete(el);
}
}
return elementGroups;
}
function isMdxComponentNode(node: Node): node is MdxJsxFlowElementHast | MdxJsxTextElementHast {
return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement';
}
/**
* Get the object keys from `export const components`
*
* @example
* `export const components = { foo, bar: Baz }`, returns `['foo', 'bar']`
*/
function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) {
const exportNamedDeclaration = node.data?.estree?.body[0];
if (exportNamedDeclaration?.type !== 'ExportNamedDeclaration') return;
const variableDeclaration = exportNamedDeclaration.declaration;
if (variableDeclaration?.type !== 'VariableDeclaration') return;
const variableInit = variableDeclaration.declarations[0]?.init;
if (variableInit?.type !== 'ObjectExpression') return;
const keys: string[] = [];
for (const propertyNode of variableInit.properties) {
if (propertyNode.type === 'Property' && propertyNode.key.type === 'Identifier') {
keys.push(propertyNode.key.name);
}
}
return keys;
}
/**
* Some MDX nodes are simply `<kbd>something</kbd>` which isn't needed to be completely treated
* as an MDX node. This function tries to mutate this node as a simple hast element node if so.
*/
function simplifyPlainMdxComponentNode(node: Node, ignoreElementNames: Set<string>) {
if (
!isMdxComponentNode(node) ||
// Attributes could be dynamic, so bail if so.
node.attributes.length > 0 ||
// Fragments are also dynamic
!node.name ||
// Ignore if the node name is in the ignore list
ignoreElementNames.has(node.name) ||
// If the node name has uppercase characters, it's likely an actual MDX component
node.name.toLowerCase() !== node.name
) {
return;
}
// Mutate as hast element node
const newNode = node as unknown as Element;
newNode.type = 'element';
newNode.tagName = node.name;
newNode.properties = {};
// @ts-expect-error Delete mdx-specific properties
node.attributes = undefined;
node.data = undefined;
}