Skip to content

Commit cacbb5d

Browse files
committed
Fix escaped generic markdown rendering
1 parent 8f076d9 commit cacbb5d

2 files changed

Lines changed: 325 additions & 8 deletions

File tree

docs/cloudflare-workers-migration.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ TanStack.com is configured as a Cloudflare Workers deployment for this branch. N
1212
- `src/components/OptimizedImage.tsx`, `src/utils/optimizedImage.ts`: host-neutral optimized image helper with Cloudflare image transformations behind an explicit build flag.
1313
- `src/routes/api/builder/*`, `src/components/builder/*`: builder deploy/download path uses browser-generated files; direct server-generation endpoints return explicit 501 on Workers.
1414
- `src/routes/*`, `src/utils/*`, `src/server/*`: CDN cache headers moved from Netlify-specific headers to portable `CDN-Cache-Control` / `Cache-Tag`.
15+
- `src/utils/markdown/processor.ts`: site-side compatibility guard for escaped angle brackets in generated TypeDoc markdown until `@tanstack/markdown` handles `\<...\>` as escaped text.
1516
- Removed hosting-only Netlify files: `netlify.toml`, `netlify/functions/*`, `scripts/run-built-server.mjs`.
1617

1718
## Commands Used
@@ -25,16 +26,16 @@ pnpm run deploy:cloudflare
2526
pnpm run preview:cloudflare -- --host 127.0.0.1 --port 3001
2627
```
2728

28-
Additional checks used `curl` and Playwright with system Chrome against the Workers preview URL.
29+
Additional checks used `curl`, Node fetch scripts, Wrangler tail, and Playwright with system Chrome against the Workers preview URL.
2930

3031
## Worker
3132

3233
- Account: `8da95258a9c70b54c3e2b374a0079106`
3334
- Worker: `tanstack-com`
3435
- URL: `https://tanstack-com.thetanstack.workers.dev`
35-
- Current version: `ee22ac5c-2681-4b55-acbc-d11ee0adacfc`
36-
- Upload size: `14872.99 KiB` raw, `4804.71 KiB` gzip
37-
- Startup time: `27 ms`
36+
- Current version: `5fc0f032-4b93-4f9a-8983-cd27803ac9d9`
37+
- Upload size: `14607.10 KiB` raw, `4735.79 KiB` gzip
38+
- Startup time: `35 ms`
3839
- Note: the secret-bearing `tanstack-com-staging` Worker was renamed to `tanstack-com`, and the older empty `tanstack-com` Worker was removed.
3940

4041
## Passed
@@ -56,6 +57,10 @@ Additional checks used `curl` and Playwright with system Chrome against the Work
5657
- `/.well-known/oauth-authorization-server` returned OAuth metadata.
5758
- `/api/mcp/` returned the expected unauthenticated JSON-RPC auth error instead of a runtime failure.
5859
- `POST /api/application-starter/resolve` returned a Start recipe.
60+
- `Link` response headers for static assets are emitted on SSR responses for Cloudflare Early Hints fallback.
61+
- Broad docs/blog audit generated 2,767 latest-doc/blog URLs from GitHub doc trees plus local blog posts and compared production vs Worker.
62+
- Escaped generic headings in TypeDoc markdown now render correctly, e.g. `Interface: AudioAdapter<TModel, TProviderOptions>` with the production-compatible `interface-audioadaptertmodel-tprovideroptions` anchor.
63+
- Three full-body rechecks of 43 URLs that intermittently returned Worker 500/timeout during the high-concurrency audit cleared; the only stable non-200s were `/hotkeys/latest/docs/reference` and `/pacer/latest/docs/reference`, both 404 on production and Worker.
5964

6065
## Failed Or Not Proven
6166

