Skip to content

Commit c47a46f

Browse files
authored
Plugin Docs Parser: Reduce dependency footprint (#2659)
1 parent a91b2e8 commit c47a46f

7 files changed

Lines changed: 221 additions & 65 deletions

File tree

package-lock.json

Lines changed: 10 additions & 40 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin-docs-parser/package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,8 @@
4545
},
4646
"dependencies": {
4747
"@types/hast": "^3.0.0",
48-
"gray-matter": "^4.0.3",
49-
"hast-util-to-string": "^3.0.0",
50-
"rehype-raw": "^7.0.0",
48+
"js-yaml": "^4.1.1",
5149
"rehype-sanitize": "^6.0.0",
52-
"rehype-slug": "^6.0.0",
5350
"remark-gfm": "^4.0.0",
5451
"remark-parse": "^11.0.0",
5552
"remark-rehype": "^11.0.0",
@@ -58,6 +55,7 @@
5855
"vfile": "^6.0.0"
5956
},
6057
"devDependencies": {
58+
"@types/js-yaml": "^4.0.9",
6159
"hast-util-to-html": "^9.0.0"
6260
}
6361
}

packages/plugin-docs-parser/src/parser.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Body text here.`;
8686
expect(html).toContain('Title');
8787
});
8888

89-
it('should preserve safe raw HTML elements like <details>', () => {
89+
it('should drop raw HTML elements per the docs spec', () => {
9090
const markdown = `## FAQ
9191
9292
<details>
@@ -99,8 +99,10 @@ It works by parsing markdown.
9999
const result = parseMarkdown(markdown);
100100
const html = toHtml(result.hast);
101101

102-
expect(html).toContain('<details>');
103-
expect(html).toContain('<summary>');
102+
// raw HTML is forbidden by the docs spec, so <details>/<summary> get stripped
103+
expect(html).not.toContain('<details>');
104+
expect(html).not.toContain('<summary>');
105+
// text content inside the raw block is preserved as a regular paragraph
104106
expect(html).toContain('It works by parsing markdown.');
105107
});
106108

