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
278 changes: 278 additions & 0 deletions nuxt/components/AlgoliaSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'

// Dimension 2 — restores the 11ty Algolia Autocomplete search box that the Nuxt
// migration dropped. The widget is the @algolia/autocomplete-js@1.6.1 widget
// (NOT @docsearch/js), section-scoped via the `scope` prop (mirrors the old
// <meta property="article:section">). It renders <div id="algolia-search"> so
// the already-shipped src/css/algolia-theme.css applies verbatim, and pulls the
// same pinned CDN scripts + base autocomplete theme the 11ty common-js.njk used.
const props = defineProps({
scope: { type: String, default: '' },
})

const config = useRuntimeConfig()
const appId = config.public.algoliaAppId
const apiKey = config.public.algoliaApiKey
const indexName = config.public.algoliaIndexName

// Env-gate: render/boot nothing without credentials so local/static builds stay
// clean (no network, no console noise).
const enabled = Boolean(appId && apiKey)

const containerEl = ref(null)
let instance = null

// Pinned CDN URLs — identical versions to the 11ty common-js.njk loader so the
// shipped algolia-theme.css / aa-* classes line up.
const ALGOLIASEARCH_SRC =
'https://cdn.jsdelivr.net/npm/algoliasearch@4.24.0/dist/algoliasearch-lite.umd.js'
const AUTOCOMPLETE_SRC =
'https://cdn.jsdelivr.net/npm/@algolia/autocomplete-js@1.6.1'
// Base autocomplete theme stylesheets the 11ty layout also loaded — without
// these the .aa-* widget renders unstyled (algolia-theme.css only overrides).
const THEME_CSS = [
'https://cdn.jsdelivr.net/npm/instantsearch.css@8.5.1/themes/reset-min.css',
'https://cdn.jsdelivr.net/npm/@algolia/autocomplete-theme-classic@1.6.1',
]

function loadStyle(href) {
if (document.querySelector(`link[href="${href}"]`)) return
const l = document.createElement('link')
l.rel = 'stylesheet'
l.href = href
document.head.appendChild(l)
}

function loadScript(src) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`)
if (existing) {
if (existing.dataset.loaded === 'true') return resolve()
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', reject)
return
}
const s = document.createElement('script')
s.src = src
s.async = true
s.addEventListener('load', () => {
s.dataset.loaded = 'true'
resolve()
})
s.addEventListener('error', reject)
document.head.appendChild(s)
})
}

const scopeTitles = {
ama: 'Ask Me Anything',
blog: 'Blog',
changelog: 'Changelog',
'customer-stories': 'Customer Stories',
docs: 'Docs',
ebooks: 'E-Books',
handbook: 'Handbook',
'node-red': 'Node-RED',
webinars: 'Webinars',
}

function initSearchBar() {
const searchScope = props.scope || ''
const { autocomplete, getAlgoliaResults } =
window['@algolia/autocomplete-js']
// algoliasearch-lite UMD exposes a global `algoliasearch`.
const searchClient = window.algoliasearch(appId, apiKey)

const placeholder = Object.prototype.hasOwnProperty.call(
scopeTitles,
searchScope,
)
? `Search in ${scopeTitles[searchScope]}...`
: 'Search...'

const initialHitsPerPage = 5
const hitsPerPageMap = {}
let initialQuery = ''

const createSource = (client, query, scope) => {
let totalHits = 0
if (!hitsPerPageMap[scope]) hitsPerPageMap[scope] = initialHitsPerPage

const filters = scope.length === 0 ? undefined : `category:${scope}`

return {
sourceId: scope,
getItems: () =>
getAlgoliaResults({
searchClient: client,
queries: [
{
indexName,
params: {
query,
hitsPerPage: hitsPerPageMap[scope],
attributesToSnippet: ['content:50'],
},
attributesToHighlight: '*',
filters,
},
],
transformResponse({ hits, results }) {
totalHits = results[0].nbHits
return hits
},
}),
templates: {
header({ html }) {
if (
!Object.prototype.hasOwnProperty.call(scopeTitles, scope)
) {
return null
}
return html`
<span class="aa-SourceHeaderTitle"
>In ${scopeTitles[scope]}</span
>
<div class="aa-SourceHeaderLine" />
`
},
item({ item, components, html }) {
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 &&
item.content.trim().length > 0
? components.Snippet({
hit: item,
attribute: 'content',
})
: components.Snippet({
hit: item,
attribute: 'description',
})}
</div>
</div>
</div>
</a>`
},
footer({ items, html }) {
if (items.length === 0 || items.length >= totalHits) {
return null
}
return html`<button
type="button"
data-scope="${scope}"
id="load-more"
class="aa-LoadMore load-more-btn"
>
Load more...
</button>`
},
},
}
}

