Skip to content

Commit 9b47ccd

Browse files
heiskrCopilot
andauthored
Add a hast-producing render path to the unified pipeline (#61774)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a8efca1 commit 9b47ccd

3 files changed

Lines changed: 199 additions & 1 deletion

File tree

src/content-render/index.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { renderLiquid } from './liquid/index'
2-
import { renderMarkdown, renderUnified } from './unified/index'
2+
import { renderMarkdown, renderUnified, renderUnifiedToHast } from './unified/index'
33
import { engine } from './liquid/engine'
44
import type { Context } from '@/types'
5+
import type { Root as HastRoot } from 'hast'
56
import { createLogger } from '@/observability/logger'
67

78
const logger = createLogger(import.meta.url)
@@ -66,4 +67,42 @@ function getDefaultCacheKey(template: string, context: Context): string {
6667
return `${template}:${context.currentVersion}:${context.currentLanguage}`
6768
}
6869

70+
/**
71+
* Like `renderContent`, but returns the hast (HTML AST) tree alongside the
72+
* derived HTML string, both produced from a single unified pass. Used by the
73+
* render-page boundary to thread a serializable AST to the React layer instead
74+
* of an opaque HTML string, so React can render the body itself rather than
75+
* injecting raw HTML (github/docs-engineering#6619).
76+
*
77+
* Does liquid first (same as renderContent), then runs the unified pipeline but
78+
* stops before rehype-stringify. The `html` returned here is derived from the
79+
* exact same tree, so it stays consistent with `hast`.
80+
*
81+
* Unlike `renderContent`, this does not support `context.markdownRequested`:
82+
* the hast path always produces HTML. Callers that need markdown output should
83+
* use `renderContent`.
84+
*/
85+
export async function renderContentToHast(
86+
template = '',
87+
context: Context = {} as Context,
88+
options: Pick<RenderOptions, 'filename'> = {},
89+
): Promise<{ html: string; hast: HastRoot | null }> {
90+
if (!template) return { html: template, hast: null }
91+
if (context.markdownRequested) {
92+
throw new Error(
93+
'renderContentToHast does not support markdownRequested; use renderContent for markdown output',
94+
)
95+
}
96+
try {
97+
const liquidRendered = await renderLiquid(template, context)
98+
const { hast, html } = await renderUnifiedToHast(liquidRendered, context)
99+
return { html, hast }
100+
} catch (error) {
101+
if (options.filename) {
102+
logger.error('renderContentToHast failed on file', { filename: options.filename })
103+
}
104+
throw error
105+
}
106+
}
107+
69108
export const liquid = engine
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { renderContentToHast } from '@/content-render/index'
4+
import { renderUnified, renderUnifiedToHast } from '@/content-render/unified/index'
5+
import type { Context } from '@/types'
6+
7+
// A corpus that exercises the parts of the pipeline most likely to differ
8+
// between "stringify the processed vfile" (today) and "stringify the hast tree
9+
// we stopped at" (the new hast path): headings (slug + anchor links), code
10+
// blocks (highlight + code-header), tables (several rewrite plugins), alerts,
11+
// raw inline HTML (rehype-raw), and images.
12+
const fixtures: Array<{ name: string; template: string }> = [
13+
{ name: 'paragraph', template: 'Hello **world**, this is a [link](https://github.com).' },
14+
{
15+
name: 'headings',
16+
template: '# Title\n\n## Section one\n\nSome text.\n\n### Subsection\n\nMore text.',
17+
},
18+
{
19+
name: 'fenced code block',
20+
template: '```js\nconst x = 1\nconsole.log(x)\n```',
21+
},
22+
{
23+
name: 'table',
24+
template: '| Col A | Col B |\n| --- | --- |\n| one | two |\n| three | four |',
25+
},
26+
{
27+
name: 'ordered list with nesting',
28+
template: '1. First\n1. Second\n - nested a\n - nested b\n1. Third',
29+
},
30+
{
31+
name: 'alert',
32+
template: '> [!NOTE]\n> This is an alert body with a [link](/foo).',
33+
},
34+
{
35+
name: 'inline raw html',
36+
template: 'A paragraph with <kbd>Ctrl</kbd> and <em>emphasis</em> inline.',
37+
},
38+
{
39+
name: 'blockquote and emphasis',
40+
template: '> A quote\n>\n> spanning lines with _emphasis_ and `code`.',
41+
},
42+
{
43+
name: 'asset image (png -> picture)',
44+
template: '![Alternative text](/assets/images/help.png)',
45+
},
46+
{
47+
name: 'external image',
48+
template: '![A logo](https://example.com/logo.png)',
49+
},
50+
]
51+
52+
describe('renderUnifiedToHast', () => {
53+
test.each(fixtures)('derived html matches renderUnified for: $name', async ({ template }) => {
54+
const context = {} as Context
55+
const stringOutput = await renderUnified(template, structuredClone(context))
56+
const { html, hast } = await renderUnifiedToHast(template, structuredClone(context))
57+
58+
expect(html).toBe(stringOutput)
59+
expect(hast).toBeTruthy()
60+
expect(hast.type).toBe('root')
61+
expect(Array.isArray(hast.children)).toBe(true)
62+
})
63+
64+
test('returns a hast root with element children for a heading', async () => {
65+
const { hast } = await renderUnifiedToHast('# Hello', {} as Context)
66+
const firstElement = hast.children.find((node) => node.type === 'element')
67+
expect(firstElement).toBeTruthy()
68+
expect(firstElement && 'tagName' in firstElement && firstElement.tagName).toBe('h1')
69+
})
70+
71+
test('strips position metadata from every node', async () => {
72+
const { hast } = await renderUnifiedToHast(
73+
'# Title\n\nA paragraph with a [link](/foo) and `code`.',
74+
{} as Context,
75+
)
76+
const hasPosition = (node: unknown): boolean => {
77+
if (!node || typeof node !== 'object') return false
78+
if ('position' in node) return true
79+
const children = (node as { children?: unknown[] }).children
80+
return Array.isArray(children) && children.some(hasPosition)
81+
}
82+
expect(hasPosition(hast)).toBe(false)
83+
})
84+
})
85+
86+
describe('renderContentToHast', () => {
87+
test('renders liquid before the unified pass', async () => {
88+
const context = { page: { foo: 'bar' } } as unknown as Context
89+
const { html, hast } = await renderContentToHast('A {{ page.foo }} value.', context)
90+
91+
expect(html).toContain('A bar value.')
92+
expect(hast).toBeTruthy()
93+
expect(hast?.type).toBe('root')
94+
})
95+
96+
test('derived html matches the inner renderUnified path after liquid', async () => {
97+
const context = { page: { foo: 'bar' } } as unknown as Context
98+
const template = '# {{ page.foo }}\n\nWith a [link](/foo).'
99+
const { html } = await renderContentToHast(template, structuredClone(context))
100+
const expected = await renderUnified('# bar\n\nWith a [link](/foo).', {} as Context)
101+
102+
expect(html).toBe(expected)
103+
})
104+
105+
test('returns null hast for an empty template', async () => {
106+
const { html, hast } = await renderContentToHast('', {} as Context)
107+
expect(html).toBe('')
108+
expect(hast).toBeNull()
109+
})
110+
111+
test('throws when markdownRequested is set', async () => {
112+
await expect(
113+
renderContentToHast('# Hello', { markdownRequested: true } as Context),
114+
).rejects.toThrow(/does not support markdownRequested/)
115+
})
116+
})

src/content-render/unified/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Processor } from 'unified'
2+
import type { Nodes as HastNodes, Root as HastRoot } from 'hast'
13
import { fastTextOnly } from '@/content-render/unified/text-only'
24
import { createProcessor, createMarkdownOnlyProcessor } from '@/content-render/unified/processor'
35
import type { Context } from '@/types'
@@ -22,6 +24,47 @@ export async function renderUnified(
2224
return html.trim()
2325
}
2426