packages/plugin-docs-parser/src/parser.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { unified } from 'unified';
22
import remarkParse from 'remark-parse';
33
import remarkGfm from 'remark-gfm';
44
import remarkRehype from 'remark-rehype';
5-
import rehypeRaw from 'rehype-raw';
6-
import rehypeSlug from 'rehype-slug';
75
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
8-
import matter from 'gray-matter';
6+
import * as yaml from 'js-yaml';
97
import { VFile } from 'vfile';
8+
import { rehypeSlug } from './plugins/rehype-slug.js';
109
import type { Root as HastRoot } from 'hast';
1110
import { rehypeRewriteAssetPaths } from './plugins/rehype-rewrite-asset-paths.js';
1211
import { rehypeRewriteDocLinks } from './plugins/rehype-rewrite-doc-links.js';
@@ -63,6 +62,21 @@ export interface ParsedMarkdown {
6362
// the default schema already allows id (via clobber) and className on code (for language-* classes).
6463
const sanitizeSchema = { ...defaultSchema, clobberPrefix: '' };
6564

65+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/;
66+
67+
function parseFrontmatter(content: string): { data: Record<string, unknown>; content: string } {
68+
const match = content.match(FRONTMATTER_RE);
69+
if (!match) {
70+
return { data: {}, content };
71+
}
72+
// yaml.load can return scalars or arrays for malformed frontmatter; coerce
73+
// anything that isn't a plain object back to {} so the public type holds.
74+
const parsed = yaml.load(match[1]);
75+
const data =
76+
parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
77+
return { data, content: match[2] };
78+
}
79+
6680
/**
6781
* Parses markdown content into a HAST tree with extracted frontmatter and headings.
6882
*
@@ -72,30 +86,23 @@ const sanitizeSchema = { ...defaultSchema, clobberPrefix: '' };
7286
* @throws {Error} If markdown parsing fails
7387
*/
7488
export function parseMarkdown(content: string, options?: ParseOptions): ParsedMarkdown {
75-
// extract frontmatter using gray-matter
89+
// extract frontmatter
7690
let frontmatter: Record<string, unknown>;
7791
let markdownContent: string;
7892

7993
try {
80-
const result = matter(content);
94+
const result = parseFrontmatter(content);
8195
frontmatter = result.data;
8296
markdownContent = result.content;
8397
} catch (error) {
8498
const message = error instanceof Error ? error.message : String(error);
8599
throw new Error(`Failed to extract frontmatter: ${message}`);
86100
}
87101

88-
// build the unified pipeline: markdown → mdast → hast
89-
const processor = unified()
90-
.use(remarkParse)
91-
.use(remarkGfm)
92-
// allow raw HTML in markdown (e.g. <details>, <img>) to pass through to hast;
93-
// rehype-raw parses the raw nodes into proper hast elements so
94-
// rehype-sanitize can inspect and strip anything dangerous
95-
.use(remarkRehype, { allowDangerousHtml: true })
96-
.use(rehypeRaw)
97-
.use(rehypeStripH1)
98-
.use(rehypeSlug);
102+
// build the unified pipeline: markdown → mdast → hast.
103+
// raw HTML in markdown is forbidden by the docs spec, so allowDangerousHtml
104+
// is off and any inline HTML gets dropped by remark-rehype.
105+
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeStripH1).use(rehypeSlug);
99106

100107
// rewrite asset paths before sanitization so URLs are final
101108
if (options?.assetBaseUrl) {

packages/plugin-docs-parser/src/plugins/rehype-extract-headings.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import type { Root, Element } from 'hast';
2+
import type { Node } from 'unist';
23
import type { VFile } from 'vfile';
34
import { visit } from 'unist-util-visit';
4-
import { toString } from 'hast-util-to-string';
55
import type { Heading } from '../types.js';
66

7+
function hastToString(node: Node): string {
8+
if ('value' in node) {
9+
return node.value as string;
10+
}
11+
if ('children' in node) {
12+
return (node.children as Node[]).map(hastToString).join('');
13+
}
14+
return '';
15+
}
16+
717
declare module 'vfile' {
818
interface DataMap {
919
headings: Heading[];
@@ -31,7 +41,7 @@ export function rehypeExtractHeadings() {
3141
headings.push({
3242
level: node.tagName === 'h2' ? 2 : 3,
3343
id,
34-
text: toString(node),
44+
text: hastToString(node),
3545
});
3646
});
3747

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { Root, Element, Text, Properties } from 'hast';
3+
import { rehypeSlug } from './rehype-slug.js';
4+
5+
function el(tag: string, text: string, properties: Properties = {}): Element {
6+
const child: Text = { type: 'text', value: text };
7+
return { type: 'element', tagName: tag, properties, children: [child] };
8+
}
9+
10+
function tree(...children: Element[]): Root {
11+
return { type: 'root', children };
12+
}
13+
14+
function getEl(root: Root, index: number): Element {
15+
const child = root.children[index];
16+
if (child.type !== 'element') {
17+
throw new Error(`expected element at index ${index}, got ${child.type}`);
18+
}
19+
return child;
20+
}
21+
22+
describe('rehypeSlug', () => {
23+
const transform = rehypeSlug();
24+
25+
it('should add id attributes to headings', () => {
26+
const root = tree(el('h2', 'Getting Started'), el('h3', 'Prerequisites'));
27+
transform(root);
28+
29+
expect(getEl(root, 0).properties).toMatchObject({ id: 'getting-started' });
30+
expect(getEl(root, 1).properties).toMatchObject({ id: 'prerequisites' });
31+
});
32+
33+
it('should lowercase and strip punctuation when slugifying', () => {
34+
const root = tree(el('h2', "What's New?"));
35+
transform(root);
36+
37+
expect(getEl(root, 0).properties).toMatchObject({ id: 'whats-new' });
38+
});
39+
40+
it('should suffix duplicate headings with -1, -2, ...', () => {
41+
const root = tree(el('h2', 'Setup'), el('h2', 'Setup'), el('h2', 'Setup'));
42+
transform(root);
43+
44+
expect(getEl(root, 0).properties).toMatchObject({ id: 'setup' });
45+
expect(getEl(root, 1).properties).toMatchObject({ id: 'setup-1' });
46+
expect(getEl(root, 2).properties).toMatchObject({ id: 'setup-2' });
47+
});
48+
49+
it('should leave pre-existing ids untouched', () => {
50+
const root = tree(el('h2', 'Setup', { id: 'custom-anchor' }));
51+
transform(root);
52+
53+
expect(getEl(root, 0).properties).toMatchObject({ id: 'custom-anchor' });
54+
});
55+
56+
it('should not count pre-existing ids toward dedupe', () => {
57+
const root = tree(el('h2', 'Setup', { id: 'setup' }), el('h2', 'Setup'));
58+
transform(root);
59+
60+
// the second heading still gets the unsuffixed slug because the visitor
61+
// only tracks ids it has assigned itself
62+
expect(getEl(root, 1).properties).toMatchObject({ id: 'setup' });
63+
});
64+
65+
it('should skip headings that slugify to empty (emoji or punctuation only)', () => {
66+
const root = tree(el('h2', '🎉'), el('h2', '!!!'));
67+
transform(root);
68+
69+
expect(getEl(root, 0).properties).not.toHaveProperty('id');
70+
expect(getEl(root, 1).properties).not.toHaveProperty('id');
71+
});
72+
73+
it('should slugify across all h1-h6 levels', () => {
74+
const root = tree(el('h1', 'Title'), el('h4', 'Deep'), el('h6', 'Deeper'));
75+
transform(root);
76+
77+
expect(getEl(root, 0).properties).toMatchObject({ id: 'title' });
78+
expect(getEl(root, 1).properties).toMatchObject({ id: 'deep' });
79+
expect(getEl(root, 2).properties).toMatchObject({ id: 'deeper' });
80+
});
81+
82+
it('should ignore non-heading elements', () => {
83+
const root = tree(el('p', 'A paragraph'), el('div', 'A div'));
84+
transform(root);
85+
86+
expect(getEl(root, 0).properties).not.toHaveProperty('id');
87+
expect(getEl(root, 1).properties).not.toHaveProperty('id');
88+
});
89+
90+
it('should concatenate text from nested children', () => {
91+
const inner: Element = {
92+
type: 'element',
93+
tagName: 'code',
94+
properties: {},
95+
children: [{ type: 'text', value: 'parseMarkdown' } as Text],
96+
};
97+
const heading: Element = {
98+
type: 'element',
99+
tagName: 'h2',
100+
properties: {},
101+
children: [{ type: 'text', value: 'The ' } as Text, inner, { type: 'text', value: ' function' } as Text],
102+
};
103+
const root = tree(heading);
104+
transform(root);
105+
106+
expect(getEl(root, 0).properties).toMatchObject({ id: 'the-parsemarkdown-function' });
107+
});
108+
109+
it('should handle an empty tree', () => {
110+
const root = tree();
111+
transform(root);
112+
113+
expect(root.children).toHaveLength(0);
114+
});
115+
});

0 commit comments

Comments
 (0)