Skip to content

Commit f0bbf90

Browse files
ZJvandeWegclaudeYndira-E
authored
nuxt: migrate handbook to Nuxt with @nuxt/content (#5065)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Yndira-E <yndira@flowfuse.com>
1 parent 5c7ee9e commit f0bbf90

250 files changed

Lines changed: 1039 additions & 761 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eleventy.js

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -531,12 +531,13 @@ module.exports = function(eleventyConfig) {
531531
return new URL(url, site.baseURL).href;
532532
})
533533

534+
534535
eleventyConfig.addFilter("handbookBreadcrumbs", (url) => {
535536
let parts = url.split("/").filter(e => e !== '');
536537
if (parts[parts.length-1] === "index") {
537538
parts.pop();
538539
}
539-
540+
540541
let path = "";
541542
return "/"+parts.map(p => {
542543
let url = `${path}/${p}`;
@@ -546,24 +547,16 @@ module.exports = function(eleventyConfig) {
546547
});
547548

548549
eleventyConfig.addFilter("rewriteHandbookLinks", (str, page) => {
549-
// If page.inputPath looks like: ./src/handbook/abc/def.md
550-
// then the url of the page will be `/handbook/abc/def/`
551-
// links of the form `./` or `[^/]` must be prepended with `../`
552-
// to ensure it links to the right place
553-
554550
const isIndexPage = /(README.md|index.md)$/i.test(page.inputPath)
555551

556552
const matcher = /((href|src)="([^"]*))"/g
557553
let match
558554
while ((match = matcher.exec(str)) !== null) {
559555
let url = match[3]
560556
if (/^(http|#|mailto:)/.test(url)) {
561-
// Do not rewrite absolute urls, in-page anchors or emails
562557
continue
563558
}
564-
// */abc.md#anchor => */abc/#anchor
565559
url = url.replace(/.md(#.*)?$/, '$1')
566-
// */README#anchor => */#anchor
567560
url = url.replace(/README(#.*)?$/, '$1')
568561
if (url[0] !== '/' && !isIndexPage) {
569562
url = '../'+url
@@ -922,7 +915,7 @@ module.exports = function(eleventyConfig) {
922915
// Inject tier badges into docs pages: parent feature after H1, subfeatures after their headings
923916
eleventyConfig.addTransform("docsFeatureBadges", function(content) {
924917
if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content;
925-
if (!this.page.url || !/^(\/docs\/|\/node-red\/|\/handbook\/)/.test(this.page.url)) return content;
918+
if (!this.page.url || !/^(\/docs\/|\/node-red\/)/.test(this.page.url)) return content;
926919

927920
const parentFeature = findFeatureByDocsLink(this.page.url);
928921
const subfeatures = findSubfeaturesForDocsPage(this.page.url);
@@ -1109,7 +1102,6 @@ module.exports = function(eleventyConfig) {
11091102
eleventyConfig.addCollection('nav', function(collection) {
11101103
let nav = {}
11111104

1112-
createNav('handbook')
11131105
createNav('docs')
11141106

11151107
function createNav(tag) {
@@ -1140,7 +1132,7 @@ module.exports = function(eleventyConfig) {
11401132
// recursively parse the folder hierarchy and created our collection object
11411133
// pass nav = {} as the first accumulator - build up hierarchy map of TOC
11421134
hierarchy.reduce((accumulator, currentValue, i) => {
1143-
// create a nested object detailing the full handbook hierarchy
1135+
// create a nested object detailing the full docs hierarchy
11441136
if (!accumulator[currentValue]) {
11451137
accumulator[currentValue] = {
11461138
'name': currentValue,
@@ -1191,7 +1183,6 @@ module.exports = function(eleventyConfig) {
11911183
}
11921184
}
11931185

1194-
// not req'd to have handbook in Website build, so this may be empty
11951186
if (nav[tag]) {
11961187
for (child of nav[tag].children) {
11971188
if (child.group) {
@@ -1205,7 +1196,7 @@ module.exports = function(eleventyConfig) {
12051196
}
12061197
groups[group].children.push(child)
12071198
} else {
1208-
// capture & flag top-level handbook docs, that haven't had a group assigned
1199+
// capture & flag top-level docs that haven't had a group assigned
12091200
groups['Other'].children.push(child)
12101201
}
12111202
}

nuxt/.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
.output/
44
dist/
55

6-
# Generated by 11ty during unified build
7-
public/
6+
# Generated by 11ty during unified build (handbook images are tracked separately)
7+
/public/*
8+
!/public/handbook/
89

910
# Dependencies (hoisted to workspace root)
1011
node_modules/
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<script setup lang="ts">
2+
import { buildHandbookNav, type NavNode, type NavGroup } from '~/composables/useHandbookNav'
3+
4+
const route = useRoute()
5+
6+
const { data: pages } = await useAsyncData('handbook-nav', () =>
7+
queryCollection('handbook').all()
8+
)
9+
10+
const navGroups = computed<NavGroup[]>(() => {
11+
if (!pages.value) return []
12+
return buildHandbookNav(pages.value as any[])
13+
})
14+
15+
function normPath(p: string) {
16+
return p.replace(/\/$/, '') || '/'
17+
}
18+
19+
function isActive(nodePath: string): boolean {
20+
return normPath(route.path) === normPath(nodePath)
21+
}
22+
23+
function hasActiveDescendant(node: NavNode): boolean {
24+
if (isActive(node.path)) return true
25+
return node.children.some(hasActiveDescendant)
26+
}
27+
28+
const manuallyExpanded = ref<Set<string>>(new Set())
29+
30+
function toggle(path: string) {
31+
const next = new Set(manuallyExpanded.value)
32+
if (next.has(path)) {
33+
next.delete(path)
34+
} else {
35+
next.add(path)
36+
}
37+
manuallyExpanded.value = next
38+
}
39+
40+
function isOpen(node: NavNode): boolean {
41+
return manuallyExpanded.value.has(node.path) || hasActiveDescendant(node)
42+
}
43+
44+
function ulStyle(node: NavNode) {
45+
return isOpen(node) ? { maxHeight: 'initial' } : {}
46+
}
47+
</script>
48+
49+
<template>
50+
<div class="border-r lg:pt-2 text-sm" data-handbook>
51+
<ul class="handbook-nav" data-el="navigation">
52+
<li :class="{ active: isActive('/handbook') }">
53+
<NuxtLink href="/handbook">Handbook</NuxtLink>
54+
</li>
55+
56+
<template v-for="group in navGroups" :key="group.name">
57+
<li class="handbook-nav-group">{{ group.name }}</li>
58+
59+
<template v-for="entry in group.children" :key="entry.path">
60+
<li :class="{ active: isActive(entry.path), open: isOpen(entry) && entry.children.length > 0 }">
61+
<NuxtLink :href="entry.path">{{ entry.name }}</NuxtLink>
62+
<button v-if="entry.children.length"
63+
@click="toggle(entry.path)"
64+
:aria-expanded="isOpen(entry).toString()"
65+
:aria-label="`Toggle ${entry.name} submenu`">
66+
<span class="ff-icon icon-expand">
67+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
68+
</span>
69+
<span class="ff-icon icon-minimise">
70+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
71+
</span>
72+
</button>
73+
</li>
74+
75+
<li v-if="entry.children.length" class="contents">
76+
<ul class="handbook-nav-nested" :style="ulStyle(entry)">
77+
<template v-for="child in entry.children" :key="child.path">
78+
<li :class="{ active: isActive(child.path), open: isOpen(child) && child.children.length > 0 }">
79+
<NuxtLink :href="child.path">{{ child.name }}</NuxtLink>
80+
<button v-if="child.children.length"
81+
@click="toggle(child.path)"
82+
:aria-expanded="isOpen(child).toString()"
83+
:aria-label="`Toggle ${child.name} submenu`">
84+
<span class="ff-icon icon-expand">
85+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
86+
</span>
87+
<span class="ff-icon icon-minimise">
88+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
89+
</span>
90+
</button>
91+
</li>
92+
93+
<li v-if="child.children.length" class="contents">
94+
<ul class="handbook-nav-nested-2" :style="ulStyle(child)">
95+
<li v-for="grandchild in child.children" :key="grandchild.path"
96+
:class="{ active: isActive(grandchild.path) }">
97+
<NuxtLink :href="grandchild.path">{{ grandchild.name }}</NuxtLink>
98+
</li>
99+
</ul>
100+
</li>
101+
</template>
102+
</ul>
103+
</li>
104+
</template>
105+
</template>
106+
</ul>
107+
</div>
108+
</template>

nuxt/components/HandbookSearch.vue

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
const searchContainer = ref<HTMLElement>()
3+
4+
onMounted(async () => {
5+
const loadScript = (src: string, integrity?: string): Promise<void> =>
6+
new Promise((resolve, reject) => {
7+
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return }
8+
const script = document.createElement('script')
9+
script.src = src
10+
if (integrity) { script.integrity = integrity; script.crossOrigin = 'anonymous' }
11+
script.onload = () => resolve()
12+
script.onerror = reject
13+
document.head.appendChild(script)
14+
})
15+
16+
const loadLink = (href: string, integrity?: string) => {
17+
if (document.querySelector(`link[href="${href}"]`)) return
18+
const link = document.createElement('link')
19+
link.rel = 'stylesheet'
20+
link.href = href
21+
if (integrity) { link.integrity = integrity; link.crossOrigin = 'anonymous' }
22+
document.head.appendChild(link)
23+
}
24+
25+
loadLink('https://cdn.jsdelivr.net/npm/instantsearch.css@8.5.1/themes/reset-min.css', 'sha256-KvFgFCzgqSErAPu6y9gz/AhZAvzK48VJASu3DpNLCEQ=')
26+
loadLink('https://cdn.jsdelivr.net/npm/@algolia/autocomplete-theme-classic@1.6.1')
27+
28+
await loadScript(
29+
'https://cdn.jsdelivr.net/npm/algoliasearch@4.24.0/dist/algoliasearch-lite.umd.js',
30+
'sha256-b2n6oSgG4C1stMT/yc/ChGszs9EY/Mhs6oltEjQbFCQ='
31+
)
32+
await loadScript('https://cdn.jsdelivr.net/npm/@algolia/autocomplete-js@1.6.1')
33+
34+
const win = window as any
35+
const { autocomplete, getAlgoliaResults } = win['@algolia/autocomplete-js']
36+
const searchClient = win.algoliasearch('ISKYOHIT7D', '68d4032f487d66423c37e6483e067272')
37+
38+
const initialHitsPerPage = 5
39+
let hitsPerPage = initialHitsPerPage
40+
let prevQuery = ''
41+
let totalHits = 0
42+
43+
autocomplete({
44+
container: searchContainer.value!,
45+
placeholder: 'Search in Handbook...',
46+
getSources ({ query }: { query: string }) {
47+
if (query !== prevQuery) {
48+
prevQuery = query
49+
hitsPerPage = initialHitsPerPage
50+
}
51+
return [{
52+
sourceId: 'handbook',
53+
getItems: () => getAlgoliaResults({
54+
searchClient,
55+
queries: [{
56+
indexName: 'prod_netlify',
57+
params: { query, hitsPerPage, attributesToSnippet: ['content:50'] },
58+
attributesToHighlight: '*',
59+
filters: 'category:handbook'
60+
}],
61+
transformResponse ({ hits, results }: any) {
62+
totalHits = results[0].nbHits
63+
return hits
64+
}
65+
}),
66+
templates: {
67+
item ({ item, components, html }: any) {
68+
return html`
69+
<a href="#" data-href="${item.url}" class="aa-ItemWrapper">
70+
<div class="aa-ItemContent">
71+
<div class="aa-ItemIcon aa-ItemIcon--alignTop">
72+
<img src="#" data-src="${item.image}" alt="${item.name}" width="40" height="40" />
73+
</div>
74+
<div class="aa-ItemContentBody">
75+
<div class="aa-ItemContentTitle">
76+
${components.Highlight({ hit: item, attribute: ['hierarchy', 'lvl0'] })}
77+
</div>
78+
<div class="aa-ItemContentSubTitle ${item.type === 'lvl0' ? 'hidden' : ''}">
79+
${components.Highlight({ hit: item, attribute: ['hierarchy', item.type] })}
80+
</div>
81+
<div class="aa-ItemContentDescription">
82+
${item.content?.trim().length > 0
83+
? components.Snippet({ hit: item, attribute: 'content' })
84+
: components.Snippet({ hit: item, attribute: 'description' })}
85+
</div>
86+
</div>
87+
</div>
88+
</a>`
89+
},
90+
footer ({ items, html }: any) {
91+
if (!items.length || items.length >= totalHits) return null
92+
return html`<button type="button" class="aa-LoadMore load-more-btn">Load more...</button>`
93+
}
94+
}
95+
}]
96+
},
97+
onStateChange ({ refresh }: any) {
98+
document.querySelectorAll('.aa-Panel a').forEach((el: any) => {
99+
el.href = el.getAttribute('data-href')
100+
})
101+
document.querySelectorAll('.aa-Panel img').forEach((img: any) => {
102+
img.src = img.getAttribute('data-src')
103+
})
104+
const btn = document.querySelector<HTMLElement>('.load-more-btn')
105+
if (btn) {
106+
btn.onclick = (e) => {
107+
e.preventDefault()
108+
e.stopPropagation()
109+
hitsPerPage += initialHitsPerPage
110+
refresh()
111+
}
112+
}
113+
}
114+
})
115+
})
116+
</script>
117+
118+
<template>
119+
<div ref="searchContainer" id="algolia-search" class="border rounded"></div>
120+
</template>

nuxt/components/HandbookToc.vue

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
interface TocItem {
3+
id: string
4+
text: string
5+
level: number
6+
}
7+
8+
const toc = ref<TocItem[]>([])
9+
const activeId = ref<string>('')
10+
11+
onMounted(() => {
12+
const content = document.querySelector('.handbook-content')
13+
if (!content) return
14+
15+
const headings = content.querySelectorAll('h2, h3, h4')
16+
toc.value = Array.from(headings)
17+
.map(h => ({
18+
id: h.id,
19+
text: h.textContent?.trim() || '',
20+
level: parseInt(h.tagName[1])
21+
}))
22+
.filter(h => h.id && h.text)
23+
24+
if (!toc.value.length) return
25+
26+
const observer = new IntersectionObserver(
27+
(entries) => {
28+
const visible = entries.filter(e => e.isIntersecting)
29+
if (visible.length) activeId.value = visible[0].target.id
30+
},
31+
{ rootMargin: '-80px 0px -60% 0px', threshold: 0 }
32+
)
33+
headings.forEach(h => { if (h.id) observer.observe(h) })
34+
onUnmounted(() => observer.disconnect())
35+
})
36+
</script>
37+
38+
<template>
39+
<div v-if="toc.length" class="mb-6">
40+
<h3 class="mb-3">Table of Contents</h3>
41+
<div class="toc-wrapper text-sm">
42+
<ul class="list-none p-0 m-0">
43+
<li v-for="item in toc" :key="item.id"
44+
:class="['mb-4', item.level === 2 ? 'pl-0' : item.level === 3 ? 'pl-4' : 'pl-8']">
45+
<a :href="`#${item.id}`"
46+
:class="['block py-[0.2rem] text-blue-600 no-underline transition-all duration-200 hover:pl-2 hover:underline', activeId === item.id ? 'font-medium' : '']">
47+
{{ item.text }}
48+
</a>
49+
</li>
50+
</ul>
51+
</div>
52+
</div>
53+
</template>

0 commit comments

Comments
 (0)