Skip to content

Commit 0cb63a8

Browse files
authored
feat: add signature metadata handling for improved documentation extractio (#2435)
1 parent b6ca7cd commit 0cb63a8

3 files changed

Lines changed: 109 additions & 7 deletions

File tree

scripts/orama-documents.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { fileURLToPath, URL } from 'node:url';
44
import matter from 'gray-matter';
55
import { fromMarkdown } from 'mdast-util-from-markdown';
66
import { toString } from 'mdast-util-to-string';
7+
import { signatureMetaToText } from '../src/utils/signature-meta.mjs';
78

89
const CONTENT_DIR = fileURLToPath(new URL('../src/content', import.meta.url));
910

1011
const stripMdxImports = (content) => content.replace(/^import\s+.*$/gm, '');
1112

13+
// Strip HTML/JSX tags, then drop any leftover `<` that could still start a tag
14+
// (e.g. the one `<<a>script>` reconstructs). After the second pass no `<` precedes
15+
// a letter, so no tag-like content survives, while comparison text such as
16+
// `<21 || >=22` is preserved.
17+
const stripTags = (text) =>
18+
text.replace(/<\/?[A-Za-z][^>]*>/g, '').replace(/<(?=\/?[A-Za-z])/g, '');
19+
1220
const mdToText = (content) =>
13-
toString(fromMarkdown(stripMdxImports(content))).replace(/<[^>]*>/g, '');
21+
stripTags(toString(fromMarkdown(signatureMetaToText(stripMdxImports(content)))));
1422

1523
// Build the public path segment from a content-relative file path: drop the
1624
// extension and any trailing `index` so `foo/index.mdx` -> `foo`. This matches

src/utils/llms.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs/promises';
22
import matter from 'gray-matter';
3+
import { JSX_ATTRS, signatureMetaToText } from './signature-meta.mjs';
34

45
export interface ContentEntry {
56
id: string;
@@ -9,14 +10,17 @@ export interface ContentEntry {
910

1011
export function stripMdxSyntax(content: string): string {
1112
return (
12-
content
13+
signatureMetaToText(
1314
// Remove import statements
14-
.replace(/^import\s+.*$/gm, '')
15-
// Remove JSX self-closing tags like <Alert ... />
16-
.replace(/<[A-Z]\w*\s*[^>]*\/>/g, '')
15+
content.replace(/^import\s+.*$/gm, '')
16+
)
17+
// Remove JSX self-closing tags like <Alert ... />. `JSX_ATTRS` tolerates `>`
18+
// inside quoted or braced attribute values (e.g. runtime version constraints).
19+
.replace(new RegExp(`<[A-Z]\\w*${JSX_ATTRS}\\/>`, 'g'), '')
1720
// Remove JSX opening and closing tags like <Alert> </Alert>
18-
.replace(/<\/?[A-Z]\w*[^>]*>/g, '')
19-
// Collapse multiple blank lines
21+
.replace(new RegExp(`<\\/?[A-Z]\\w*${JSX_ATTRS}>`, 'g'), '')
22+
// Clear whitespace-only lines left by removed tags, then collapse blank lines
23+
.replace(/^[ \t]+$/gm, '')
2024
.replace(/\n{3,}/g, '\n\n')
2125
.trim()
2226
);

src/utils/signature-meta.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// `<Signature>` and `<Param>` carry API metadata (added-in version, deprecation,
2+
// runtime requirements, types, defaults) as JSX attributes, which plain-text and
3+
// llms.txt exports would otherwise discard along with the tags. This rewrites each
4+
// opening tag into the same plain-text sentences the component renders, so the
5+
// metadata stays readable next to the member's heading.
6+
7+
// Attribute values may contain `>` inside quotes or braces (e.g.
8+
// `runtime={{ 'Node.js': '>=22.2.0' }}`), so tag matching can't stop at the
9+
// first `>`.
10+
export const JSX_ATTRS = `(?:"[^"]*"|'[^']*'|\\{(?:[^{}]|\\{[^{}]*\\})*\\}|[^>"'{])*`;
11+
12+
// Also matches the component's slot markers, so their section titles ("Arguments",
13+
// "Properties", "Returns") survive as text.
14+
const JSX_META_TAG = new RegExp(
15+
`<(Signature|Param)\\b(${JSX_ATTRS})\\/?>|<Fragment\\s+slot=["'](attributes|properties|returns)["']\\s*>`,
16+
'g'
17+
);
18+
19+
/**
20+
* @param {string} attrs
21+
* @param {string} name
22+
* @returns {string | undefined}
23+
*/
24+
const getAttr = (attrs, name) => {
25+
const match = attrs.match(new RegExp(`(?:^|\\s)${name}=(?:"([^"]*)"|'([^']*)')`));
26+
return match ? (match[1] ?? match[2]) : undefined;
27+
};
28+
29+
/**
30+
* @param {string} attrs
31+
* @param {string} name
32+
* @returns {boolean}
33+
*/
34+
const hasFlag = (attrs, name) => new RegExp(`(?:^|\\s)${name}(?=\\s|$)`).test(attrs);
35+
36+
/**
37+
* Replace `<Signature>`/`<Param>` opening tags with the sentences the component
38+
* renders ("Added in v4.16.0.", "options (Object, optional):", …).
39+
*
40+
* @param {string} content
41+
* @returns {string}
42+
*/
43+
export const signatureMetaToText = (content) => {
44+
// Title for the next `attributes` slot; set by the enclosing Signature's
45+
// `attributesTitle` prop. Matches are visited in document order, so the
46+
// Signature opening tag is always seen before its slots.
47+
let attributesTitle = 'Arguments';
48+
return content.replace(JSX_META_TAG, (tag, component, attrs, slot, offset, source) => {
49+
// Keep the tag's own indentation, which mirrors the nesting depth in the
50+
// source, so replacements stay visually grouped under their section.
51+
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
52+
const beforeTag = source.slice(lineStart, offset);
53+
const indent = /^[ \t]*$/.test(beforeTag) ? beforeTag : '';
54+
55+
if (slot) {
56+
const title =
57+
slot === 'attributes' ? attributesTitle : slot === 'properties' ? 'Properties' : 'Returns';
58+
return `\n\n${indent}${title}:\n\n`;
59+
}
60+
61+
const since = getAttr(attrs, 'since');
62+
const deprecated = getAttr(attrs, 'deprecated');
63+
64+
if (component === 'Param') {
65+
const name = getAttr(attrs, 'name');
66+
if (!name) return tag;
67+
const details = [
68+
getAttr(attrs, 'type'),
69+
hasFlag(attrs, 'optional') && 'optional',
70+
getAttr(attrs, 'default') && `default: ${getAttr(attrs, 'default')}`,
71+
since && `added in ${since}`,
72+
deprecated && `deprecated in ${deprecated}`,
73+
].filter(Boolean);
74+
return `\n\n${indent}- ${name}${details.length ? ` (${details.join(', ')})` : ''}:\n\n`;
75+
}
76+
77+
attributesTitle = getAttr(attrs, 'attributesTitle') ?? 'Arguments';
78+
const runtime = [...attrs.matchAll(/['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g)]
79+
.map(([, engine, constraint]) => `${engine} ${constraint}`)
80+
.join(', ');
81+
const lines = [
82+
getAttr(attrs, 'type') && `Type: ${getAttr(attrs, 'type')}.`,
83+
getAttr(attrs, 'returns') && `Returns: ${getAttr(attrs, 'returns')}.`,
84+
since && `Added in ${since}.`,
85+
deprecated && `Deprecated in ${deprecated}.`,
86+
runtime && `Requires runtime: ${runtime}.`,
87+
].filter(Boolean);
88+
return lines.length ? `\n\n${lines.join(' ')}\n\n` : tag;
89+
});
90+
};

0 commit comments

Comments
 (0)