27+
/**
28+
* Run the same unified pipeline as `renderUnified`, but stop before the terminal
29+
* rehype-stringify step and return the hast (HTML AST) tree instead of a string.
30+
*
31+
* The 18 transform plugins all run during `processor.run()`; rehype-stringify is
32+
* only invoked by `processor.stringify()`. So we get the fully transformed hast
33+
* from a single pass, then derive the legacy HTML string from that exact same
34+
* tree. This guarantees the string and hast can never drift, and avoids running
35+
* the pipeline twice for consumers that still need the string (mini-TOC text,
36+
* search indexing, validators).
37+
*/
38+
export async function renderUnifiedToHast(
39+
template: string,
40+
context: Context,
41+
): Promise<{ hast: HastRoot; html: string }> {
42+
const processor = createProcessor(context) as unknown as Processor
43+
const mdast = processor.parse(template)
44+
const hast = (await processor.run(mdast)) as HastNodes
45+
const html = processor.stringify(hast).toString()
46+
// Strip `position` data (line/column offsets) the parser leaves on every node.
47+
// It is useless to the client and meaningfully inflates the serialized tree we
48+
// ship through the Next props boundary. Done after deriving `html` so the
49+
// string output is byte-identical to the legacy path.
50+
stripPositions(hast)
51+
return { hast: hast as HastRoot, html: html.trim() }
52+
}
53+
54+
/**
55+
* Recursively delete `position` fields from a hast/unist tree in place.
56+
* Avoids adding a dependency (unist-util-remove-position) for one field.
57+
*/
58+
function stripPositions(node: HastNodes): void {
59+
if (node && typeof node === 'object') {
60+
if ('position' in node) delete (node as { position?: unknown }).position
61+
const children = (node as { children?: HastNodes[] }).children
62+
if (Array.isArray(children)) {
63+
for (const child of children) stripPositions(child)
64+
}
65+
}
66+
}
67+
2568
export async function renderMarkdown(template: string, context: Context) {
2669
const processor = createMarkdownOnlyProcessor(context)
2770
const vFile = await processor.process(template)

0 commit comments

Comments
 (0)