diff --git a/nuxt/assets/css/style.css b/nuxt/assets/css/style.css index 79076bf728..03276abfb0 100644 --- a/nuxt/assets/css/style.css +++ b/nuxt/assets/css/style.css @@ -478,8 +478,13 @@ dl.message-properties > dd { @apply flex items-start justify-between gap-4 rounded-lg p-4 text-left; } +/* The site-wide `code` rule draws a bordered, padded box, which inside the dark block reads + as a second box. Pages that sit in .nohero reset it already; docs pages do not. */ .ff-command__text { @apply text-sm text-gray-100 whitespace-pre-wrap break-all; + border: 0; + border-radius: 0; + padding: 0; } .ff-command__copy { @@ -999,8 +1004,11 @@ h4:hover .header-anchor::before { font-weight: 400; } + /* The grey line under the header is a shadow, not a border, so it adds nothing to the + height that --ff-header-height below has to match. */ .ff-website .ff-header { @apply bg-white w-full px-6 py-4 sm:py-6 md:py-0 top-0 z-50 sticky; + box-shadow: 0 1px 0 var(--color-gray-200); } /* The header pins itself to the top of the viewport, so anything else that wants to diff --git a/nuxt/components/AlgoliaSearch.vue b/nuxt/components/AlgoliaSearch.vue index 7cfab36266..53198724c4 100644 --- a/nuxt/components/AlgoliaSearch.vue +++ b/nuxt/components/AlgoliaSearch.vue @@ -7,14 +7,58 @@ const props = withDefaults(defineProps<{ indexFilter?: string placeholder?: string sourceId?: string + /** Render a search button that opens the results in a dialog, at every screen width. */ + detached?: boolean + /** Open it with Cmd+K / Ctrl+K and from a `ff-docs-search:open` window event. Needs `detached`. */ + shortcut?: boolean + /** `lg` is the taller search field of a landing page's hero. Needs `detached`. */ + size?: 'md' | 'lg' }>(), { placeholder: 'Search...', sourceId: 'content', + detached: false, + shortcut: false, + size: 'md', +}) + +const isMac = ref(true) + +// Autocomplete arrives from a CDN after the page has rendered. Until it has mounted, a +// detached search shows a stand-in button of the same size, so the field is there from the +// first paint instead of popping in; a click or Cmd+K before then opens the dialog as soon +// as it can. +const ready = ref(false) +let openWhenReady = false + +function openDialog () { + if (!ready.value) { + openWhenReady = true + return + } + searchContainer.value?.querySelector('.aa-DetachedSearchButton')?.click() +} + +function onKeydown (e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { + e.preventDefault() + openDialog() + } +} + +onBeforeUnmount(() => { + window.removeEventListener('keydown', onKeydown) + window.removeEventListener('ff-docs-search:open', openDialog) }) const searchContainer = ref() onMounted(async () => { + if (props.detached && props.shortcut) { + isMac.value = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) + window.addEventListener('keydown', onKeydown) + window.addEventListener('ff-docs-search:open', openDialog) + } + const loadScript = (src: string, integrity?: string): Promise => new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve(); return } @@ -56,6 +100,16 @@ onMounted(async () => { autocomplete({ container: searchContainer.value!, placeholder: props.placeholder, + // An empty media query matches every width, so the input is always a button that + // opens the dialog: centred on wide screens, full screen on narrow ones (the theme's + // --aa-detached-modal-media-query decides which). + // The dialog carries its own class, so its styles below leave the other searches' + // phone dialogs alone. Its close button reads "Close" to a screen reader and shows an X. + ...(props.detached ? { + detachedMediaQuery: '', + classNames: { detachedContainer: 'ff-search-dialog' }, + translations: { detachedCancelButtonText: 'Close' }, + } : {}), getSources ({ query }: { query: string }) { if (query !== prevQuery) { prevQuery = query @@ -81,9 +135,9 @@ onMounted(async () => { return html`
-
+ ${item.image ? html`
${item.name} -
+
` : ''}
${components.Highlight({ hit: item, attribute: ['hierarchy', 'lvl0'] })} @@ -125,9 +179,164 @@ onMounted(async () => { } } }) + + ready.value = true + if (openWhenReady) { + openWhenReady = false + // Autocomplete renders its button a frame or two after it is set up. + for (let i = 0; i < 30 && !searchContainer.value?.querySelector('.aa-DetachedSearchButton'); i++) { + await new Promise(resolve => requestAnimationFrame(resolve)) + } + openDialog() + } }) + + + + diff --git a/nuxt/components/AppHeader.vue b/nuxt/components/AppHeader.vue index d9b297485f..bf8fac8ce1 100644 --- a/nuxt/components/AppHeader.vue +++ b/nuxt/components/AppHeader.vue @@ -11,6 +11,9 @@ const hl = (key) => { const resolveHref = useResolveHref() +const route = useRoute() +const isDocs = computed(() => route.path === '/docs' || route.path.startsWith('/docs/')) + onMounted(() => { const navToggle = document.getElementById('nav-toggle') if (navToggle) { @@ -119,17 +122,20 @@ onMounted(() => { + + diff --git a/nuxt/components/DocsHeaderActions.vue b/nuxt/components/DocsHeaderActions.vue new file mode 100644 index 0000000000..bfb6875ce3 --- /dev/null +++ b/nuxt/components/DocsHeaderActions.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/nuxt/components/DocsLeftNav.vue b/nuxt/components/DocsLeftNav.vue index 111f65f44f..2655aafc9c 100644 --- a/nuxt/components/DocsLeftNav.vue +++ b/nuxt/components/DocsLeftNav.vue @@ -7,8 +7,9 @@ const route = useRoute() const { data: navGroups } = await useDocsNavTree() +// No "Documentation" entry on top: the search button sits there, and the breadcrumb and +// the site header both lead back to /docs. const items = computed((): NavigationMenuItem[] => [ - { label: 'Documentation', to: '/docs/' }, ...(navGroups.value ?? []).flatMap(group => [ { type: 'label', label: group.name } satisfies NavigationMenuItem, ...buildNavigationMenuItems(group.children, route.path), @@ -17,5 +18,43 @@ const items = computed((): NavigationMenuItem[] => [ + + diff --git a/nuxt/components/SidebarNav.vue b/nuxt/components/SidebarNav.vue index 4dd32dfe6c..aa7621b46b 100644 --- a/nuxt/components/SidebarNav.vue +++ b/nuxt/components/SidebarNav.vue @@ -68,3 +68,19 @@ watch(() => route.path, () => { open.value = false })
+ + diff --git a/nuxt/components/content/DocsChoice.vue b/nuxt/components/content/DocsChoice.vue new file mode 100644 index 0000000000..fd98faea09 --- /dev/null +++ b/nuxt/components/content/DocsChoice.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/nuxt/components/content/DocsChoices.vue b/nuxt/components/content/DocsChoices.vue new file mode 100644 index 0000000000..66178b665c --- /dev/null +++ b/nuxt/components/content/DocsChoices.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/nuxt/components/content/DocsHero.vue b/nuxt/components/content/DocsHero.vue new file mode 100644 index 0000000000..d37596bbb4 --- /dev/null +++ b/nuxt/components/content/DocsHero.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/nuxt/components/content/DocsSection.vue b/nuxt/components/content/DocsSection.vue new file mode 100644 index 0000000000..8d034fac38 --- /dev/null +++ b/nuxt/components/content/DocsSection.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/nuxt/components/content/DocsTiles.vue b/nuxt/components/content/DocsTiles.vue new file mode 100644 index 0000000000..69fe4f70ed --- /dev/null +++ b/nuxt/components/content/DocsTiles.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/nuxt/content-guides/README.md b/nuxt/content-guides/README.md new file mode 100644 index 0000000000..699909d3d1 --- /dev/null +++ b/nuxt/content-guides/README.md @@ -0,0 +1,249 @@ +--- +navTitle: Documentation +metaTitle: Documentation +landing: true +meta: + description: FlowFuse documentation. Get started on FlowFuse Cloud, on your own hardware or with your AI agent, then build, deploy and run industrial applications. +--- + +::docs-hero +# Start building with FlowFuse + +Build industrial applications, then deploy and run them from cloud to edge. + +#actions +- [:icon{name="i-lucide-play"} Getting started](/docs/user/introduction/) +- [:icon{name="i-lucide-book-open"} API reference](/docs/api/) + +#aside +:::docs-choices{title="Where do you want to start?"} +::::docs-choice{label="FlowFuse Cloud" icon="i-lucide-cloud"} +FlowFuse runs the platform for you. Sign up, then build your applications and deploy them from your browser, with nothing to install. The quickest way to try FlowFuse. + +- [Get started on FlowFuse Cloud](/docs/user/introduction/) +- [About FlowFuse Cloud](/docs/cloud/introduction/) +:::: + +::::docs-choice{label="Your own edge hardware, with the Device Agent" icon="i-lucide-cpu"} +Run your applications on your own machines at the edge, from an industrial PC to a Raspberry Pi, and manage them all from FlowFuse. The Device Agent connects each machine as a remote instance, to FlowFuse Cloud or to a self-hosted platform. + +- [Connect your first machine](/docs/device-agent/quickstart/) +- [About the Device Agent](/docs/device-agent/introduction/) +:::: + +::::docs-choice{label="FlowFuse Self-Hosted" icon="i-lucide-server"} +Run the whole platform, and the applications on it, on your own infrastructure: on premises or in your own cloud, with Docker or Kubernetes. Talk to our sales team first: they help you choose the right licence and plan the installation with you. + +- [Contact sales](/contact-us/) +- [About self-hosting](/docs/install/introduction/) +:::: + +::::docs-choice{label="Your AI agent" icon="i-lucide-bot"} +Connect Claude, ChatGPT, Copilot or another agent to FlowFuse over MCP. It can manage your teams and applications, and build and edit their flows, within the access you grant it. + +- [Connect your agent](/docs/user/expert/third-party-agents/) +- [About FlowFuse MCP](/docs/user/mcp/) +:::: + +::::docs-choice{label="Not sure yet? Talk to us" icon="i-lucide-messages-square" open} +Tell us about the applications you want to build, and the sites and machines they run on. We work out with you which setup fits, then help you get it running. To understand the platform first, the Application Guide explains how FlowFuse applications are shaped. + +- [Book a call](/book-demo/) +- [About FlowFuse applications](/docs/application-guide/) +:::: +::: +:: + +::docs-section{eyebrow="Platform" title="Choose how you run FlowFuse"} +Where FlowFuse runs is the one choice that changes your first steps. After that, the documentation is the same. + +:::card-group +::::card{title="FlowFuse Cloud" icon="i-lucide-cloud"} +Hosted by FlowFuse. Nothing to install: sign up and build in the browser. + +- [:icon{name="i-lucide-info"} About FlowFuse Cloud](/docs/cloud/introduction/) +- [:icon{name="i-lucide-play"} Get started on FlowFuse Cloud](/docs/user/introduction/) +- [:icon{name="i-lucide-receipt"} Billing](/docs/cloud/billing/) +:::: + +::::card{title="Self-hosted" icon="i-lucide-server"} +The platform on your own infrastructure, with Docker, Kubernetes or a single machine. Start by talking to our sales team. + +- [:icon{name="i-lucide-messages-square"} Contact sales](/contact-us/) +- [:icon{name="i-lucide-signpost"} Choosing how to install](/docs/install/introduction/) +- [:icon{name="i-lucide-container"} Docker](/docs/install/docker/) +- [:icon{name="i-lucide-ship-wheel"} Kubernetes](/docs/install/kubernetes/) +- [:icon{name="i-lucide-settings"} Configuring FlowFuse](/docs/install/configuration/) +:::: +::: + +:::docs-tiles +- [:icon{name="i-lucide-cpu"} Remote instances with the Device Agent](/docs/device-agent/) +- [:icon{name="i-lucide-arrow-right-left"} Moving from plain Node-RED](/docs/migration/) +- [:icon{name="i-lucide-hard-drive"} Hardware guides](/docs/hardware/introduction/) +::: +:: + +::docs-section{eyebrow="Documentation" title="Find what you need" cols="4"} +Four kinds of page, for four different moments. + +:::card-group +::::card{title="Learn by building" icon="i-lucide-graduation-cap"} +Lessons that take you from nothing to something working. + +- [Build a weather dashboard](/blog/2025/12/getting-weather-data-in-node-red/) +- [Build a machine downtime tracker](/blog/2026/07/build-downtime-logger/) +- [Build a defect tracking dashboard](/blog/2026/07/defect-and-quality-monitoring/) +:::: + +::::card{title="Get something done" icon="i-lucide-list-checks"} +Task guides, for when you know what you want. + +- [Working in FlowFuse](/docs/user/) +- [Working in Node-RED](/docs/node-red/) +- [MCP in your flows](/docs/flowfuse-nodes/mcp/) +- [Administering FlowFuse](/docs/admin/) +:::: + +::::card{title="Look something up" icon="i-lucide-book-open"} +Details you come back for rather than read once. + +- [Instance states](/docs/user/instance-states/) +- [FlowFuse nodes](/docs/flowfuse-nodes/) +- [Node-RED core nodes](/docs/node-red/core-nodes/) +- [API](/docs/api/) +:::: + +::::card{title="Understand FlowFuse" icon="i-lucide-lightbulb"} +What FlowFuse is, and how to shape an application on it. + +- [FlowFuse concepts](/docs/user/concepts/) +- [Application Guide](/docs/application-guide/) +- [Node-RED Guide](/docs/node-red-guide/) +:::: +::: +:: + +::docs-section{eyebrow="Journey" title="From first flow to production"} +Follow the lifecycle, or jump to what you need. + +:::steps{level="3"} +### Get started +Your first application and its first flow, and the people you build it with. + +::::docs-tiles +- [:icon{name="i-lucide-play"} Getting started](/docs/user/introduction/) +- [:icon{name="i-lucide-layout-dashboard"} Your first dashboard](/blog/2025/12/getting-weather-data-in-node-red/) +- [:icon{name="i-lucide-shapes"} FlowFuse concepts](/docs/user/concepts/) +- [:icon{name="i-lucide-users"} Teams](/docs/user/team/) +- [:icon{name="i-lucide-arrow-right-left"} Move from Node-RED](/docs/migration/) +:::: + +### Build +The flows, dashboards and data your application runs on. + +::::docs-tiles +- [:icon{name="i-lucide-gauge"} Dashboards](/docs/user/dashboards/) +- [:icon{name="i-lucide-sparkles"} FlowFuse Expert](/docs/user/expert/) +- [:icon{name="i-lucide-bot"} Connect your own agent](/docs/user/expert/third-party-agents/) +- [:icon{name="i-lucide-table"} FlowFuse Tables](/docs/user/ff-tables/) +- [:icon{name="i-lucide-radio-tower"} Team Broker](/docs/user/teambroker/) +- [:icon{name="i-lucide-variable"} Environment variables](/docs/user/envvar/) +- [:icon{name="i-lucide-library"} Shared team library](/docs/user/shared-library/) +:::: + +### Deploy +Promote tested work to production, in the cloud and at the edge. + +::::docs-tiles +- [:icon{name="i-lucide-history"} Snapshots](/docs/user/snapshots/) +- [:icon{name="i-lucide-git-branch"} DevOps pipelines](/docs/user/devops-pipelines/) +- [:icon{name="i-lucide-cpu"} Deploy to remote instances](/docs/device-agent/deploy/) +- [:icon{name="i-lucide-boxes"} Remote instance groups](/docs/user/device-groups/) +- [:icon{name="i-lucide-copy"} High availability](/docs/user/high-availability/) +- [:icon{name="i-lucide-globe"} Custom hostnames](/docs/user/custom-hostnames/) +:::: + +### Operate +Access, visibility and cost, once it runs. + +::::docs-tiles +- [:icon{name="i-lucide-shield"} Roles and permissions](/docs/user/role-based-access-control/) +- [:icon{name="i-lucide-key-round"} Single sign-on](/docs/admin/sso/) +- [:icon{name="i-lucide-scroll-text"} Logging](/docs/user/logs/) +- [:icon{name="i-lucide-activity"} Monitoring](/docs/admin/monitoring/) +- [:icon{name="i-lucide-settings"} Instance settings](/docs/user/instance-settings/) +- [:icon{name="i-lucide-receipt"} Billing](/docs/cloud/billing/) +:::: +::: +:: + +::docs-section{eyebrow="Resources" title="Keep learning" cols="3"} +:::card-group +::::card{icon="i-lucide-newspaper"} +[Blog :icon{name="i-lucide-arrow-up-right"}](/blog/) + +Articles, how-tos and product news. +:::: + +::::card{icon="i-lucide-layout-template"} +[Blueprint library :icon{name="i-lucide-arrow-up-right"}](/blueprints/) + +Ready-made flows to start an application from. +:::: + +::::card{icon="i-lucide-star"} +[What's new :icon{name="i-lucide-arrow-up-right"}](/changelog/) + +Every release, feature by feature. +:::: + +::::card{icon="i-lucide-presentation"} +[Webinars :icon{name="i-lucide-arrow-up-right"}](/webinars/) + +Live and recorded sessions with the team. +:::: + +::::card{icon="i-lucide-plug"} +[Integrations :icon{name="i-lucide-arrow-up-right"}](/integrations/) + +Certified nodes for the systems you connect to. +:::: + +::::card{icon="i-lucide-youtube"} +[YouTube :icon{name="i-lucide-arrow-up-right"}](https://www.youtube.com/channel/UCbBzP8NZbv3WDtlt4UouA-g) + +Walkthroughs and recorded talks. +:::: +::: +:: + +::docs-section{eyebrow="Help" title="Ask a question" cols="2"} +Put your question to our support team, the community, or the troubleshooting guides. + +:::card-group +::::card{icon="i-lucide-headset"} +[Talk to our support team :icon{name="i-lucide-arrow-up-right"}](/support/) + +Search the Help Center or submit a ticket. For Cloud and Enterprise customers. +:::: + +::::card{icon="i-lucide-bug"} +[Debugging Node-RED](/docs/debugging/) + +Find out why a flow or an instance is not behaving. +:::: + +::::card{icon="i-lucide-messages-square"} +[Community forum](/docs/community-support/) + +Ask the FlowFuse and Node-RED community. +:::: + +::::card{icon="i-lucide-activity"} +[Platform status :icon{name="i-lucide-arrow-up-right"}](https://status.flowfuse.com/) + +FlowFuse Cloud availability. +:::: +::: +:: diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index d4475e9036..28afc5488c 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -57,6 +57,9 @@ export default defineContentConfig({ structuredData: z.object({ description: z.string().optional(), }).optional(), + // A contents page that lays itself out (the docs home): no sidebar, no + // table of contents, the full page width. Read by pages/docs/[...slug].vue. + landing: z.boolean().optional(), // No `sitemap` schema field here on purpose - @nuxtjs/sitemap's own // @nuxt/content integration only accepts *plain* onUrl/filter functions // (it re-splices their source text into a generated file with no closure diff --git a/nuxt/lib/docs-sync.mjs b/nuxt/lib/docs-sync.mjs index 766eceb612..aa3c0e8e87 100644 --- a/nuxt/lib/docs-sync.mjs +++ b/nuxt/lib/docs-sync.mjs @@ -96,6 +96,15 @@ function gitOutput (cwd, args) { } } +/** + * The README at the root of the flowfuse docs tree is that repository's own readme for its + * docs folder, not the portal's home page. /docs spans more than the flowfuse docs (the + * guides and the Node-RED library come from this repo), so its home is this repo's + * nuxt/content-guides/README.md, which guides-sync overlays as index.md. This file is + * therefore never copied, and syncing it neither writes nor removes the home page. + */ +export const FLOWFUSE_DOCS_README = 'README.md' + /** * Where one source file lands: markdown becomes a page under content/, a README becomes * its section index, and anything else is an asset served from public/. @@ -141,6 +150,7 @@ function copyDocsDir ({ docsDir, sourceRoot, contentDocsDir, publicDocsDir, vers if (entry.name.startsWith('.')) continue const relPath = join(relDir, entry.name) + if (relPath === FLOWFUSE_DOCS_README) continue const args = { docsDir, sourceRoot, contentDocsDir, publicDocsDir, version } if (entry.isDirectory()) { @@ -172,6 +182,8 @@ export function syncDocsPath ({ docsDir, nuxtRoot, relPath }) { const contentDocsDir = join(nuxtRoot, 'content', 'docs') const publicDocsDir = join(nuxtRoot, 'public', 'docs') + if (relPath === FLOWFUSE_DOCS_README) return + if (!existsSync(join(docsDir, relPath))) { rmSync(destinationFor(relPath, contentDocsDir, publicDocsDir), { force: true }) return diff --git a/nuxt/lib/docs-sync.test.mjs b/nuxt/lib/docs-sync.test.mjs index 014c7692bb..072c6f9ea0 100644 --- a/nuxt/lib/docs-sync.test.mjs +++ b/nuxt/lib/docs-sync.test.mjs @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' -import { resolveSource, syncDocsPath } from './docs-sync.mjs' +import { resolveSource, syncDocs, syncDocsPath } from './docs-sync.mjs' const repoRoot = '/repo/website' @@ -100,6 +100,40 @@ test('a README becomes the index page of its section', (t) => { assert.ok(!existsSync(fx.content('cloud', 'README.md'))) }) +// The portal home is this repo's page (nuxt/content-guides/README.md); the README at the +// root of the flowfuse docs tree is only that repo's own readme. +test('the root README of the flowfuse docs is not written as the portal home', (t) => { + const fx = fixture(t) + writeFileSync(join(fx.docsDir, 'README.md'), '# FlowFuse docs\n') + + syncDocsPath({ docsDir: fx.docsDir, nuxtRoot: fx.nuxtRoot, relPath: 'README.md' }) + + assert.ok(!existsSync(fx.content('index.md'))) +}) + +test('syncing the root README leaves the portal home in place', (t) => { + const fx = fixture(t) + writeFileSync(fx.content('index.md'), 'the website home\n') + + syncDocsPath({ docsDir: fx.docsDir, nuxtRoot: fx.nuxtRoot, relPath: 'README.md' }) + + assert.equal(readFileSync(fx.content('index.md'), 'utf8'), 'the website home\n') +}) + +test('a full sync copies every page but the root README', async (t) => { + const fx = fixture(t) + writeFileSync(join(fx.docsDir, 'README.md'), '# FlowFuse docs\n') + writeFileSync(join(fx.docsDir, 'cloud', 'README.md'), '# Cloud\n') + writeFileSync(join(fx.docsDir, 'cloud', 'billing.md'), '# Billing\n') + const silent = { info () {}, warn () {} } + + await syncDocs({ repoRoot: join(fx.nuxtRoot, '..'), nuxtRoot: fx.nuxtRoot, env: { FLOWFUSE_DOCS_LOCAL: fx.docsDir }, logger: silent }) + + assert.ok(!existsSync(fx.content('index.md'))) + assert.ok(existsSync(fx.content('cloud', 'index.md'))) + assert.ok(existsSync(fx.content('cloud', 'billing.md'))) +}) + test('a non-markdown file is copied to the public tree unchanged', (t) => { const fx = fixture(t) writeFileSync(join(fx.docsDir, 'cloud', 'diagram.svg'), '') diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 635cbb20f7..518ea3abaf 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -29,6 +29,10 @@ if (!page.value) { // Empty on most docs pages: only the ones a catalog feature names as its docsLink get badges. const plans = useDocsPlans(contentPath) +// A contents page such as the docs home lays itself out from its own components, so it +// gets the full width: no sidebar, no table of contents, no previous and next cards. +const isLanding = computed(() => (page.value as { landing?: boolean } | null)?.landing === true) + // /docs is assembled from two repos, so "Edit this page" has to point at whichever one // owns the page. Guides overlaid from this repo carry a ready-made `editUrl` (stamped by // nuxt/lib/guides-sync.mjs); everything else came from FlowFuse/flowfuse and is addressed @@ -108,7 +112,18 @@ const surround = computed(() => {