@@ -69,6 +74,7 @@ Additional checks used `curl` and Playwright with system Chrome against the Work
6974
- Full GitHub OAuth callback/account login was not completed.
7075
- End-to-end GitHub repository deploy was not completed with a logged-in account.
7176
- Cron trigger behavior was deployed but not manually invoked.
77+
- High-concurrency audit runs can still produce transient Worker 500s with `{"status":500,"unhandled":true,"message":"HTTPError"}` on changing docs paths, but targeted full-body rechecks did not reproduce stable page failures. Treat this as a load/audit-cache risk, not a confirmed content regression.
7278

7379
## Builder Generation Note
7480

@@ -80,6 +86,16 @@ The deployable compromise in this branch keeps dynamic generation in the browser
8086

8187
Cloudflare image transformations are disabled by default because `/cdn-cgi/image/*` returned 404 on the Worker preview URL before custom-domain image resizing was proven. Set `TANSTACK_IMAGE_TRANSFORMATIONS=true` during build only after Image Resizing works on the routed `tanstack.com` zone.
8288

89+
## Markdown Audit Note
90+
91+
The new markdown renderer initially parsed escaped TypeDoc generics like `\<T\>` as inline HTML. The site now protects escaped `<` / `>` outside code fences and inline code before parsing, restores them into text nodes before render, and rebuilds headings so rendered content and ToC anchors stay aligned.
92+
93+
Remaining markdown differences observed during audit:
94+
95+
- Production duplicates light/dark code blocks; the Worker branch renders one theme-aware code block. This explains large HTML-size and `<pre>` count differences.
96+
- Blog footnote headings are omitted by the new renderer on a few posts. Confirm whether this is an intentional markdown package behavior change before treating it as a site regression.
97+
- Two table-count diffs remain in `/ai/latest/docs/code-mode/code-mode` and `/db/latest/docs/collections/powersync-collection`; these should be reviewed upstream in `@tanstack/markdown` because route/status/content otherwise match.
98+
8399
## Readiness
84100

85101
Core marketing SSR, docs/start navigation, security headers, static assets, analytics proxying, GitHub auth start, MCP auth rejection, application-starter API, scheduled Worker registration, Cloudflare preview, deploy, and dynamic OG image generation are working on Cloudflare Workers.

src/utils/markdown/processor.ts

Lines changed: 305 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,323 @@
11
import { docsMarkdownExtensions } from '@tanstack/markdown/extensions/docs'
22
import { parseMarkdown } from '@tanstack/markdown/parser'
3-
import type { MarkdownDocument, MarkdownHeading } from '@tanstack/markdown'
3+
import type {
4+
BlockNode,
5+
InlineNode,
6+
MarkdownDocument,
7+
MarkdownHeading,
8+
} from '@tanstack/markdown'
49

510
export type { MarkdownDocument, MarkdownHeading } from '@tanstack/markdown'
611

712
export type SiteMarkdownDocument = MarkdownDocument & {
813
headings: Array<MarkdownHeading>
914
}
1015

