Skip to content

Commit bd1a8a4

Browse files
authored
Merge pull request #535 from raifdmueller/feat/responsive-left-toc
Move inline TOC to responsive left sidebar; fix logo aspect ratio on narrow screens
2 parents 0f37a8a + 270268d commit bd1a8a4

4 files changed

Lines changed: 170 additions & 10 deletions

File tree

scripts/render-docs.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,62 @@ const OPTS = {
3636
},
3737
}
3838

39+
/**
40+
* Extract the AsciiDoc-generated `<div id="toc">...</div>` block from the
41+
* rendered HTML. Returns `{ toc, body }` where `toc` is the TOC HTML (or
42+
* `null` if the doc has no TOC) and `body` is the HTML with the TOC removed.
43+
*
44+
* AsciiDoctor outputs the TOC as a single `<div id="toc" class="toc">` (or
45+
* `class="toc2"` for `:toc: left`). We depth-count `<div>` open/close tags
46+
* so a TOC with arbitrarily nested ULs is removed cleanly.
47+
*
48+
* Splitting the TOC out at build time lets doc-page.js render it in its own
49+
* sticky sidebar slot instead of inline at the top of the body.
50+
*/
51+
function extractToc(html) {
52+
const startMatch = html.match(/<div[^>]*\sid="toc"[^>]*>/)
53+
if (!startMatch) return { toc: null, body: html }
54+
const start = startMatch.index
55+
let depth = 1
56+
let pos = start + startMatch[0].length
57+
while (pos < html.length && depth > 0) {
58+
const nextOpen = html.indexOf('<div', pos)
59+
const nextClose = html.indexOf('</div>', pos)
60+
if (nextClose === -1) return { toc: null, body: html }
61+
if (nextOpen !== -1 && nextOpen < nextClose) {
62+
depth++
63+
pos = nextOpen + 4
64+
} else {
65+
depth--
66+
pos = nextClose + 6
67+
}
68+
}
69+
if (depth !== 0) return { toc: null, body: html }
70+
return { toc: html.slice(start, pos), body: html.slice(0, start) + html.slice(pos) }
71+
}
72+
3973
/**
4074
* Render a single AsciiDoc file to HTML.
4175
* Uses safe:'safe' so include:: directives are resolved from the filesystem.
76+
* If the rendered HTML contains an AsciiDoc TOC, it is extracted into a
77+
* sidecar `<basename>.toc.html` file so doc-page.js can render it in its
78+
* own sidebar slot.
4279
*/
4380
function renderFile(srcPath, destPath) {
4481
if (!fs.existsSync(srcPath)) return
4582
try {
4683
fs.mkdirSync(path.dirname(destPath), { recursive: true })
47-
const html = asciidoctor.convertFile(srcPath, { ...OPTS, to_file: false })
48-
fs.writeFileSync(destPath, String(html), 'utf-8')
84+
const html = String(asciidoctor.convertFile(srcPath, { ...OPTS, to_file: false }))
85+
const { toc, body } = extractToc(html)
86+
fs.writeFileSync(destPath, body, 'utf-8')
4987
console.log(`Rendered: ${path.relative(ROOT, destPath)}`)
88+
const tocPath = destPath.replace(/\.html$/, '.toc.html')
89+
if (toc) {
90+
fs.writeFileSync(tocPath, toc, 'utf-8')
91+
console.log(`Rendered: ${path.relative(ROOT, tocPath)}`)
92+
} else if (fs.existsSync(tocPath)) {
93+
fs.unlinkSync(tocPath)
94+
}
5095
} catch (err) {
5196
console.error(`Failed to render ${path.relative(ROOT, srcPath)}:`, err.message)
5297
process.exit(1)

website/src/components/doc-page.js

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,28 @@ import { i18n } from '../i18n.js'
22
import { 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
*/
814
export 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(/\.html$/, `.${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(/\.html$/, '.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*<div[^>]*\sid="toc"/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+
}

website/src/components/header.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function renderHeader() {
1111
<!-- Logo left, spanning both rows -->
1212
<div class="flex items-center">
1313
<a href="#/" class="no-underline flex flex-col items-center">
14-
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="218" height="96" class="h-24 w-[218px]" />
14+
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="218" height="96" class="h-24 w-[218px] max-w-none shrink-0" />
1515
<span class="text-xs text-[var(--color-text-secondary)] leading-tight" data-i18n="header.slogan">${i18n.t('header.slogan')}</span>
1616
</a>
1717
<button
@@ -101,7 +101,7 @@ export function renderHeader() {
101101
<div class="sm:hidden">
102102
<div class="flex flex-col items-center">
103103
<a href="#/" class="no-underline flex flex-col items-center">
104-
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="145" height="64" class="h-16 w-[145px]" />
104+
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="145" height="64" class="h-16 w-[145px] max-w-none shrink-0" />
105105
<span class="text-xs text-[var(--color-text-secondary)] leading-tight text-center" data-i18n="header.slogan">${i18n.t('header.slogan')}</span>
106106
</a>
107107
<div class="flex items-center gap-3 mt-2">

website/src/styles/asciidoctor-scoped.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,60 @@
292292
border-color: #bf0000;
293293
}
294294
}
295+
296+
/*
297+
* Styles for the TOC sidebar that doc-page.js extracts from each rendered
298+
* doc and injects into the #doc-toc aside. The TOC HTML keeps its
299+
* asciidoctor-generated id/classes (#toc, #toctitle, ul.sectlevel1, ...).
300+
* We do not reuse the body.toc2 fixed-position styles because the aside
301+
* is already positioned by Tailwind classes on the doc-page shell.
302+
*/
303+
.doc-toc {
304+
font-size: 0.875rem;
305+
line-height: 1.5;
306+
color: var(--color-text);
307+
}
308+
309+
.doc-toc #toctitle {
310+
font-size: 0.75rem;
311+
font-weight: 600;
312+
text-transform: uppercase;
313+
letter-spacing: 0.05em;
314+
color: var(--color-text-secondary);
315+
margin-bottom: 0.75rem;
316+
padding-bottom: 0.5rem;
317+
border-bottom: 1px solid var(--color-border);
318+
}
319+
320+
.doc-toc ul {
321+
list-style: none;
322+
margin: 0;
323+
padding: 0;
324+
}
325+
326+
.doc-toc ul ul {
327+
padding-left: 0.875rem;
328+
margin-top: 0.125rem;
329+
margin-bottom: 0.25rem;
330+
border-left: 1px solid var(--color-border);
331+
}
332+
333+
.doc-toc li {
334+
margin: 0.125rem 0;
335+
}
336+
337+
.doc-toc a {
338+
color: var(--color-text-secondary);
339+
text-decoration: none;
340+
display: block;
341+
padding: 0.125rem 0.5rem;
342+
border-radius: 0.25rem;
343+
transition:
344+
color 0.15s,
345+
background-color 0.15s;
346+
}
347+
348+
.doc-toc a:hover {
349+
color: var(--color-text);
350+
background-color: var(--color-bg-secondary);
351+
}

0 commit comments

Comments
 (0)