diff --git a/apps/svelte.dev/scripts/sync-docs/index.ts b/apps/svelte.dev/scripts/sync-docs/index.ts index 9af5b20eb3..53f08ca5f7 100644 --- a/apps/svelte.dev/scripts/sync-docs/index.ts +++ b/apps/svelte.dev/scripts/sync-docs/index.ts @@ -1,4 +1,4 @@ -import { replace_export_type_placeholders, type Modules } from '@sveltejs/site-kit/markdown'; +import { preprocess } from '@sveltejs/site-kit/markdown/preprocess'; import path from 'node:path'; import { cpSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import ts from 'typescript'; @@ -6,6 +6,7 @@ import glob from 'tiny-glob/sync'; import { fileURLToPath } from 'node:url'; import { clone_repo, migrate_meta_json, replace_strings, strip_origin } from './utils'; import { get_types, read_d_ts_file, read_types } from './types'; +import type { Modules } from '@sveltejs/site-kit/markdown'; interface Package { name: string; @@ -149,7 +150,7 @@ for (const pkg of packages) { const files = glob(`${DOCS}/${pkg.name}/**/*.md`); for (const file of files) { - const content = await replace_export_type_placeholders(readFileSync(file, 'utf-8'), modules); + const content = await preprocess(readFileSync(file, 'utf-8'), modules); writeFileSync(file, content); } diff --git a/packages/site-kit/package.json b/packages/site-kit/package.json index 50d147daee..bdbb93280b 100644 --- a/packages/site-kit/package.json +++ b/packages/site-kit/package.json @@ -77,8 +77,10 @@ "svelte": "./src/lib/docs/index.ts" }, "./markdown": { - "default": "./src/lib/markdown/index.ts", - "svelte": "./src/lib/markdown/index.ts" + "default": "./src/lib/markdown/index.ts" + }, + "./markdown/preprocess": { + "default": "./src/lib/markdown/preprocess/index.ts" }, "./nav": { "default": "./src/lib/nav/index.ts", diff --git a/packages/site-kit/src/lib/markdown/index.ts b/packages/site-kit/src/lib/markdown/index.ts index 2ace78f1a2..4170c129f4 100644 --- a/packages/site-kit/src/lib/markdown/index.ts +++ b/packages/site-kit/src/lib/markdown/index.ts @@ -1,7 +1,4 @@ -export { - render_content_markdown as renderContentMarkdown, - replace_export_type_placeholders -} from './renderer'; +export { render_content_markdown as renderContentMarkdown } from './renderer'; export { extract_frontmatter as extractFrontmatter, diff --git a/packages/site-kit/src/lib/markdown/preprocess.ts b/packages/site-kit/src/lib/markdown/preprocess.ts new file mode 100644 index 0000000000..b63f971c2f --- /dev/null +++ b/packages/site-kit/src/lib/markdown/preprocess.ts @@ -0,0 +1,324 @@ +import { SHIKI_LANGUAGE_MAP } from './utils'; +import type { Declaration, TypeElement, Modules } from './index'; + +/** + * Replace module/export placeholders during `sync-docs` + */ +export async function preprocess(content: string, modules: Modules) { + const REGEXES = { + /** Render a specific type from a module with more details. Example: `> EXPANDED_TYPES: svelte#compile` */ + EXPANDED_TYPES: /> EXPANDED_TYPES: (.+?)#(.+)$/gm, + /** Render types from a specific module. Example: `> TYPES: svelte` */ + TYPES: /> TYPES: (.+?)(?:#(.+))?$/gm, + /** Render all exports and types from a specific module. Example: `> MODULE: svelte` */ + MODULE: /> MODULE: (.+?)$/gm, + /** Render the snippet of a specific export. Example: `> EXPORT_SNIPPET: svelte#compile` */ + EXPORT_SNIPPET: /> EXPORT_SNIPPET: (.+?)#(.+)?$/gm, + /** Render all modules. Example: `> MODULES` */ + MODULES: /> MODULES/g, //! /g is VERY IMPORTANT, OR WILL CAUSE INFINITE LOOP + /** Render all value exports from a specific module. Example: `> EXPORTS: svelte` */ + EXPORTS: /> EXPORTS: (.+)/ + }; + + if (REGEXES.EXPORTS.test(content)) { + throw new Error('yes'); + } + + if (!modules || modules.length === 0) { + return content + .replace(REGEXES.EXPANDED_TYPES, '') + .replace(REGEXES.TYPES, '') + .replace(REGEXES.EXPORT_SNIPPET, '') + .replace(REGEXES.MODULES, '') + .replace(REGEXES.EXPORTS, ''); + } + content = await async_replace(content, REGEXES.EXPANDED_TYPES, async ([_, name, id]) => { + const module = modules.find((module) => module.name === name); + if (!module) throw new Error(`Could not find module ${name}`); + if (!module.types) return ''; + + const type = module.types.find((t) => t.name === id); + + if (!type) throw new Error(`Could not find type ${name}#${id}`); + + return stringify_expanded_type(type); + }); + + content = await async_replace(content, REGEXES.TYPES, async ([_, name, id]) => { + const module = modules.find((module) => module.name === name); + if (!module) throw new Error(`Could not find module ${name}`); + if (!module.types) return ''; + + if (id) { + const type = module.types.find((t) => t.name === id); + + if (!type) throw new Error(`Could not find type ${name}#${id}`); + + return render_declaration(type, true); + } + + let comment = ''; + if (module.comment) { + comment += `${module.comment}\n\n`; + } + + return ( + comment + module.types.map((t) => `## ${t.name}\n\n${render_declaration(t, true)}`).join('') + ); + }); + + content = await async_replace(content, REGEXES.EXPORT_SNIPPET, async ([_, name, id]) => { + const module = modules.find((module) => module.name === name); + if (!module) throw new Error(`Could not find module ${name} for EXPORT_SNIPPET clause`); + + if (!id) { + throw new Error(`id is required for module ${name}`); + } + + const exported = module.exports?.filter((t) => t.name === id); + + return exported?.map((d) => render_declaration(d, false)).join('\n\n') ?? ''; + }); + + content = await async_replace(content, REGEXES.MODULE, async ([_, name]) => { + const module = modules.find((module) => module.name === name); + if (!module) throw new Error(`Could not find module ${name}`); + + return stringify_module(module); + }); + + content = await async_replace(content, REGEXES.MODULES, async () => { + return modules + .map((module) => { + if (!module.exports) return; + + if (module.exports.length === 0 && !module.exempt) return ''; + + let import_block = ''; + + if (module.exports.length > 0) { + // deduplication is necessary for now, because of `error()` overload + const exports = Array.from(new Set(module.exports?.map((x) => x.name))); + + let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; + if (declaration.length > 80) { + declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; + } + + import_block = fence(declaration, 'js'); + } + + return `## ${module.name}\n\n${import_block}\n\n${module.comment}\n\n${module.exports + .map((declaration) => { + const markdown = render_declaration(declaration, true); + return `### ${declaration.name}\n\n${markdown}`; + }) + .join('\n\n')}`; + }) + .join('\n\n'); + }); + + content = await async_replace(content, REGEXES.EXPORTS, async ([_, name]) => { + const module = modules.find((module) => module.name === name); + if (!module) throw new Error(`Could not find module ${name} for EXPORTS: clause`); + if (!module.exports) return ''; + + if (module.exports.length === 0 && !module.exempt) return ''; + + let import_block = ''; + + if (module.exports.length > 0) { + // deduplication is necessary for now, because of `error()` overload + const exports = Array.from(new Set(module.exports.map((x) => x.name))); + + let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; + if (declaration.length > 80) { + declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; + } + + import_block = fence(declaration, 'js'); + } + + return `${import_block}\n\n${module.comment}\n\n${module.exports + .map((declaration) => { + const markdown = render_declaration(declaration, true); + return `### ${declaration.name}\n\n${markdown}`; + }) + .join('\n\n')}`; + }); + + return content; +} + +function render_declaration(declaration: Declaration, full: boolean) { + let content = ''; + + if (declaration.deprecated) { + content += `
\n\n${declaration.deprecated}\n\n
\n\n`; + } + + if (declaration.comment) { + content += declaration.comment + '\n\n'; + } + + return ( + content + + declaration.overloads + .map((overload) => { + const children = full + ? overload.children?.map((val) => stringify(val, 'dts')).join('\n\n') + : ''; + + return `
${fence(overload.snippet, 'dts')}${children}
\n\n`; + }) + .join('') + ); +} + +async function async_replace( + inputString: string, + regex: RegExp, + asyncCallback: (match: RegExpExecArray) => string | Promise +) { + let match; + let previousLastIndex = 0; + let parts = []; + + // While there is a match + while ((match = regex.exec(inputString)) !== null) { + // Add the text before the match + parts.push(inputString.slice(previousLastIndex, match.index)); + + // Perform the asynchronous operation for the match and add the result + parts.push(await asyncCallback(match)); + + // Update the previous last index + previousLastIndex = regex.lastIndex; + + // Avoid infinite loops with zero-width matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + + // Add the remaining text + parts.push(inputString.slice(previousLastIndex)); + + return parts.join(''); +} + +/** + * Takes a module and returns a markdown string. + */ +function stringify_module(module: Modules[0]) { + let content = ''; + + if (module.exports && module.exports.length > 0) { + // deduplication is necessary for now, because of method overloads + const exports = Array.from(new Set(module.exports?.map((x) => x.name))); + + let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; + if (declaration.length > 80) { + declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; + } + + content += fence(declaration, 'js'); + } + + if (module.comment) { + content += `${module.comment}\n\n`; + } + + for (const declaration of module.exports || []) { + const markdown = render_declaration(declaration, true); + content += `## ${declaration.name}\n\n${markdown}\n\n`; + } + + for (const t of module.types || []) { + content += `## ${t.name}\n\n` + render_declaration(t, true); + } + + return content; +} + +function stringify_expanded_type(type: Declaration) { + return ( + type.comment + + type.overloads + .map((overload) => + overload.children + ?.map((child) => { + let section = `## ${child.name}`; + + if (child.bullets) { + section += `\n\n
\n\n${child.bullets.join( + '\n' + )}\n\n
`; + } + + section += `\n\n${child.comment}`; + + if (child.children) { + section += `\n\n
\n\n${child.children + .map((v) => stringify(v)) + .join('\n')}\n\n
`; + } + + return section; + }) + .join('\n\n') + ) + .join('\n\n') + ); +} + +/** + * Helper function for {@link replace_export_type_placeholders}. Renders specifiv members to their markdown/html representation. + */ +function stringify(member: TypeElement, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts'): string { + if (!member) return ''; + + // It's important to always use two newlines after a dom tag or else markdown does not render it properly + + const bullet_block = + (member.bullets?.length ?? 0) > 0 + ? `\n\n
\n\n${member.bullets?.join('\n')}\n\n
` + : ''; + + const comment = member.comment + ? '\n\n' + + member.comment + .replace(/\/\/\/ type: (.+)/g, '/** @type {$1} */') + .replace(/^( )+/gm, (match, spaces) => { + return '\t'.repeat(match.length / 2); + }) + : ''; + + const child_block = + (member.children?.length ?? 0) > 0 + ? `\n\n
${member.children + ?.map((val) => stringify(val, lang)) + .join('\n')}
` + : ''; + + return ( + `
${fence(member.snippet, lang)}` + + `
` + + bullet_block + + comment + + child_block + + (bullet_block || comment || child_block ? '\n\n' : '') + + '
\n
' + ); +} + +function fence(code: string, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts') { + return ( + '\n\n```' + + lang + + '\n' + + (['js', 'ts'].includes(lang) ? '// @noErrors\n' : '') + + code + + '\n```\n\n' + ); +} diff --git a/packages/site-kit/src/lib/markdown/renderer.ts b/packages/site-kit/src/lib/markdown/renderer.ts index 208cc8709d..03663f62ec 100644 --- a/packages/site-kit/src/lib/markdown/renderer.ts +++ b/packages/site-kit/src/lib/markdown/renderer.ts @@ -7,7 +7,7 @@ import * as prettier from 'prettier'; import { codeToHtml, createCssVariablesTheme } from 'shiki'; import { transformerTwoslash } from '@shikijs/twoslash'; import { SHIKI_LANGUAGE_MAP, escape, normalizeSlugify, smart_quotes, transform } from './utils'; -import type { Declaration, TypeElement, Modules } from './index'; +import type { Modules } from './index'; interface SnippetOptions { file: string | null; @@ -136,8 +136,6 @@ export async function render_content_markdown( const { type_links, type_regex } = create_type_links(modules, resolveTypeLinks); const snippets = await create_snippet_cache(cacheCodeSnippets); - body = await replace_export_type_placeholders(body, modules); - const headings: string[] = []; // this is a bit hacky, but it allows us to prevent type declarations @@ -499,296 +497,6 @@ export async function convert_to_ts(js_code: string, indent = '', offset = '') { } } -/** - * Replace module/export information placeholders in the docs. - */ -export async function replace_export_type_placeholders(content: string, modules: Modules) { - const REGEXES = { - /** Render a specific type from a module with more details. Example: `> EXPANDED_TYPES: svelte#compile` */ - EXPANDED_TYPES: /> EXPANDED_TYPES: (.+?)#(.+)$/gm, - /** Render types from a specific module. Example: `> TYPES: svelte` */ - TYPES: /> TYPES: (.+?)(?:#(.+))?$/gm, - /** Render all exports and types from a specific module. Example: `> MODULE: svelte` */ - MODULE: /> MODULE: (.+?)$/gm, - /** Render the snippet of a specific export. Example: `> EXPORT_SNIPPET: svelte#compile` */ - EXPORT_SNIPPET: /> EXPORT_SNIPPET: (.+?)#(.+)?$/gm, - /** Render all modules. Example: `> MODULES` */ - MODULES: /> MODULES/g, //! /g is VERY IMPORTANT, OR WILL CAUSE INFINITE LOOP - /** Render all value exports from a specific module. Example: `> EXPORTS: svelte` */ - EXPORTS: /> EXPORTS: (.+)/ - }; - - if (REGEXES.EXPORTS.test(content)) { - throw new Error('yes'); - } - - if (!modules || modules.length === 0) { - return content - .replace(REGEXES.EXPANDED_TYPES, '') - .replace(REGEXES.TYPES, '') - .replace(REGEXES.EXPORT_SNIPPET, '') - .replace(REGEXES.MODULES, '') - .replace(REGEXES.EXPORTS, ''); - } - content = await async_replace(content, REGEXES.EXPANDED_TYPES, async ([_, name, id]) => { - const module = modules.find((module) => module.name === name); - if (!module) throw new Error(`Could not find module ${name}`); - if (!module.types) return ''; - - const type = module.types.find((t) => t.name === id); - - if (!type) throw new Error(`Could not find type ${name}#${id}`); - - return stringify_expanded_type(type); - }); - - content = await async_replace(content, REGEXES.TYPES, async ([_, name, id]) => { - const module = modules.find((module) => module.name === name); - if (!module) throw new Error(`Could not find module ${name}`); - if (!module.types) return ''; - - if (id) { - const type = module.types.find((t) => t.name === id); - - if (!type) throw new Error(`Could not find type ${name}#${id}`); - - return render_declaration(type, true); - } - - let comment = ''; - if (module.comment) { - comment += `${module.comment}\n\n`; - } - - return ( - comment + module.types.map((t) => `## ${t.name}\n\n${render_declaration(t, true)}`).join('') - ); - }); - - content = await async_replace(content, REGEXES.EXPORT_SNIPPET, async ([_, name, id]) => { - const module = modules.find((module) => module.name === name); - if (!module) throw new Error(`Could not find module ${name} for EXPORT_SNIPPET clause`); - - if (!id) { - throw new Error(`id is required for module ${name}`); - } - - const exported = module.exports?.filter((t) => t.name === id); - - return exported?.map((d) => render_declaration(d, false)).join('\n\n') ?? ''; - }); - - content = await async_replace(content, REGEXES.MODULE, async ([_, name]) => { - const module = modules.find((module) => module.name === name); - if (!module) throw new Error(`Could not find module ${name}`); - - return stringify_module(module); - }); - - content = await async_replace(content, REGEXES.MODULES, async () => { - return modules - .map((module) => { - if (!module.exports) return; - - if (module.exports.length === 0 && !module.exempt) return ''; - - let import_block = ''; - - if (module.exports.length > 0) { - // deduplication is necessary for now, because of `error()` overload - const exports = Array.from(new Set(module.exports?.map((x) => x.name))); - - let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; - if (declaration.length > 80) { - declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; - } - - import_block = fence(declaration, 'js'); - } - - return `## ${module.name}\n\n${import_block}\n\n${module.comment}\n\n${module.exports - .map((declaration) => { - const markdown = render_declaration(declaration, true); - return `### ${declaration.name}\n\n${markdown}`; - }) - .join('\n\n')}`; - }) - .join('\n\n'); - }); - - content = await async_replace(content, REGEXES.EXPORTS, async ([_, name]) => { - const module = modules.find((module) => module.name === name); - if (!module) throw new Error(`Could not find module ${name} for EXPORTS: clause`); - if (!module.exports) return ''; - - if (module.exports.length === 0 && !module.exempt) return ''; - - let import_block = ''; - - if (module.exports.length > 0) { - // deduplication is necessary for now, because of `error()` overload - const exports = Array.from(new Set(module.exports.map((x) => x.name))); - - let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; - if (declaration.length > 80) { - declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; - } - - import_block = fence(declaration, 'js'); - } - - return `${import_block}\n\n${module.comment}\n\n${module.exports - .map((declaration) => { - const markdown = render_declaration(declaration, true); - return `### ${declaration.name}\n\n${markdown}`; - }) - .join('\n\n')}`; - }); - - return content; -} - -function render_declaration(declaration: Declaration, full: boolean) { - let content = ''; - - if (declaration.deprecated) { - content += `
\n\n${declaration.deprecated}\n\n
\n\n`; - } - - if (declaration.comment) { - content += declaration.comment + '\n\n'; - } - - return ( - content + - declaration.overloads - .map((overload) => { - const children = full - ? overload.children?.map((val) => stringify(val, 'dts')).join('\n\n') - : ''; - - return `
${fence(overload.snippet, 'dts')}${children}
\n\n`; - }) - .join('') - ); -} - -/** - * Takes a module and returns a markdown string. - */ -function stringify_module(module: Modules[0]) { - let content = ''; - - if (module.exports && module.exports.length > 0) { - // deduplication is necessary for now, because of method overloads - const exports = Array.from(new Set(module.exports?.map((x) => x.name))); - - let declaration = `import { ${exports.join(', ')} } from '${module.name}';`; - if (declaration.length > 80) { - declaration = `import {\n\t${exports.join(',\n\t')}\n} from '${module.name}';`; - } - - content += fence(declaration, 'js'); - } - - if (module.comment) { - content += `${module.comment}\n\n`; - } - - for (const declaration of module.exports || []) { - const markdown = render_declaration(declaration, true); - content += `## ${declaration.name}\n\n${markdown}\n\n`; - } - - for (const t of module.types || []) { - content += `## ${t.name}\n\n` + render_declaration(t, true); - } - - return content; -} - -function stringify_expanded_type(type: Declaration) { - return ( - type.comment + - type.overloads - .map((overload) => - overload.children - ?.map((child) => { - let section = `## ${child.name}`; - - if (child.bullets) { - section += `\n\n
\n\n${child.bullets.join( - '\n' - )}\n\n
`; - } - - section += `\n\n${child.comment}`; - - if (child.children) { - section += `\n\n
\n\n${child.children - .map((v) => stringify(v)) - .join('\n')}\n\n
`; - } - - return section; - }) - .join('\n\n') - ) - .join('\n\n') - ); -} - -function fence(code: string, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts') { - return ( - '\n\n```' + - lang + - '\n' + - (['js', 'ts'].includes(lang) ? '// @noErrors\n' : '') + - code + - '\n```\n\n' - ); -} - -/** - * Helper function for {@link replace_export_type_placeholders}. Renders specifiv members to their markdown/html representation. - */ -function stringify(member: TypeElement, lang: keyof typeof SHIKI_LANGUAGE_MAP = 'ts'): string { - if (!member) return ''; - - // It's important to always use two newlines after a dom tag or else markdown does not render it properly - - const bullet_block = - (member.bullets?.length ?? 0) > 0 - ? `\n\n
\n\n${member.bullets?.join('\n')}\n\n
` - : ''; - - const comment = member.comment - ? '\n\n' + - member.comment - .replace(/\/\/\/ type: (.+)/g, '/** @type {$1} */') - .replace(/^( )+/gm, (match, spaces) => { - return '\t'.repeat(match.length / 2); - }) - : ''; - - const child_block = - (member.children?.length ?? 0) > 0 - ? `\n\n
${member.children - ?.map((val) => stringify(val, lang)) - .join('\n')}
` - : ''; - - return ( - `
${fence(member.snippet, lang)}` + - `
` + - bullet_block + - comment + - child_block + - (bullet_block || comment || child_block ? '\n\n' : '') + - '
\n
' - ); -} - function find_nearest_node_modules(file: string): string | null { let current = file; @@ -1036,35 +744,3 @@ function indent_multiline_comments(str: string) { } ); } - -async function async_replace( - inputString: string, - regex: RegExp, - asyncCallback: (match: RegExpExecArray) => string | Promise -) { - let match; - let previousLastIndex = 0; - let parts = []; - - // While there is a match - while ((match = regex.exec(inputString)) !== null) { - // Add the text before the match - parts.push(inputString.slice(previousLastIndex, match.index)); - - // Perform the asynchronous operation for the match and add the result - parts.push(await asyncCallback(match)); - - // Update the previous last index - previousLastIndex = regex.lastIndex; - - // Avoid infinite loops with zero-width matches - if (match.index === regex.lastIndex) { - regex.lastIndex++; - } - } - - // Add the remaining text - parts.push(inputString.slice(previousLastIndex)); - - return parts.join(''); -}