16+
const escapedLessThanToken = '\uE000'
17+
const escapedGreaterThanToken = '\uE001'
18+
1119
export function parseSiteMarkdown(content: string): SiteMarkdownDocument {
12-
const document = parseMarkdown(content, {
20+
const headingSlugger = createHeadingSlugger()
21+
const document = parseMarkdown(protectEscapedAngleBrackets(content), {
1322
allowHtml: true,
1423
extensions: docsMarkdownExtensions(),
15-
headingIds: true,
24+
headingIds: (text) =>
25+
headingSlugger(restoreEscapedAngleBracketText(text).replace(/[<>]/g, '')),
1626
})
1727

28+
restoreEscapedAngleBrackets(document)
29+
1830
return {
1931
...document,
20-
headings: document.headings ?? [],
32+
headings: collectHeadings(document),
33+
}
34+
}
35+
36+
function protectEscapedAngleBrackets(content: string) {
37+
const lines = content.split('\n')
38+
const protectedLines: Array<string> = []
39+
let fence: { marker: '`' | '~'; size: number } | undefined
40+
41+
for (const line of lines) {
42+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
43+
44+
if (fence) {
45+
protectedLines.push(line)
46+
47+
if (
48+
fenceMatch &&
49+
fenceMatch[1]?.startsWith(fence.marker.repeat(fence.size))
50+
) {
51+
fence = undefined
52+
}
53+
54+
continue
55+
}
56+
57+
if (fenceMatch) {
58+
const marker = fenceMatch[1]?.[0]
59+
60+
if (marker === '`' || marker === '~') {
61+
fence = {
62+
marker,
63+
size: fenceMatch[1]?.length ?? 3,
64+
}
65+
}
66+
67+
protectedLines.push(line)
68+
continue
69+
}
70+
71+
protectedLines.push(protectEscapedAngleBracketsInLine(line))
72+
}
73+
74+
return protectedLines.join('\n')
75+
}
76+
77+
function protectEscapedAngleBracketsInLine(line: string) {
78+
let result = ''
79+
let index = 0
80+
let inlineCodeFenceSize = 0
81+
82+
while (index < line.length) {
83+
const character = line[index]
84+
85+
if (character === '`') {
86+
const fenceSize = countRun(line, index, '`')
87+
const fence = '`'.repeat(fenceSize)
88+
result += fence
89+
index += fenceSize
90+
91+
if (inlineCodeFenceSize === 0) {
92+
inlineCodeFenceSize = fenceSize
93+
} else if (inlineCodeFenceSize === fenceSize) {
94+
inlineCodeFenceSize = 0
95+
}
96+
97+
continue
98+
}
99+
100+
if (inlineCodeFenceSize === 0 && character === '\\') {
101+
const nextCharacter = line[index + 1]
102+
103+
if (nextCharacter === '<') {
104+
result += escapedLessThanToken
105+
index += 2
106+
continue
107+
}
108+
109+
if (nextCharacter === '>') {
110+
result += escapedGreaterThanToken
111+
index += 2
112+
continue
113+
}
114+
}
115+
116+
result += character
117+
index++
118+
}
119+
120+
return result
121+
}
122+
123+
function countRun(value: string, start: number, character: string) {
124+
let index = start
125+
126+
while (value[index] === character) {
127+
index++
128+
}
129+
130+
return index - start
131+
}
132+
133+
function restoreEscapedAngleBrackets(document: MarkdownDocument) {
134+
for (const child of document.children) {
135+
restoreEscapedAngleBracketsInBlock(child)
136+
}
137+
}
138+
139+
function restoreEscapedAngleBracketsInBlock(block: BlockNode) {
140+
switch (block.type) {
141+
case 'heading':
142+
case 'paragraph':
143+
restoreEscapedAngleBracketsInInlineNodes(block.children)
144+
return
145+
case 'blockquote':
146+
case 'callout':
147+
case 'component':
148+
for (const child of block.children) {
149+
restoreEscapedAngleBracketsInBlock(child)
150+
}
151+
return
152+
case 'list':
153+
for (const item of block.items) {
154+
for (const child of item.children) {
155+
restoreEscapedAngleBracketsInBlock(child)
156+
}
157+
}
158+
return
159+
case 'table':
160+
for (const cell of [...block.header, ...block.rows.flat()]) {
161+
restoreEscapedAngleBracketsInInlineNodes(cell.children)
162+
}
163+
return
164+
case 'code':
165+
case 'html':
166+
case 'thematicBreak':
167+
return
168+
}
169+
}
170+
171+
function restoreEscapedAngleBracketsInInlineNodes(nodes: Array<InlineNode>) {
172+
for (const node of nodes) {
173+
switch (node.type) {
174+
case 'text':
175+
node.value = restoreEscapedAngleBracketText(node.value)
176+
break
177+
case 'strong':
178+
case 'emphasis':
179+
case 'strike':
180+
case 'link':
181+
restoreEscapedAngleBracketsInInlineNodes(node.children)
182+
break
183+
case 'inlineCode':
184+
case 'image':
185+
case 'break':
186+
case 'inlineHtml':
187+
break
188+
}
189+
}
190+
}
191+
192+
function restoreEscapedAngleBracketText(value: string) {
193+
return value
194+
.replaceAll(escapedLessThanToken, '<')
195+
.replaceAll(escapedGreaterThanToken, '>')
196+
}
197+
198+
function collectHeadings(document: MarkdownDocument): Array<MarkdownHeading> {
199+
const headings: Array<MarkdownHeading> = []
200+
201+
collectHeadingsFromBlocks(document.children, headings, undefined, false)
202+
203+
return headings
204+
}
205+
206+
function collectHeadingsFromBlocks(
207+
blocks: Array<BlockNode>,
208+
headings: Array<MarkdownHeading>,
209+
framework: string | undefined,
210+
insideSkippedComponent: boolean,
211+
) {
212+
for (const block of blocks) {
213+
switch (block.type) {
214+
case 'heading': {
215+
if (!insideSkippedComponent && block.id) {
216+
const heading: MarkdownHeading = {
217+
id: block.id,
218+
text: inlineText(block.children),
219+
level: block.depth,
220+
}
221+
const headingFramework = block.framework ?? framework
222+
223+
if (headingFramework) {
224+
heading.framework = headingFramework
225+
}
226+
227+
headings.push(heading)
228+
}
229+
230+
break
231+
}
232+
case 'list':
233+
for (const item of block.items) {
234+
collectHeadingsFromBlocks(
235+
item.children,
236+
headings,
237+
framework,
238+
insideSkippedComponent,
239+
)
240+
}
241+
break
242+
case 'blockquote':
243+
case 'callout':
244+
collectHeadingsFromBlocks(
245+
block.children,
246+
headings,
247+
framework,
248+
insideSkippedComponent,
249+
)
250+
break
251+
case 'component': {
252+
const componentName = block.name.toLowerCase()
253+
const nextInsideSkippedComponent =
254+
insideSkippedComponent || componentName === 'tabs'
255+
const nextFramework =
256+
block.tagName === 'md-framework-panel'
257+
? (block.properties?.['data-framework'] ?? framework)
258+
: framework
259+
260+
collectHeadingsFromBlocks(
261+
block.children,
262+
headings,
263+
nextFramework,
264+
nextInsideSkippedComponent,
265+
)
266+
break
267+
}
268+
case 'paragraph':
269+
case 'code':
270+
case 'table':
271+
case 'html':
272+
case 'thematicBreak':
273+
break
274+
}
275+
}
276+
}
277+
278+
function inlineText(nodes: Array<InlineNode>): string {
279+
let text = ''
280+
281+
for (const node of nodes) {
282+
switch (node.type) {
283+
case 'text':
284+
case 'inlineCode':
285+
text += node.value
286+
break
287+
case 'strong':
288+
case 'emphasis':
289+
case 'strike':
290+
case 'link':
291+
text += inlineText(node.children)
292+
break
293+
case 'image':
294+
text += node.alt
295+
break
296+
case 'break':
297+
case 'inlineHtml':
298+
break
299+
}
300+
}
301+
302+
return text
303+
}
304+
305+
function createHeadingSlugger() {
306+
const seen = new Map<string, number>()
307+
308+
return (value: string) => {
309+
const base =
310+
value
311+
.toLowerCase()
312+
.normalize('NFKD')
313+
.replace(/[\u0300-\u036f]/g, '')
314+
.replace(/&[a-z0-9#]+;/gi, '')
315+
.replace(/[^a-z0-9]+/g, '-')
316+
.replace(/^-+|-+$/g, '') || 'section'
317+
const count = seen.get(base) ?? 0
318+
319+
seen.set(base, count + 1)
320+
321+
return count === 0 ? base : `${base}-${count + 1}`
21322
}
22323
}

0 commit comments

Comments
 (0)