diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index c4b259dbf6..eb3393b173 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -57,16 +57,14 @@ export async function renderDocNodes( symbolLookup: SymbolLookup, ): Promise { const grouped = groupMergedByKind(symbols) - const sections: string[] = [] - - for (const kind of KIND_DISPLAY_ORDER) { + const sectionPromises = KIND_DISPLAY_ORDER.map(async kind => { const kindSymbols = grouped[kind] - if (!kindSymbols || kindSymbols.length === 0) continue + if (!kindSymbols || kindSymbols.length === 0) return '' + return renderKindSection(kind, kindSymbols, symbolLookup) + }) - sections.push(await renderKindSection(kind, kindSymbols, symbolLookup)) - } - - return sections.join('\n') + const sections = await Promise.all(sectionPromises) + return sections.filter(Boolean).join('\n') } /** @@ -79,13 +77,13 @@ async function renderKindSection( ): Promise { const title = KIND_TITLES[kind] || kind const lines: string[] = [] + const renderedSymbols = await Promise.all( + symbols.map(symbol => renderMergedSymbol(symbol, symbolLookup)), + ) lines.push(`
`) lines.push(`

${title}

`) - - for (const symbol of symbols) { - lines.push(await renderMergedSymbol(symbol, symbolLookup)) - } + lines.push(...renderedSymbols) lines.push(`
`) @@ -129,9 +127,21 @@ async function renderMergedSymbol( .map(n => getNodeSignature(n)) .filter(Boolean) as string[] - if (signatures.length > 0) { - const signatureCode = signatures.join('\n') - const highlightedSignature = await highlightCodeBlock(signatureCode, 'typescript') + const description = symbol.jsDoc?.doc?.trim() + const signaturePromise = + signatures.length > 0 ? highlightCodeBlock(signatures.join('\n'), 'typescript') : null + const descriptionPromise = description ? renderMarkdown(description, symbolLookup) : null + const jsDocTagsPromise = + symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0 + ? renderJsDocTags(symbol.jsDoc.tags, symbolLookup) + : null + const [highlightedSignature, renderedDescription, renderedJsDocTags] = await Promise.all([ + signaturePromise, + descriptionPromise, + jsDocTagsPromise, + ]) + + if (highlightedSignature) { lines.push(`
${highlightedSignature}
`) if (symbol.nodes.length > MAX_OVERLOAD_SIGNATURES) { @@ -141,16 +151,13 @@ async function renderMergedSymbol( } // Description - if (symbol.jsDoc?.doc) { - const description = symbol.jsDoc.doc.trim() - lines.push( - `
${await renderMarkdown(description, symbolLookup)}
`, - ) + if (renderedDescription) { + lines.push(`
${renderedDescription}
`) } // JSDoc tags - if (symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0) { - lines.push(await renderJsDocTags(symbol.jsDoc.tags, symbolLookup)) + if (renderedJsDocTags) { + lines.push(renderedJsDocTags) } // Type-specific members @@ -179,15 +186,30 @@ async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Pr const deprecated = tags.find(t => t.kind === 'deprecated') const see = tags.filter(t => t.kind === 'see') + const deprecatedMessagePromise = deprecated?.doc + ? renderMarkdown(deprecated.doc.replace(/\n/g, ' '), symbolLookup) + : null + const examplePromises = examples.map(async example => { + if (!example.doc) return '' + const langMatch = example.doc.match(/```(\w+)?/) + const lang = langMatch?.[1] || 'typescript' + const code = example.doc.replace(/```\w*\n?/g, '').trim() + return highlightCodeBlock(code, lang) + }) + + const [renderedDeprecatedMessage, ...renderedExamples] = await Promise.all([ + deprecatedMessagePromise, + ...examplePromises, + ]) + // Deprecated warning if (deprecated) { lines.push(`
`) lines.push(`Deprecated`) - if (deprecated.doc) { + if (renderedDeprecatedMessage) { // We remove new lines because they look weird when rendered into the deprecated block // I think markdown is actually supposed to collapse single new lines automatically but this function doesn't do that so if that changes remove this - const renderedMessage = await renderMarkdown(deprecated.doc.replace(/\n/g, ' '), symbolLookup) - lines.push(`
${renderedMessage}
`) + lines.push(`
${renderedDeprecatedMessage}
`) } lines.push(`
`) } @@ -218,18 +240,10 @@ async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Pr } // Examples (with syntax highlighting) - if (examples.length > 0) { + if (examples.length > 0 && renderedExamples.some(Boolean)) { lines.push(`
`) lines.push(`

Example${examples.length > 1 ? 's' : ''}

`) - for (const example of examples) { - if (example.doc) { - const langMatch = example.doc.match(/```(\w+)?/) - const lang = langMatch?.[1] || 'typescript' - const code = example.doc.replace(/```\w*\n?/g, '').trim() - const highlighted = await highlightCodeBlock(code, lang) - lines.push(highlighted) - } - } + lines.push(...renderedExamples.filter(Boolean)) lines.push(`
`) } diff --git a/server/utils/docs/text.ts b/server/utils/docs/text.ts index 7cb5749039..706fbede38 100644 --- a/server/utils/docs/text.ts +++ b/server/utils/docs/text.ts @@ -131,11 +131,13 @@ export async function renderMarkdown(text: string, symbolLookup: SymbolLookup): .replace(/\n/g, '
') // Highlight and restore code blocks - for (let i = 0; i < codeBlockData.length; i++) { - const { lang, code } = codeBlockData[i]! - const highlighted = await highlightCodeBlock(code, lang) - result = result.replace(`__CODE_BLOCK_${i}__`, highlighted) - } + const highlightedCodeBlocks = await Promise.all( + codeBlockData.map(({ lang, code }) => highlightCodeBlock(code, lang)), + ) + + highlightedCodeBlocks.forEach((highlighted, i) => { + result = result.replace(`__CODE_BLOCK_${i}__`, () => highlighted) + }) return result } diff --git a/server/utils/shiki.ts b/server/utils/shiki.ts index 00eaf6a71c..d070a5e95b 100644 --- a/server/utils/shiki.ts +++ b/server/utils/shiki.ts @@ -3,6 +3,7 @@ import { createHighlighterCore, type HighlighterCore } from 'shiki/core' import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' let highlighter: HighlighterCore | null = null +let highlighterPromise: Promise | null = null function replaceThemeColors( theme: ThemeRegistration, @@ -18,65 +19,76 @@ function replaceThemeColors( } export async function getShikiHighlighter(): Promise { - if (!highlighter) { - highlighter = await createHighlighterCore({ - themes: [ - import('@shikijs/themes/github-dark'), - import('@shikijs/themes/github-light').then(t => - replaceThemeColors(t.default ?? t, { - '#22863A': '#227436', // green - '#E36209': '#BA4D02', // orange - '#D73A49': '#CD3443', // red - '#B31D28': '#AC222F', // red - }), - ), - ], - langs: [ - // Core web languages - import('@shikijs/langs/javascript'), - import('@shikijs/langs/typescript'), - import('@shikijs/langs/json'), - import('@shikijs/langs/jsonc'), - import('@shikijs/langs/html'), - import('@shikijs/langs/css'), - import('@shikijs/langs/scss'), - import('@shikijs/langs/less'), + if (highlighter) { + return highlighter + } + + highlighterPromise ??= createHighlighterCore({ + themes: [ + import('@shikijs/themes/github-dark'), + import('@shikijs/themes/github-light').then(t => + replaceThemeColors(t.default ?? t, { + '#22863A': '#227436', // green + '#E36209': '#BA4D02', // orange + '#D73A49': '#CD3443', // red + '#B31D28': '#AC222F', // red + }), + ), + ], + langs: [ + // Core web languages + import('@shikijs/langs/javascript'), + import('@shikijs/langs/typescript'), + import('@shikijs/langs/json'), + import('@shikijs/langs/jsonc'), + import('@shikijs/langs/html'), + import('@shikijs/langs/css'), + import('@shikijs/langs/scss'), + import('@shikijs/langs/less'), - // Frameworks - import('@shikijs/langs/vue'), - import('@shikijs/langs/jsx'), - import('@shikijs/langs/tsx'), - import('@shikijs/langs/svelte'), - import('@shikijs/langs/astro'), - import('@shikijs/langs/glimmer-js'), - import('@shikijs/langs/glimmer-ts'), + // Frameworks + import('@shikijs/langs/vue'), + import('@shikijs/langs/jsx'), + import('@shikijs/langs/tsx'), + import('@shikijs/langs/svelte'), + import('@shikijs/langs/astro'), + import('@shikijs/langs/glimmer-js'), + import('@shikijs/langs/glimmer-ts'), - // Shell/CLI - import('@shikijs/langs/bash'), - import('@shikijs/langs/shell'), + // Shell/CLI + import('@shikijs/langs/bash'), + import('@shikijs/langs/shell'), - // Config/Data formats - import('@shikijs/langs/yaml'), - import('@shikijs/langs/toml'), - import('@shikijs/langs/xml'), - import('@shikijs/langs/markdown'), + // Config/Data formats + import('@shikijs/langs/yaml'), + import('@shikijs/langs/toml'), + import('@shikijs/langs/xml'), + import('@shikijs/langs/markdown'), - // Other languages - import('@shikijs/langs/diff'), - import('@shikijs/langs/sql'), - import('@shikijs/langs/graphql'), - import('@shikijs/langs/python'), - import('@shikijs/langs/rust'), - import('@shikijs/langs/go'), - ], - langAlias: { - gjs: 'glimmer-js', - gts: 'glimmer-ts', - }, - engine: createJavaScriptRegexEngine(), + // Other languages + import('@shikijs/langs/diff'), + import('@shikijs/langs/sql'), + import('@shikijs/langs/graphql'), + import('@shikijs/langs/python'), + import('@shikijs/langs/rust'), + import('@shikijs/langs/go'), + ], + langAlias: { + gjs: 'glimmer-js', + gts: 'glimmer-ts', + }, + engine: createJavaScriptRegexEngine(), + }) + .then(createdHighlighter => { + highlighter = createdHighlighter + return createdHighlighter }) - } - return highlighter + .catch(error => { + highlighterPromise = null + throw error + }) + + return highlighterPromise } /** diff --git a/test/unit/server/utils/docs/render.spec.ts b/test/unit/server/utils/docs/render.spec.ts index 739dc29c80..1d7d5286ef 100644 --- a/test/unit/server/utils/docs/render.spec.ts +++ b/test/unit/server/utils/docs/render.spec.ts @@ -21,6 +21,37 @@ function createClassSymbol(classDef: DenoDocNode['classDef']): MergedSymbol { } } +function createFunctionSymbol(name: string): MergedSymbol { + const node: DenoDocNode = { + name, + kind: 'function', + functionDef: { + params: [], + returnType: { repr: 'void', kind: 'keyword', keyword: 'void' }, + }, + } + + return { + name, + kind: 'function', + nodes: [node], + } +} + +function createInterfaceSymbol(name: string): MergedSymbol { + const node: DenoDocNode = { + name, + kind: 'interface', + interfaceDef: {}, + } + + return { + name, + kind: 'interface', + nodes: [node], + } +} + describe('issue #1943 - class getters separated from methods', () => { it('renders getters under a "Getters" heading, not "Methods"', async () => { const symbol = createClassSymbol({ @@ -131,3 +162,33 @@ describe('issue #1943 - class getters separated from methods', () => { expect(html).toContain('static get instance') }) }) + +describe('renderDocNodes ordering', () => { + it('preserves kind display order while rendering sections in parallel', async () => { + const html = await renderDocNodes( + [createInterfaceSymbol('Config'), createFunctionSymbol('run')], + new Map(), + ) + + const functionsIndex = html.indexOf('id="section-function"') + const interfacesIndex = html.indexOf('id="section-interface"') + + expect(functionsIndex).toBeGreaterThanOrEqual(0) + expect(interfacesIndex).toBeGreaterThanOrEqual(0) + expect(functionsIndex).toBeLessThan(interfacesIndex) + }) + + it('preserves symbol order within a section while rendering symbols in parallel', async () => { + const html = await renderDocNodes( + [createFunctionSymbol('alpha'), createFunctionSymbol('beta')], + new Map(), + ) + + const alphaIndex = html.indexOf('id="function-alpha"') + const betaIndex = html.indexOf('id="function-beta"') + + expect(alphaIndex).toBeGreaterThanOrEqual(0) + expect(betaIndex).toBeGreaterThanOrEqual(0) + expect(alphaIndex).toBeLessThan(betaIndex) + }) +})