instance = autocomplete({
debug: false,
container: containerEl.value,
placeholder,
getSources({ query }) {
if (query !== initialQuery) {
initialQuery = query
for (const key in hitsPerPageMap) {
hitsPerPageMap[key] = initialHitsPerPage
}
}
if (searchScope === 'docs') {
return [
createSource(searchClient, query, 'docs'),
createSource(searchClient, query, 'node-red'),
]
}
return [createSource(searchClient, query, searchScope)]
},
onStateChange() {
document.querySelectorAll('.aa-Panel a').forEach((el) => {
el.href = el.getAttribute('data-href')
})
document.querySelectorAll('.aa-Panel img').forEach((img) => {
img.src = img.getAttribute('data-src')
})
document.querySelectorAll('.load-more-btn').forEach((btn) => {
btn.onclick = (e) => {
e.preventDefault()
e.stopPropagation()
const scope = e.target.getAttribute('data-scope')
if (scope) {
hitsPerPageMap[scope] =
(hitsPerPageMap[scope] || initialHitsPerPage) +
initialHitsPerPage
}
instance && instance.refresh()
}
})
},
})
}

onMounted(async () => {
if (!enabled || !containerEl.value) return
try {
THEME_CSS.forEach(loadStyle)
await loadScript(ALGOLIASEARCH_SRC)
await loadScript(AUTOCOMPLETE_SRC)
if (containerEl.value) initSearchBar()
} catch {
// Network/CDN failure: leave the empty container; do not throw.
}
})

onBeforeUnmount(() => {
if (instance) {
try {
instance.destroy()
} catch {
/* ignore */
}
instance = null
}
})
</script>

<template>
<ClientOnly>
<div v-if="enabled" ref="containerEl" id="algolia-search" class="border rounded"></div>
</ClientOnly>
</template>
54 changes: 54 additions & 0 deletions nuxt/components/HandbookNavSubtree.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script setup>
// Recursive nav renderer for the grouped docs/handbook sidebar (Dimension 1).
// Mirrors NodeRedNavTree.vue: auto-expand the branch containing the current
// route, chevron toggle, consumes { title, url, children } items.
const props = defineProps({
items: { type: Array, default: () => [] },
current: { type: String, default: '' },
})

const norm = (p) => (p || '').replace(/\/+$/, '')

function containsCurrent(item) {
if (norm(item.url) === norm(props.current)) return true
return (item.children || []).some(containsCurrent)
}

const open = reactive({})
for (const item of props.items) {
if (item.children?.length && containsCurrent(item)) open[item.url] = true
}
const toggle = (url) => { open[url] = !open[url] }
const isActive = (item) => norm(item.url) === norm(props.current)
</script>

<template>
<ul class="space-y-0.5">
<li v-for="item in items" :key="item.url">
<div v-if="item.children?.length" class="flex items-center justify-between">
<NuxtLink
:to="item.url"
class="block py-0.5"
:class="isActive(item) ? 'text-blue-700 font-medium' : 'text-gray-600 hover:text-blue-700'"
>{{ item.title }}</NuxtLink>
<button
type="button"
class="px-1 text-gray-400 hover:text-gray-600"
:aria-expanded="open[item.url] ? 'true' : 'false'"
@click="toggle(item.url)"
>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open[item.url] }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" /></svg>
</button>
</div>
<NuxtLink
v-else
:to="item.url"
class="block py-0.5"
:class="isActive(item) ? 'text-blue-700 font-medium' : 'text-gray-600 hover:text-blue-700'"
>{{ item.title }}</NuxtLink>
<div v-if="item.children?.length" v-show="open[item.url]" class="pl-3 border-l border-gray-200 ml-1 mt-0.5">
<HandbookNavSubtree :items="item.children" :current="current" />
</div>
</li>
</ul>
</template>
33 changes: 33 additions & 0 deletions nuxt/components/HandbookNavTree.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup>
// Dimension 1 — renders the static grouped nav emitted at copy time
// (nuxt/docs.nav.json / nuxt/handbook.nav.json), reproducing the 11ty
// navGroup/navOrder sidebar: the section-root link, then group headings, each
// with its ordered children. Group headings only appear at the top level; the
// recursive subtree lives in HandbookNavSubtree.vue.
defineProps({
// { root: { title, url }, groups: [ { name, children: [navItem] } ] }
nav: { type: Object, default: () => ({ root: null, groups: [] }) },
current: { type: String, default: '' },
})

const norm = (p) => (p || '').replace(/\/+$/, '')
</script>

<template>
<div class="hb-sidebar-nav">
<NuxtLink
v-if="nav?.root"
:to="nav.root.url"
class="block py-0.5 font-medium"
:class="norm(nav.root.url) === norm(current) ? 'text-blue-700' : 'text-gray-700 hover:text-blue-700'"
>{{ nav.root.title }}</NuxtLink>

<template v-for="group in nav?.groups || []" :key="group.name">
<p
v-if="group.name !== 'Other'"
class="handbook-nav-group mt-3 mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
>{{ group.name }}</p>
<HandbookNavSubtree :items="group.children" :current="current" />
</template>
</div>
</template>
7 changes: 7 additions & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export default defineContentConfig({
formTitle: z.string().optional(),
formSubtitle: z.string().optional(),
})
}),
// Product docs are generated from src/docs (itself synced from the
// external FlowFuse repo by scripts/copy_docs.js) into nuxt/content/docs
// by scripts/copy_docs_nuxt.js. This is the /docs migration to Nuxt.
docs: defineCollection({
type: 'page',
source: 'docs/**/*.md'
})
}
})
Loading
Loading