@@ -2,16 +2,28 @@ import { i18n } from '../i18n.js'
22import { hydrateYouTubeFacades } from '../utils/youtube-facade.js'
33
44/**
5- * Render a documentation page shell (content loaded async)
5+ * Render a documentation page shell (content loaded async).
6+ *
7+ * Layout: on `lg` and up, a sticky TOC sidebar sits to the left of the
8+ * content. On smaller screens the aside stacks above the content. The
9+ * aside stays hidden until loadDocContent fetches a TOC sidecar; pages
10+ * without a TOC keep the full content width.
11+ *
612 * @returns {string } HTML string with loading placeholder
713 */
814export function renderDocPage ( ) {
915 return `
1016 <main class="flex-1">
11- <div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
12- <div id="doc-content" class="asciidoc-content">
13- <div class="flex items-center justify-center py-12">
14- <div class="animate-pulse text-gray-500 dark:text-gray-400">Loading...</div>
17+ <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
18+ <div class="lg:flex lg:gap-8">
19+ <aside
20+ id="doc-toc"
21+ class="doc-toc hidden mb-6 max-h-72 overflow-y-auto p-4 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] lg:mb-0 lg:w-64 lg:flex-shrink-0 lg:sticky lg:top-20 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:p-0 lg:border-0 lg:rounded-none lg:bg-transparent"
22+ ></aside>
23+ <div id="doc-content" class="asciidoc-content min-w-0 lg:flex-1 lg:max-w-4xl">
24+ <div class="flex items-center justify-center py-12">
25+ <div class="animate-pulse text-gray-500 dark:text-gray-400">Loading...</div>
26+ </div>
1527 </div>
1628 </div>
1729 </div>
@@ -33,11 +45,14 @@ export async function loadDocContent(docPath) {
3345
3446 try {
3547 let response
48+ let resolvedPath = htmlPath
3649
3750 if ( currentLang !== 'en' ) {
3851 const langPath = htmlPath . replace ( / \. h t m l $ / , `.${ currentLang } .html` )
3952 response = await fetch ( `${ import . meta. env . BASE_URL } ${ langPath } ` )
40- if ( ! response . ok ) {
53+ if ( response . ok ) {
54+ resolvedPath = langPath
55+ } else {
4156 response = await fetch ( `${ import . meta. env . BASE_URL } ${ htmlPath } ` )
4257 }
4358 } else {
@@ -48,6 +63,8 @@ export async function loadDocContent(docPath) {
4863 throw new Error ( `Failed to load: ${ response . status } ` )
4964 }
5065
66+ await loadDocToc ( resolvedPath )
67+
5168 contentEl . innerHTML = await response . text ( )
5269
5370 // Auto-expand collapsible sections
@@ -91,3 +108,44 @@ export async function loadDocContent(docPath) {
91108 `
92109 }
93110}
111+
112+ /**
113+ * Fetch the TOC sidecar that render-docs.js extracts at build time and
114+ * inject it into the #doc-toc aside. Pages without a TOC produce no
115+ * sidecar — the fetch 404s and the aside stays hidden.
116+ *
117+ * Also rewrites the in-page hash links so they bypass the SPA router
118+ * (which only handles `#/...` routes) and scroll to the target heading.
119+ */
120+ async function loadDocToc ( htmlPath ) {
121+ const aside = document . getElementById ( 'doc-toc' )
122+ if ( ! aside ) return
123+
124+ aside . innerHTML = ''
125+ aside . classList . add ( 'hidden' )
126+
127+ const tocPath = htmlPath . replace ( / \. h t m l $ / , '.toc.html' )
128+ try {
129+ const tocResponse = await fetch ( `${ import . meta. env . BASE_URL } ${ tocPath } ` )
130+ if ( ! tocResponse . ok ) return
131+ const text = await tocResponse . text ( )
132+ // Vite's SPA fallback returns index.html (200 OK) for missing files.
133+ // Verify the response is actually a TOC fragment, not the SPA shell.
134+ if ( ! / ^ \s * < d i v [ ^ > ] * \s i d = " t o c " / i. test ( text ) ) return
135+ aside . innerHTML = text
136+ aside . classList . remove ( 'hidden' )
137+
138+ aside . querySelectorAll ( 'a[href^="#"]' ) . forEach ( ( link ) => {
139+ const href = link . getAttribute ( 'href' )
140+ if ( ! href || href . startsWith ( '#/' ) ) return
141+ link . addEventListener ( 'click' , ( e ) => {
142+ e . preventDefault ( )
143+ const id = decodeURIComponent ( href . slice ( 1 ) )
144+ const target = document . getElementById ( id )
145+ if ( target ) target . scrollIntoView ( { behavior : 'smooth' , block : 'start' } )
146+ } )
147+ } )
148+ } catch {
149+ // No TOC for this page — leave aside hidden.
150+ }
151+ }
0 commit comments