@@ -59,6 +59,12 @@ function sanitizeMarkdownHtml(dirty) {
5959// Stable default so non-publicMode callers don't retrigger effects every render.
6060const 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+
6268export 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).
0 commit comments