diff --git a/src/Elastic.Documentation.Site/Assets/hljs.test.ts b/src/Elastic.Documentation.Site/Assets/hljs.test.ts index 655c50cad1..7b8d8f20a7 100644 --- a/src/Elastic.Documentation.Site/Assets/hljs.test.ts +++ b/src/Elastic.Documentation.Site/Assets/hljs.test.ts @@ -7,12 +7,14 @@ async function loadModule(): Promise { return await import('./hljs') } -function setCodeBlocks(...languages: string[]) { +function setCodeBlocks(...languages: (string | null)[]) { const blocks = languages - .map( - (language) => - `
echo hello
` - ) + .map((language) => { + if (language === null) { + return '
const x = 1
' + } + return `
echo hello
` + }) .join('') document.body.innerHTML = `
${blocks}
` } @@ -22,14 +24,15 @@ afterEach(() => { }) describe('initHighlight', () => { - it('loads only the languages referenced on the page', async () => { + it('loads all curated languages when any code block is present', async () => { const { initHighlight, hljs } = await loadModule() setCodeBlocks('bash') await initHighlight() expect(hljs.listLanguages()).toContain('bash') - expect(hljs.listLanguages()).not.toContain('python') + expect(hljs.listLanguages()).toContain('python') + expect(hljs.listLanguages()).toContain('javascript') }) it('loads no node_modules language when there are no code blocks', async () => { @@ -52,26 +55,45 @@ describe('initHighlight', () => { expect(block?.getAttribute('data-highlighted')).toBe('yes') }) - it('loads a newly encountered language on a later swap', async () => { - const { initHighlight, hljs } = await loadModule() - setCodeBlocks('bash') - await initHighlight() - expect(hljs.listLanguages()).not.toContain('python') + it.each([ + ['js', 'javascript'], + ['ts', 'typescript'], + ['jsx', 'javascript'], + ])( + 'highlights alias language-%s via %s aliases', + async (alias, canonical) => { + const { initHighlight } = await loadModule() + setCodeBlocks(alias) + + await initHighlight() + + const block = document.querySelector('#markdown-content pre code') + expect(block?.getAttribute('data-highlighted')).toBe('yes') + expect(block?.className).toContain(`language-${canonical}`) + } + ) + + it('autodetects an unlabeled code block', async () => { + const { initHighlight } = await loadModule() + setCodeBlocks(null) - // Simulate an htmx swap introducing a language not present initially. - setCodeBlocks('python') await initHighlight() - expect(hljs.listLanguages()).toContain('python') + const block = document.querySelector('#markdown-content pre code') + expect(block?.getAttribute('data-highlighted')).toBe('yes') + expect(block?.querySelector('.hljs-keyword')).not.toBeNull() }) - it('resolves aliases to their canonical language', async () => { + it('resolves sh alias to shell', async () => { const { initHighlight, hljs } = await loadModule() setCodeBlocks('sh') await initHighlight() expect(hljs.listLanguages()).toContain('shell') + const block = document.querySelector('#markdown-content pre code') + expect(block?.getAttribute('data-highlighted')).toBe('yes') + expect(block?.className).toContain('language-shell') }) it('does not re-highlight a block when invoked concurrently', async () => { @@ -100,6 +122,28 @@ describe('initHighlight', () => { }) }) +describe('highlightCodeBlocks', () => { + it('scopes highlighting to the supplied root', async () => { + const { highlightCodeBlocks } = await loadModule() + document.body.innerHTML = ` +
echo inside
+
echo outside
+ ` + + const inside = document.querySelector('#inside')! + await highlightCodeBlocks(inside) + + expect( + inside.querySelector('code')?.getAttribute('data-highlighted') + ).toBe('yes') + expect( + document + .querySelector('#outside code') + ?.getAttribute('data-highlighted') + ).not.toBe('yes') + }) +}) + describe('toLanguageFn', () => { // Parcel's dynamic import() resolves a language module to the LanguageFn directly, // while Babel/Jest wrap it as { default: fn }. Both must yield the function; a diff --git a/src/Elastic.Documentation.Site/Assets/hljs.ts b/src/Elastic.Documentation.Site/Assets/hljs.ts index 3a2c07cab2..0ed4842a61 100644 --- a/src/Elastic.Documentation.Site/Assets/hljs.ts +++ b/src/Elastic.Documentation.Site/Assets/hljs.ts @@ -1,7 +1,6 @@ import { mergeHTMLPlugin } from './hljs-merge-html-plugin' import { LanguageFn } from 'highlight.js' import hljs from 'highlight.js/lib/core' -import { $$optional } from 'select-dom' // highlight.js language modules and the esql plugin default-export the LanguageFn. // Parcel's dynamic import() resolves to the module's exports directly (the function), @@ -13,8 +12,7 @@ export function toLanguageFn(mod: unknown): LanguageFn { } // Each entry lazily imports one highlight.js language module (or the esql plugin) so -// only the languages actually present on a page are downloaded, instead of eagerly -// bundling all of them into the entry chunk. +// grammars stay out of the entry chunk until a page or message contains code. const languageLoaders: Record Promise> = { asciidoc: () => import('highlight.js/lib/languages/asciidoc').then(toLanguageFn), @@ -67,37 +65,39 @@ const languageLoaders: Record Promise> = { yaml: () => import('highlight.js/lib/languages/yaml').then(toLanguageFn), } -// Alias -> canonical language name. Aliases are registered with hljs once their -// canonical module has loaded so `language-sh` etc. resolve correctly. -const languageAliases: Record = { - sh: 'shell', +const CODE_BLOCK_SELECTOR = 'pre code:not([data-highlighted])' + +let allLanguagesReady: Promise | null = null + +function ensureHighlightReady(): Promise { + if (allLanguagesReady) return allLanguagesReady + + allLanguagesReady = Promise.all( + Object.entries(languageLoaders).map(async ([name, loader]) => { + hljs.registerLanguage(name, toLanguageFn(await loader())) + }) + ).then(() => { + hljs.registerAliases(['sh'], { languageName: 'shell' }) + }) + + return allLanguagesReady } -// Caches the registration promise per canonical language so concurrent code blocks -// (and later htmx swaps) share a single import instead of re-fetching the module. -const registrations = new Map>() +export async function highlightCodeBlocks(root: ParentNode): Promise { + const blocks = root.querySelectorAll(CODE_BLOCK_SELECTOR) + if (blocks.length === 0) return -// Imports and registers a language (plus any aliases pointing at it) exactly once. -// Unknown languages have no loader and resolve immediately so callers can await -// unconditionally. -function ensureLanguage(name: string): Promise { - const canonical = languageAliases[name] ?? name - const loader = languageLoaders[canonical] - if (!loader) return Promise.resolve() + await ensureHighlightReady() - const cached = registrations.get(canonical) - if (cached) return cached + for (const block of blocks) { + if (!block.dataset.highlighted) hljs.highlightElement(block) + } +} - const registration = loader().then((languageFn) => { - hljs.registerLanguage(canonical, languageFn) - for (const [alias, target] of Object.entries(languageAliases)) { - if (target === canonical) { - hljs.registerAliases([alias], { languageName: canonical }) - } - } - }) - registrations.set(canonical, registration) - return registration +export async function initHighlight(): Promise { + const root = document.querySelector('#markdown-content') + if (!root) return + await highlightCodeBlocks(root) } hljs.registerLanguage('apiheader', function () { @@ -230,39 +230,5 @@ hljs.addPlugin(mergeHTMLPlugin) // for code callouts hljs.configure({ ignoreUnescapedHTML: true }) -function getLanguageFromClassList(element: Element): string | undefined { - for (const className of element.classList) { - if (className.startsWith('language-')) { - return className.slice('language-'.length) - } - } - return undefined -} - -export async function initHighlight() { - const blocks = $$optional( - '#markdown-content pre code:not([data-highlighted])' - ) - if (blocks.length === 0) return - - // Import only the language modules referenced by the unprocessed blocks before - // highlighting. Unknown/plain-text languages have no loader and resolve immediately; - // hljs.highlightElement then degrades gracefully without breaking other blocks. - const requiredLanguages = new Set() - for (const block of blocks) { - const language = getLanguageFromClassList(block) - if (language) requiredLanguages.add(language) - } - - await Promise.all([...requiredLanguages].map(ensureLanguage)) - - // Re-check data-highlighted after awaiting: a concurrent initHighlight (e.g. a - // second htmx:load fired while the language import was pending) may already have - // highlighted these captured blocks, and hljs warns if asked to redo one. - blocks.forEach((block) => { - if (!block.dataset.highlighted) hljs.highlightElement(block) - }) -} - // Export the configured hljs instance for reuse export { hljs } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx index f87bce4fc8..498d86800b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx @@ -1,7 +1,8 @@ import { initCopyButton } from '../../copybutton' -import { hljs } from '../../hljs' +import { highlightCodeBlocks } from '../../hljs' import { ApiError } from '../shared/errorHandling' import { useHtmxContainer } from '../shared/htmx/useHtmxContainer' +import { markdownRenderer } from '../shared/markdownRenderer' import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent' import { aiGradients } from './ElasticAiAssistantButton' import { GeneratingStatus } from './GeneratingStatus' @@ -23,39 +24,8 @@ import { } from '@elastic/eui' import { css } from '@emotion/react' import DOMPurify from 'dompurify' -import { Marked, RendererObject, Tokens } from 'marked' import { useEffect, useMemo, useRef } from 'react' -// Create the marked instance once globally (renderer never changes) -const createMarkedInstance = () => { - const renderer: RendererObject = { - code({ text, lang }: Tokens.Code): string { - let highlighted: string - try { - highlighted = lang - ? hljs.highlight(text, { language: lang }).value - : hljs.highlightAuto(text).value - } catch { - // Fallback to auto highlighting if the specified language is not found - highlighted = hljs.highlightAuto(text).value - } - return `
-
-                    ${highlighted}
-                
-
` - }, - table(token: Tokens.Table): string { - const defaultMarked = new Marked() - const defaultTableHtml = defaultMarked.parse(token.raw) - return `
${defaultTableHtml}
` - }, - } - return new Marked({ renderer }) -} - -const markedInstance = createMarkedInstance() - interface ChatMessageProps { message: ChatMessageType events?: AskAiEvent[] @@ -387,7 +357,7 @@ export const ChatMessage = ({ }, [content, isComplete]) const parsed = useMemo(() => { - const html = markedInstance.parse(mainContent) as string + const html = markdownRenderer.parse(mainContent, { async: false }) return DOMPurify.sanitize(html) }, [mainContent]) @@ -402,21 +372,26 @@ export const ChatMessage = ({ const ref = useRef(null) useEffect(() => { - if (isComplete && ref.current) { - const timer = setTimeout(() => { - try { - initCopyButton( - '.highlight pre', - ref.current!, - 'ai-message-codecell-' - ) - } catch (error) { - console.error('Failed to initialize copy buttons:', error) - } - }, 100) - return () => clearTimeout(timer) + if (!ref.current || !parsed) return + + let cancelled = false + void highlightCodeBlocks(ref.current).then(() => { + if (cancelled || !isComplete || !ref.current) return + try { + initCopyButton( + '.highlight pre', + ref.current, + 'ai-message-codecell-' + ) + } catch (error) { + console.error('Failed to initialize copy buttons:', error) + } + }) + + return () => { + cancelled = true } - }, [isComplete]) + }, [parsed, isComplete]) // Process internal docs links for htmx navigation useHtmxContainer(ref, [parsed]) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.test.tsx new file mode 100644 index 0000000000..4e644feaa6 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.test.tsx @@ -0,0 +1,40 @@ +import { MarkdownContent } from './MarkdownContent' +import { EuiProvider } from '@elastic/eui' +import { render, waitFor } from '@testing-library/react' + +jest.mock('../../copybutton', () => ({ + initCopyButton: jest.fn(), +})) + +const renderMarkdown = (content: string) => + render( + + + + ) + +describe('MarkdownContent', () => { + it('highlights alias fences after mount', async () => { + renderMarkdown('```js\nconst x = 1\n```') + + await waitFor(() => { + const block = document.querySelector('.markdown-content pre code') + expect(block?.getAttribute('data-highlighted')).toBe('yes') + expect(block?.className).toContain('language-javascript') + }) + }) + + it('autodetects unlabeled fences after mount', async () => { + renderMarkdown('```\nconst x = 1\n```') + + await waitFor(() => { + const block = document.querySelector('.markdown-content pre code') + expect(block?.getAttribute('data-highlighted')).toBe('yes') + expect(block?.querySelector('.hljs-keyword')).not.toBeNull() + }) + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.tsx index 5de21c4449..1e3c5f892d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/MarkdownContent.tsx @@ -1,40 +1,11 @@ import { initCopyButton } from '../../copybutton' -import { hljs } from '../../hljs' +import { highlightCodeBlocks } from '../../hljs' +import { markdownRenderer } from './markdownRenderer' import { useEuiTheme } from '@elastic/eui' import { css } from '@emotion/react' import DOMPurify from 'dompurify' -import { Marked, RendererObject, Tokens } from 'marked' import { useEffect, useMemo, useRef } from 'react' -// Create the marked instance once globally -const createMarkedInstance = () => { - const renderer: RendererObject = { - code({ text, lang }: Tokens.Code): string { - let highlighted: string - try { - highlighted = lang - ? hljs.highlight(text, { language: lang }).value - : hljs.highlightAuto(text).value - } catch { - highlighted = hljs.highlightAuto(text).value - } - return `
-
-                    ${highlighted}
-                
-
` - }, - table(token: Tokens.Table): string { - const defaultMarked = new Marked() - const defaultTableHtml = defaultMarked.parse(token.raw) - return `
${defaultTableHtml}
` - }, - } - return new Marked({ renderer }) -} - -const markedInstance = createMarkedInstance() - interface MarkdownContentProps { content: string enableCopyButtons?: boolean @@ -50,26 +21,27 @@ export const MarkdownContent = ({ const ref = useRef(null) const parsed = useMemo(() => { - const html = markedInstance.parse(content) as string + const html = markdownRenderer.parse(content, { async: false }) return DOMPurify.sanitize(html) }, [content]) useEffect(() => { - if (enableCopyButtons && ref.current && content) { - const timer = setTimeout(() => { - try { - initCopyButton( - '.highlight pre', - ref.current!, - copyButtonPrefix - ) - } catch (error) { - console.error('Failed to initialize copy buttons:', error) - } - }, 100) - return () => clearTimeout(timer) + if (!ref.current) return + + let cancelled = false + void highlightCodeBlocks(ref.current).then(() => { + if (cancelled || !enableCopyButtons || !ref.current) return + try { + initCopyButton('.highlight pre', ref.current, copyButtonPrefix) + } catch (error) { + console.error('Failed to initialize copy buttons:', error) + } + }) + + return () => { + cancelled = true } - }, [content, enableCopyButtons, copyButtonPrefix]) + }, [enableCopyButtons, copyButtonPrefix, parsed]) return (
{ + it('wraps and escapes fenced code with its language class', () => { + const html = markdownRenderer.parse( + '```jsx\nconst element =
test
\n```', + { async: false } + ) + + expect(html).toBe( + '
const element = <div>test</div>\n
\n
' + ) + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/markdownRenderer.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/markdownRenderer.ts new file mode 100644 index 0000000000..3146c0cc64 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/markdownRenderer.ts @@ -0,0 +1,14 @@ +import { Marked, Renderer, RendererObject, Tokens } from 'marked' + +const defaultRenderer = new Renderer() + +const renderer: RendererObject = { + code(token: Tokens.Code): string { + return `
${defaultRenderer.code.call(this, token)}
` + }, + table(token: Tokens.Table): string { + return `
${defaultRenderer.table.call(this, token)}
` + }, +} + +export const markdownRenderer = new Marked({ renderer })