Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -531,12 +531,13 @@ module.exports = function(eleventyConfig) {
return new URL(url, site.baseURL).href;
})


eleventyConfig.addFilter("handbookBreadcrumbs", (url) => {
let parts = url.split("/").filter(e => e !== '');
if (parts[parts.length-1] === "index") {
parts.pop();
}

let path = "";
return "/"+parts.map(p => {
let url = `${path}/${p}`;
Expand All @@ -546,24 +547,16 @@ module.exports = function(eleventyConfig) {
});

eleventyConfig.addFilter("rewriteHandbookLinks", (str, page) => {
// If page.inputPath looks like: ./src/handbook/abc/def.md
// then the url of the page will be `/handbook/abc/def/`
// links of the form `./` or `[^/]` must be prepended with `../`
// to ensure it links to the right place

const isIndexPage = /(README.md|index.md)$/i.test(page.inputPath)

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

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

createNav('handbook')
createNav('docs')

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

// not req'd to have handbook in Website build, so this may be empty
if (nav[tag]) {
for (child of nav[tag].children) {
if (child.group) {
Expand All @@ -1205,7 +1196,7 @@ module.exports = function(eleventyConfig) {
}
groups[group].children.push(child)
} else {
// capture & flag top-level handbook docs, that haven't had a group assigned
// capture & flag top-level docs that haven't had a group assigned
groups['Other'].children.push(child)
}
}
Expand Down
5 changes: 3 additions & 2 deletions nuxt/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
.output/
dist/

# Generated by 11ty during unified build
public/
# Generated by 11ty during unified build (handbook images are tracked separately)
/public/*
!/public/handbook/

# Dependencies (hoisted to workspace root)
node_modules/
Expand Down
108 changes: 108 additions & 0 deletions nuxt/components/HandbookLeftNav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { buildHandbookNav, type NavNode, type NavGroup } from '~/composables/useHandbookNav'

const route = useRoute()

const { data: pages } = await useAsyncData('handbook-nav', () =>
queryCollection('handbook').all()
)

const navGroups = computed<NavGroup[]>(() => {
if (!pages.value) return []
return buildHandbookNav(pages.value as any[])
})

function normPath(p: string) {
return p.replace(/\/$/, '') || '/'
}

function isActive(nodePath: string): boolean {
return normPath(route.path) === normPath(nodePath)
}

function hasActiveDescendant(node: NavNode): boolean {
if (isActive(node.path)) return true
return node.children.some(hasActiveDescendant)
}

const manuallyExpanded = ref<Set<string>>(new Set())

function toggle(path: string) {
const next = new Set(manuallyExpanded.value)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
manuallyExpanded.value = next
}

function isOpen(node: NavNode): boolean {
return manuallyExpanded.value.has(node.path) || hasActiveDescendant(node)
}

function ulStyle(node: NavNode) {
return isOpen(node) ? { maxHeight: 'initial' } : {}
}
</script>

<template>
<div class="border-r lg:pt-2 text-sm" data-handbook>
<ul class="handbook-nav" data-el="navigation">
<li :class="{ active: isActive('/handbook') }">
<NuxtLink href="/handbook">Handbook</NuxtLink>
</li>

<template v-for="group in navGroups" :key="group.name">
<li class="handbook-nav-group">{{ group.name }}</li>

<template v-for="entry in group.children" :key="entry.path">
<li :class="{ active: isActive(entry.path), open: isOpen(entry) && entry.children.length > 0 }">
<NuxtLink :href="entry.path">{{ entry.name }}</NuxtLink>
<button v-if="entry.children.length"
@click="toggle(entry.path)"
:aria-expanded="isOpen(entry).toString()"
:aria-label="`Toggle ${entry.name} submenu`">
<span class="ff-icon icon-expand">
<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>
</span>
<span class="ff-icon icon-minimise">
<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>
</span>
</button>
</li>

<li v-if="entry.children.length" class="contents">
<ul class="handbook-nav-nested" :style="ulStyle(entry)">
<template v-for="child in entry.children" :key="child.path">
<li :class="{ active: isActive(child.path), open: isOpen(child) && child.children.length > 0 }">
<NuxtLink :href="child.path">{{ child.name }}</NuxtLink>
<button v-if="child.children.length"
@click="toggle(child.path)"
:aria-expanded="isOpen(child).toString()"
:aria-label="`Toggle ${child.name} submenu`">
<span class="ff-icon icon-expand">
<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>
</span>
<span class="ff-icon icon-minimise">
<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>
</span>
</button>
</li>

<li v-if="child.children.length" class="contents">
<ul class="handbook-nav-nested-2" :style="ulStyle(child)">
<li v-for="grandchild in child.children" :key="grandchild.path"
:class="{ active: isActive(grandchild.path) }">
<NuxtLink :href="grandchild.path">{{ grandchild.name }}</NuxtLink>
</li>
</ul>
</li>
</template>
</ul>
</li>
</template>
</template>
</ul>
</div>
</template>
120 changes: 120 additions & 0 deletions nuxt/components/HandbookSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script setup lang="ts">
const searchContainer = ref<HTMLElement>()

onMounted(async () => {
const loadScript = (src: string, integrity?: string): Promise<void> =>
new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return }
const script = document.createElement('script')
script.src = src
if (integrity) { script.integrity = integrity; script.crossOrigin = 'anonymous' }
script.onload = () => resolve()
script.onerror = reject
document.head.appendChild(script)
})

const loadLink = (href: string, integrity?: string) => {
if (document.querySelector(`link[href="${href}"]`)) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
if (integrity) { link.integrity = integrity; link.crossOrigin = 'anonymous' }
document.head.appendChild(link)
}

loadLink('https://cdn.jsdelivr.net/npm/instantsearch.css@8.5.1/themes/reset-min.css', 'sha256-KvFgFCzgqSErAPu6y9gz/AhZAvzK48VJASu3DpNLCEQ=')
loadLink('https://cdn.jsdelivr.net/npm/@algolia/autocomplete-theme-classic@1.6.1')

await loadScript(
'https://cdn.jsdelivr.net/npm/algoliasearch@4.24.0/dist/algoliasearch-lite.umd.js',
'sha256-b2n6oSgG4C1stMT/yc/ChGszs9EY/Mhs6oltEjQbFCQ='
)
await loadScript('https://cdn.jsdelivr.net/npm/@algolia/autocomplete-js@1.6.1')

const win = window as any
const { autocomplete, getAlgoliaResults } = win['@algolia/autocomplete-js']
const searchClient = win.algoliasearch('ISKYOHIT7D', '68d4032f487d66423c37e6483e067272')

const initialHitsPerPage = 5
let hitsPerPage = initialHitsPerPage
let prevQuery = ''
let totalHits = 0

autocomplete({
container: searchContainer.value!,
placeholder: 'Search in Handbook...',
getSources ({ query }: { query: string }) {
if (query !== prevQuery) {
prevQuery = query
hitsPerPage = initialHitsPerPage
}
return [{
sourceId: 'handbook',
getItems: () => getAlgoliaResults({
searchClient,
queries: [{
indexName: 'prod_netlify',
params: { query, hitsPerPage, attributesToSnippet: ['content:50'] },
attributesToHighlight: '*',
filters: 'category:handbook'
}],
transformResponse ({ hits, results }: any) {
totalHits = results[0].nbHits
return hits
}
}),
templates: {
item ({ item, components, html }: any) {
return html`
<a href="#" data-href="${item.url}" class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemIcon aa-ItemIcon--alignTop">
<img src="#" data-src="${item.image}" alt="${item.name}" width="40" height="40" />
</div>
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">
${components.Highlight({ hit: item, attribute: ['hierarchy', 'lvl0'] })}
</div>
<div class="aa-ItemContentSubTitle ${item.type === 'lvl0' ? 'hidden' : ''}">
${components.Highlight({ hit: item, attribute: ['hierarchy', item.type] })}
</div>
<div class="aa-ItemContentDescription">
${item.content?.trim().length > 0
? components.Snippet({ hit: item, attribute: 'content' })
: components.Snippet({ hit: item, attribute: 'description' })}
</div>
</div>
</div>
</a>`
},
footer ({ items, html }: any) {
if (!items.length || items.length >= totalHits) return null
return html`<button type="button" class="aa-LoadMore load-more-btn">Load more...</button>`
}
}
}]
},
onStateChange ({ refresh }: any) {
document.querySelectorAll('.aa-Panel a').forEach((el: any) => {
el.href = el.getAttribute('data-href')
})
document.querySelectorAll('.aa-Panel img').forEach((img: any) => {
img.src = img.getAttribute('data-src')
})
const btn = document.querySelector<HTMLElement>('.load-more-btn')
if (btn) {
btn.onclick = (e) => {
e.preventDefault()
e.stopPropagation()
hitsPerPage += initialHitsPerPage
refresh()
}
}
}
})
})
</script>

<template>
<div ref="searchContainer" id="algolia-search" class="border rounded"></div>
</template>
53 changes: 53 additions & 0 deletions nuxt/components/HandbookToc.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
interface TocItem {
id: string
text: string
level: number
}

const toc = ref<TocItem[]>([])
const activeId = ref<string>('')

onMounted(() => {
const content = document.querySelector('.handbook-content')
if (!content) return

const headings = content.querySelectorAll('h2, h3, h4')
toc.value = Array.from(headings)
.map(h => ({
id: h.id,
text: h.textContent?.trim() || '',
level: parseInt(h.tagName[1])
}))
.filter(h => h.id && h.text)

if (!toc.value.length) return

const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter(e => e.isIntersecting)
if (visible.length) activeId.value = visible[0].target.id
},
{ rootMargin: '-80px 0px -60% 0px', threshold: 0 }
)
headings.forEach(h => { if (h.id) observer.observe(h) })
onUnmounted(() => observer.disconnect())
})
</script>

<template>
<div v-if="toc.length" class="mb-6">
<h3 class="mb-3">Table of Contents</h3>
<div class="toc-wrapper text-sm">
<ul class="list-none p-0 m-0">
<li v-for="item in toc" :key="item.id"
:class="['mb-4', item.level === 2 ? 'pl-0' : item.level === 3 ? 'pl-4' : 'pl-8']">
<a :href="`#${item.id}`"
:class="['block py-[0.2rem] text-blue-600 no-underline transition-all duration-200 hover:pl-2 hover:underline', activeId === item.id ? 'font-medium' : '']">
{{ item.text }}
</a>
</li>
</ul>
</div>
</div>
</template>
Loading
Loading