Skip to content

Commit 34ae939

Browse files
committed
fix: transclusion error.
1 parent e65e166 commit 34ae939

2 files changed

Lines changed: 259 additions & 34 deletions

File tree

frontend/src/components/Viewer/MarkdownViewer.jsx

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ function sanitizeMarkdownHtml(dirty) {
5959
// Stable default so non-publicMode callers don't retrigger effects every render.
6060
const EMPTY_DIAGRAMS = Object.freeze({})
6161

62+
// Cap how deep ![[transclusion]] chains can recurse. Five levels is a soft
63+
// ceiling — beyond that an include chain is almost certainly accidental, and
64+
// the cap keeps a runaway loop from generating an unbounded tree even if the
65+
// per-path visited check is somehow defeated.
66+
const MAX_TRANSCLUSION_DEPTH = 5
67+
6268
export default function MarkdownViewer({
6369
content,
6470
onDiagramClick,
@@ -178,44 +184,19 @@ export default function MarkdownViewer({
178184
})
179185
}, [])
180186

181-
// Load transclusions (disabled in publicMode: we never fetch private page
182-
// content anonymously; show a placeholder instead — see Q2 in to-do.md)
183-
useEffect(() => {
184-
if (!containerRef.current) return
185-
const elements = containerRef.current.querySelectorAll('[data-transclude]')
186-
if (publicMode) {
187-
elements.forEach((el) => {
188-
el.innerHTML =
189-
'<em class="text-text-secondary">(transclusion disabled on public pages)</em>'
190-
})
191-
return
192-
}
193-
elements.forEach(async (el) => {
194-
const slug = el.dataset.transclude
195-
try {
196-
const res = await api.get(`/pages/${slug}`)
197-
el.innerHTML = sanitizeMarkdownHtml(renderMarkdown(res.data.content_md || ''))
198-
await renderMermaidIn(el)
199-
} catch {
200-
el.innerHTML = '<em class="text-gray-400">Page not found</em>'
201-
}
202-
})
203-
}, [html, publicMode, renderMermaidIn])
204-
205-
// Render Mermaid diagrams
206-
useEffect(() => {
207-
renderMermaidIn(containerRef.current)
208-
}, [html, renderMermaidIn])
209-
210-
// Load Draw.io diagram SVGs.
187+
// Hoisted so transcluded subtrees can be diagram-loaded after their HTML is
188+
// injected — without this, [[wiki]]-embedded pages would render with stuck
189+
// "Loading Draw.io..." placeholders.
211190
//
212191
// In publicMode we don't have access to /api/diagrams/* — the caller passes
213192
// already-resolved SVGs via the `diagrams` prop (keyed by diagram id).
214-
useEffect(() => {
215-
if (!containerRef.current) return
216-
const blocks = containerRef.current.querySelectorAll('[data-diagram-id]')
193+
const renderDiagramsIn = useCallback(async (root) => {
194+
if (!root) return
195+
const blocks = root.querySelectorAll('[data-diagram-id]:not([data-diagram-rendered])')
196+
if (blocks.length === 0) return
217197
if (publicMode) {
218198
blocks.forEach((el) => {
199+
el.setAttribute('data-diagram-rendered', '1')
219200
const id = el.dataset.diagramId
220201
const svg = diagrams[id]
221202
if (svg) {
@@ -231,6 +212,7 @@ export default function MarkdownViewer({
231212
return
232213
}
233214
blocks.forEach(async (el) => {
215+
el.setAttribute('data-diagram-rendered', '1')
234216
const id = el.dataset.diagramId
235217
try {
236218
const res = await api.get(`/diagrams/${id}`)
@@ -247,7 +229,86 @@ export default function MarkdownViewer({
247229
el.innerHTML = `<div class="drawio-placeholder drawio-error">Diagram #${id} not found</div>`
248230
}
249231
})
250-
}, [html, publicMode, diagrams])
232+
}, [publicMode, diagrams])
233+
234+
// Load transclusions (disabled in publicMode: we never fetch private page
235+
// content anonymously; show a placeholder instead — see Q2 in to-do.md)
236+
//
237+
// The recursive loader is defined inside the effect so its self-reference
238+
// doesn't run afoul of the react-hooks accessed-before-declared rule, and
239+
// because there's no caller outside this effect that needs a stable
240+
// reference to it.
241+
//
242+
// 1. Find [data-transclude] inside `root` we haven't claimed yet.
243+
// 2. Mark each claimed (data-transclude-loaded) so a re-fired effect
244+
// doesn't fire a duplicate fetch while the original is still in
245+
// flight. This is per-element, NOT per-slug — the same slug embedded
246+
// in two places will still load both times.
247+
// 3. Refuse to recurse if the slug is already on the current path
248+
// (per-path `visited` so two siblings can include the same page),
249+
// or if depth has reached the cap.
250+
// 4. After injecting the page body, recurse into the new subtree, then
251+
// kick the imperative diagram/mermaid renderers on it.
252+
useEffect(() => {
253+
if (!containerRef.current) return
254+
if (publicMode) {
255+
containerRef.current.querySelectorAll('[data-transclude]').forEach((el) => {
256+
el.innerHTML =
257+
'<em class="text-text-secondary">(transclusion disabled on public pages)</em>'
258+
})
259+
return
260+
}
261+
const loadTransclusionsIn = async (root, depth, visited) => {
262+
if (!root) return
263+
const elements = root.querySelectorAll('[data-transclude]:not([data-transclude-loaded])')
264+
if (elements.length === 0) return
265+
266+
await Promise.all(Array.from(elements).map(async (el) => {
267+
el.setAttribute('data-transclude-loaded', '1')
268+
const slug = el.dataset.transclude
269+
270+
if (visited.has(slug)) {
271+
el.innerHTML = '<em class="text-text-secondary">(circular transclusion)</em>'
272+
return
273+
}
274+
if (depth >= MAX_TRANSCLUSION_DEPTH) {
275+
el.innerHTML = '<em class="text-text-secondary">(max transclusion depth reached)</em>'
276+
return
277+
}
278+
279+
try {
280+
const res = await api.get(`/pages/${slug}`)
281+
el.innerHTML = sanitizeMarkdownHtml(renderMarkdown(res.data.content_md || ''))
282+
const nextVisited = new Set(visited).add(slug)
283+
await loadTransclusionsIn(el, depth + 1, nextVisited)
284+
await renderMermaidIn(el)
285+
await renderDiagramsIn(el)
286+
} catch (err) {
287+
const status = err?.response?.status
288+
if (status === 404) {
289+
el.innerHTML = '<em class="text-gray-400">Page not found</em>'
290+
} else if (status === 403) {
291+
el.innerHTML = '<em class="text-text-secondary">(no access)</em>'
292+
} else {
293+
el.innerHTML = '<em class="text-gray-400">Failed to load transclusion</em>'
294+
}
295+
}
296+
}))
297+
}
298+
loadTransclusionsIn(containerRef.current, 0, new Set())
299+
}, [html, publicMode, renderMermaidIn, renderDiagramsIn])
300+
301+
// Top-level Mermaid pass — transcluded subtrees get their own
302+
// renderMermaidIn call from inside loadTransclusionsIn.
303+
useEffect(() => {
304+
renderMermaidIn(containerRef.current)
305+
}, [html, renderMermaidIn])
306+
307+
// Top-level Draw.io pass — transcluded subtrees are handled by the
308+
// loadTransclusionsIn recursion.
309+
useEffect(() => {
310+
renderDiagramsIn(containerRef.current)
311+
}, [html, renderDiagramsIn])
251312

252313
// Add rel="nofollow" to every wikilink when rendering a public page, so
253314
// crawlers don't waste budget on private slugs (see Q13 in to-do.md).
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React from 'react'
2+
import { describe, it, expect, vi, beforeEach } from 'vitest'
3+
import { render, waitFor } from '@testing-library/react'
4+
import { MemoryRouter } from 'react-router-dom'
5+
import MarkdownViewer from './MarkdownViewer'
6+
import api from '../../api/client'
7+
8+
vi.mock('../../api/client', () => ({
9+
default: { get: vi.fn() },
10+
}))
11+
12+
vi.mock('../../lib/mermaidBootstrap', () => ({
13+
ensureMermaid: vi.fn(() => ({
14+
render: vi.fn(() => Promise.resolve({ svg: '<svg>mermaid</svg>' })),
15+
})),
16+
}))
17+
18+
function renderViewer(props) {
19+
return render(
20+
<MemoryRouter>
21+
<MarkdownViewer {...props} />
22+
</MemoryRouter>,
23+
)
24+
}
25+
26+
function pageResponse(content) {
27+
return Promise.resolve({ data: { content_md: content } })
28+
}
29+
30+
function httpError(status) {
31+
const err = new Error(`HTTP ${status}`)
32+
err.response = { status }
33+
return Promise.reject(err)
34+
}
35+
36+
describe('MarkdownViewer transclusion recursion', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
})
40+
41+
it('loads nested transclusions A → B → C', async () => {
42+
api.get.mockImplementation((url) => {
43+
if (url === '/pages/B') return pageResponse('B body ![[C]]')
44+
if (url === '/pages/C') return pageResponse('C body content')
45+
return httpError(404)
46+
})
47+
48+
const { container } = renderViewer({ content: 'A header ![[B]]' })
49+
50+
await waitFor(() => {
51+
expect(container.textContent).toContain('B body')
52+
expect(container.textContent).toContain('C body content')
53+
})
54+
})
55+
56+
it('detects self-cycle A → A', async () => {
57+
api.get.mockImplementation((url) => {
58+
if (url === '/pages/A') return pageResponse('A self ![[A]]')
59+
return httpError(404)
60+
})
61+
62+
const { container } = renderViewer({ content: '![[A]]' })
63+
64+
await waitFor(() => {
65+
expect(container.textContent).toContain('(circular transclusion)')
66+
})
67+
})
68+
69+
it('detects mutual cycle A → B → A', async () => {
70+
api.get.mockImplementation((url) => {
71+
if (url === '/pages/A') return pageResponse('![[B]]')
72+
if (url === '/pages/B') return pageResponse('![[A]]')
73+
return httpError(404)
74+
})
75+
76+
const { container } = renderViewer({ content: '![[A]]' })
77+
78+
await waitFor(() => {
79+
expect(container.textContent).toContain('(circular transclusion)')
80+
})
81+
})
82+
83+
it('stops a runaway chain at max depth', async () => {
84+
// Each page transcludes the next letter. Without a cap this is unbounded;
85+
// assert the cap fires and surfaces the message.
86+
api.get.mockImplementation((url) => {
87+
const slug = url.split('/').pop()
88+
const next = String.fromCharCode(slug.charCodeAt(0) + 1)
89+
return pageResponse(`level ${slug} ![[${next}]]`)
90+
})
91+
92+
const { container } = renderViewer({ content: '![[A]]' })
93+
94+
await waitFor(() => {
95+
expect(container.textContent).toContain('(max transclusion depth reached)')
96+
})
97+
// The cap fires once on the deepest unrendered placeholder, not on every
98+
// intermediate level.
99+
expect(container.textContent.match(/max transclusion depth/g)).toHaveLength(1)
100+
})
101+
102+
it('loads sibling transclusions of the same slug independently', async () => {
103+
// Two separate ![[B]] occurrences: per-path visited means both must load,
104+
// not get deduped into a single render.
105+
api.get.mockImplementation((url) => {
106+
if (url === '/pages/B') return pageResponse('shared B body')
107+
return httpError(404)
108+
})
109+
110+
const { container } = renderViewer({ content: '![[B]]\n\n![[B]]' })
111+
112+
await waitFor(() => {
113+
const bodies = container.querySelectorAll('.transclusion-content')
114+
expect(bodies.length).toBe(2)
115+
bodies.forEach((el) => {
116+
expect(el.textContent).toContain('shared B body')
117+
})
118+
})
119+
})
120+
121+
it('renders Mermaid and Draw.io inside transcluded content', async () => {
122+
api.get.mockImplementation((url) => {
123+
if (url === '/pages/B') {
124+
return pageResponse('::drawio[42]\n\n```mermaid\ngraph TD\nA-->B\n```')
125+
}
126+
if (url === '/diagrams/42') {
127+
return Promise.resolve({ data: { svg_cache: '<svg>diagram-42</svg>' } })
128+
}
129+
return httpError(404)
130+
})
131+
132+
const { container } = renderViewer({ content: '![[B]]' })
133+
134+
await waitFor(() => {
135+
const drawio = container.querySelector('[data-diagram-id="42"]')
136+
expect(drawio).not.toBeNull()
137+
// The recursive renderDiagramsIn call should replace the placeholder
138+
// with the resolved SVG even though the diagram lives inside a
139+
// transcluded subtree.
140+
expect(drawio.querySelector('.drawio-svg')).not.toBeNull()
141+
expect(container.querySelector('[data-mermaid]')).not.toBeNull()
142+
})
143+
})
144+
145+
it('shows distinct messages for 404, 403, and other errors', async () => {
146+
api.get.mockImplementation((url) => {
147+
if (url === '/pages/missing') return httpError(404)
148+
if (url === '/pages/forbidden') return httpError(403)
149+
if (url === '/pages/broken') return httpError(500)
150+
return httpError(404)
151+
})
152+
153+
const { container } = renderViewer({
154+
content: '![[missing]]\n\n![[forbidden]]\n\n![[broken]]',
155+
})
156+
157+
await waitFor(() => {
158+
const text = container.textContent
159+
expect(text).toContain('Page not found')
160+
expect(text).toContain('(no access)')
161+
expect(text).toContain('Failed to load transclusion')
162+
})
163+
})
164+
})

0 commit comments

Comments
 (0)