Skip to content

Commit e3ed197

Browse files
authored
separate markdown preprocessing phase from render phase (#289)
1 parent a51a014 commit e3ed197

File tree

5 files changed

+333
-333
lines changed

5 files changed

+333
-333
lines changed

apps/svelte.dev/scripts/sync-docs/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { replace_export_type_placeholders, type Modules } from '@sveltejs/site-kit/markdown';
1+
import { preprocess } from '@sveltejs/site-kit/markdown/preprocess';
22
import path from 'node:path';
33
import { cpSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
44
import ts from 'typescript';
55
import glob from 'tiny-glob/sync';
66
import { fileURLToPath } from 'node:url';
77
import { clone_repo, migrate_meta_json, replace_strings, strip_origin } from './utils';
88
import { get_types, read_d_ts_file, read_types } from './types';
9+
import type { Modules } from '@sveltejs/site-kit/markdown';
910

1011
interface Package {
1112
name: string;
@@ -149,7 +150,7 @@ for (const pkg of packages) {
149150
const files = glob(`${DOCS}/${pkg.name}/**/*.md`);
150151

151152
for (const file of files) {
152-
const content = await replace_export_type_placeholders(readFileSync(file, 'utf-8'), modules);
153+
const content = await preprocess(readFileSync(file, 'utf-8'), modules);
153154

154155
writeFileSync(file, content);
155156
}

packages/site-kit/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@
7777
"svelte": "./src/lib/docs/index.ts"
7878
},
7979
"./markdown": {
80-
"default": "./src/lib/markdown/index.ts",
81-
"svelte": "./src/lib/markdown/index.ts"
80+
"default": "./src/lib/markdown/index.ts"
81+
},
82+
"./markdown/preprocess": {
83+
"default": "./src/lib/markdown/preprocess/index.ts"
8284
},
8385
"./nav": {
8486
"default": "./src/lib/nav/index.ts",

packages/site-kit/src/lib/markdown/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
export {
2-
render_content_markdown as renderContentMarkdown,
3-
replace_export_type_placeholders
4-
} from './renderer';
1+
export { render_content_markdown as renderContentMarkdown } from './renderer';
52

63
export {
74
extract_frontmatter as extractFrontmatter,
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { SHIKI_LANGUAGE_MAP } from './utils';
2+
import type { Declaration, TypeElement, Modules } from './index';
3+
4+
/**
5+
* Replace module/export placeholders during `sync-docs`
6+
*/
7+
export async function preprocess(content: string, modules: Modules) {
8+
const REGEXES = {
9+
/** Render a specific type from a module with more details. Example: `> EXPANDED_TYPES: svelte#compile` */
10+
EXPANDED_TYPES: /> EXPANDED_TYPES: (.+?)#(.+)$/gm,
11+
/** Render types from a specific module. Example: `> TYPES: svelte` */
12+
TYPES: /> TYPES: (.+?)(?:#(.+))?$/gm,
13+
/** Render all exports and types from a specific module. Example: `> MODULE: svelte` */
14+
MODULE: /> MODULE: (.+?)$/gm,
15+
/** Render the snippet of a specific export. Example: `> EXPORT_SNIPPET: svelte#compile` */
16+
EXPORT_SNIPPET: /> EXPORT_SNIPPET: (.+?)#(.+)?$/gm,
17+
/** Render all modules. Example: `> MODULES` */
18+
MODULES: /> MODULES/g, //! /g is VERY IMPORTANT, OR WILL CAUSE INFINITE LOOP
19+
/** Render all value exports from a specific module. Example: `> EXPORTS: svelte` */
20+
EXPORTS: /> EXPORTS: (.+)/
21+
};
22+
23+
if (REGEXES.EXPORTS.test(content)) {
24+
throw new Error('yes');
25+
}
26+
27+
if (!modules || modules.length === 0) {
28+
return content
29+
.replace(REGEXES.EXPANDED_TYPES, '')
30+
.replace(REGEXES.TYPES, '')
31+
.replace(REGEXES.EXPORT_SNIPPET, '')
32+
.replace(REGEXES.MODULES, '')
33+
.replace(REGEXES.EXPORTS, '');
34+
}
35+
content = await async_replace(content, REGEXES.EXPANDED_TYPES, async ([_, name, id]) => {
36+
const module = modules.find((module) => module.name === name);
37+
if (!module) throw new Error(`Could not find module ${name}`);
38+
if (!module.types) return '';
39+
40+
const type = module.types.find((t) => t.name === id);
41+
42+
if (!type) throw new Error(`Could not find type ${name}#${id}`);
43+
44+
return stringify_expanded_type(type);
45+
});
46+
47+
content = await async_replace(content, REGEXES.TYPES, async ([_, name, id]) => {
48+
const module = modules.find((module) => module.name === name);
49+
if (!module) throw new Error(`Could not find module ${name}`);
50+
if (!module.types) return '';
51+
52+
if (id) {
53+
const type = module.types.find((t) => t.name === id);
54+
55+
if (!type) throw new Error(`Could not find type ${name}#${id}`);
56+
57+
return render_declaration(type, true);
58+
}
59+
60+
let comment = '';
61+
if (module.comment) {
62+
comment += `${module.comment}\n\n`;
63+
}
64+
65+
return (
66+
comment + module.types.map((t) => `## ${t.name}\n\n${render_declaration(t, true)}`).join('')
67+
);
68+
});
69+
70+
content = await async_replace(content, REGEXES.EXPORT_SNIPPET, async ([_, name, id]) => {
71+
const module = modules.find((module) => module.name === name);
72+
if (!module) throw new Error(`Could not find module ${name} for EXPORT_SNIPPET clause`);
73+
74+
if (!id) {
75+
throw new Error(`id is required for module ${name}`);
76+
}
77+
78+
const exported = module.exports?.filter((t) => t.name === id);
79+
80+
return exported?.map((d) => render_declaration(d, false)).join('\n\n') ?? '';
81+
});
82+
83+
content = await async_replace(content, REGEXES.MODULE, async ([_, name]) => {
84+
const module = modules.find((module) => module.name === name);
85+
if (!module) throw new Error(`Could not find module ${name}`);
86+
87+
return stringify_module(module);
88+
});
89+
90+
content = await async_replace(content, REGEXES.MODULES, async () => {
91+
return modules
92+
.map((module) => {
93+
if (!module.exports) return;
94+
95+
if (module.exports.length === 0 && !module.exempt) return '';
96+
97+
let import_block = '';
98+
99+
if (module.exports.length > 0) {
100+
// deduplication is necessary for now, because of `error()` overload
101+
const exports = Array.from(new Set(module.exports?.map((x) => x.name)));
102+
103+
let declaration = `import { ${exports.join(', ')} } from '${module.name}';`;
104+
if (declaration.length > 80) {
105+
declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`;
106+
}
107+
108+
import_block = fence(declaration, 'js');
109+
}
110+
111+
return `## ${module.name}\n\n${import_block}\n\n${module.comment}\n\n${module.exports
112+
.map((declaration) => {
113+
const markdown = render_declaration(declaration, true);
114+
return `### ${declaration.name}\n\n${markdown}`;
115+
})
116+
.join('\n\n')}`;
117+
})
118+
.join('\n\n');
119+
});
120+
121+
content = await async_replace(content, REGEXES.EXPORTS, async ([_, name]) => {
122+
const module = modules.find((module) => module.name === name);
123+
if (!module) throw new Error(`Could not find module ${name} for EXPORTS: clause`);
124+
if (!module.exports) return '';
125+
126+
if (module.exports.length === 0 && !module.exempt) return '';
127+
128+
let import_block = '';
129+
130+
if (module.exports.length > 0) {
131+
// deduplication is necessary for now, because of `error()` overload
132+
const exports = Array.from(new Set(module.exports.map((x) => x.name)));
133+
134+
let declaration = `import { ${exports.join(', ')} } from '${module.name}';`;
135+
if (declaration.length > 80) {
136+
declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`;
137+
}
138+
139+
import_block = fence(declaration, 'js');
140+
}
141+
142+
return `${import_block}\n\n${module.comment}\n\n${module.exports
143+
.map((declaration) => {
144+
const markdown = render_declaration(declaration, true);
145+
return `### ${declaration.name}\n\n${markdown}`;
146+
})
147+
.join('\n\n')}`;
148+
});
149+
150+
return content;
151+
}
152+
153+
function render_declaration(declaration: Declaration, full: boolean) {
154+
let content = '';
155+
156+
if (declaration.deprecated) {
157+
content += `<blockquote class="tag deprecated">\n\n${declaration.deprecated}\n\n</blockquote>\n\n`;
158+
}
159+
160+
if (declaration.comment) {
161+
content += declaration.comment + '\n\n';
162+
}
163+
164+
return (
165+
content +
166+
declaration.overloads
167+
.map((overload) => {
168+
const children = full
169+
? overload.children?.map((val) => stringify(val, 'dts')).join('\n\n')
170+
: '';
171+
172+
return `<div class="ts-block">${fence(overload.snippet, 'dts')}${children}</div>\n\n`;
173+
})
174+
.join('')
175+
);
176+
}
177+
178+
async function async_replace(
179+
inputString: string,
180+
regex: RegExp,
181+
asyncCallback: (match: RegExpExecArray) => string | Promise<string>
182+
) {
183+
let match;
184+
let previousLastIndex = 0;
185+
let parts = [];
186+
187+
// While there is a match
188+
while ((match = regex.exec(inputString)) !== null) {
189+
// Add the text before the match
190+
parts.push(inputString.slice(previousLastIndex, match.index));
191+
192+
// Perform the asynchronous operation for the match and add the result
193+
parts.push(await asyncCallback(match));
194+
195+
// Update the previous last index
196+
previousLastIndex = regex.lastIndex;
197+
198+
// Avoid infinite loops with zero-width matches
199+
if (match.index === regex.lastIndex) {
200+
regex.lastIndex++;
201+
}
202+
}
203+
204+
// Add the remaining text
205+
parts.push(inputString.slice(previousLastIndex));
206+
207+
return parts.join('');
208+
}
209+
210+
/**
211+
* Takes a module and returns a markdown string.
212+
*/
213+
function stringify_module(module: Modules[0]) {
214+
let content = '';
215+
216+
if (module.exports && module.exports.length > 0) {
217+
// deduplication is necessary for now, because of method overloads
218+
const exports = Array.from(new Set(module.exports?.map((x) => x.name)));
219+
220+
let declaration = `import { ${exports.join(', ')} } from '${module.name}';`;
221+
if (declaration.length > 80) {
222+
declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`;
223+
}
224+
225+
content += fence(declaration, 'js');
226+
}
227+
228+
if (module.comment) {
229+
content += `${module.comment}\n\n`;
230+
}
231+
232+
for (const declaration of module.exports || []) {
233+
const markdown = render_declaration(declaration, true);
234+
content += `## ${declaration.name}\n\n${markdown}\n\n`;
235+
}
236+
237+
for (const t of module.types || []) {
238+
content += `## ${t.name}\n\n` + render_declaration(t, true);
239+
}
240+
241+
return content;
242+
}
243+
244+
function stringify_expanded_type(type: Declaration) {
245+
return (
246+
type.comment +
247+
type.overloads
248+
.map((overload) =>
249+
overload.children
250+
?.map((child) => {
251+
let section = `## ${child.name}`;
252+
253+
if (child.bullets) {
254+
section += `\n\n<div class="ts-block-property-bullets">\n\n${child.bullets.join(
255+
'\n'
256+
)}\n\n</div>`;
257+
}
258+
259+
section += `\n\n${child.comment}`;
260+
261+
if (child.children) {
262+
section += `\n\n<div class="ts-block-property-children">\n\n${child.children
263+
.map((v) => stringify(v))
264+
.join('\n')}\n\n</div>`;
265+
}
266+
267+
return section;
268+
})
269+
.join('\n\n')
270+
)
271+
.join('\n\n')
272+
);
273+
}
274+
275+
/**
276+
* Helper function for {@link replace_export_type_placeholders}. Renders specifiv members to their markdown/html representation.
277+
*/
278+
function stringify(member: TypeElement, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts'): string {
279+
if (!member) return '';
280+
281+
// It's important to always use two newlines after a dom tag or else markdown does not render it properly
282+
283+
const bullet_block =
284+
(member.bullets?.length ?? 0) > 0
285+
? `\n\n<div class="ts-block-property-bullets">\n\n${member.bullets?.join('\n')}\n\n</div>`
286+
: '';
287+
288+
const comment = member.comment
289+
? '\n\n' +
290+
member.comment
291+
.replace(/\/\/\/ type: (.+)/g, '/** @type {$1} */')
292+
.replace(/^( )+/gm, (match, spaces) => {
293+
return '\t'.repeat(match.length / 2);
294+
})
295+
: '';
296+
297+
const child_block =
298+
(member.children?.length ?? 0) > 0
299+
? `\n\n<div class="ts-block-property-children">${member.children
300+
?.map((val) => stringify(val, lang))
301+
.join('\n')}</div>`
302+
: '';
303+
304+
return (
305+
`<div class="ts-block-property">${fence(member.snippet, lang)}` +
306+
`<div class="ts-block-property-details">` +
307+
bullet_block +
308+
comment +
309+
child_block +
310+
(bullet_block || comment || child_block ? '\n\n' : '') +
311+
'</div>\n</div>'
312+
);
313+
}
314+
315+
function fence(code: string, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts') {
316+
return (
317+
'\n\n```' +
318+
lang +
319+
'\n' +
320+
(['js', 'ts'].includes(lang) ? '// @noErrors\n' : '') +
321+
code +
322+
'\n```\n\n'
323+
);
324+
}

0 commit comments

Comments
 (0)