diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3769d27852..20219dadb2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -206,7 +206,7 @@ reference costs a page its whole Node help section with nothing failing, which i **URL:** `/docs/{section}/{slug}/` **Rendered by:** Nuxt — `nuxt/pages/docs/[...slug].vue` + `DocsLeftNav` component -**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build). Both sources are copied into it: `nuxt/lib/docs-sync.mjs` brings in the flowfuse tree and `nuxt/lib/guides-sync.mjs` overlays `nuxt/content-guides/` on top (stamping each guide with an `editUrl`), so `@nuxt/content` sees one `docs` collection. A guide edit therefore only reaches a running dev server once that overlay re-runs: `npm run dev:docs` is the watcher that does it, and without it an edit under `nuxt/content-guides/` shows up on the page only after a restart. +**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build) holds only the flowfuse tree, materialized by `nuxt/lib/docs-sync.mjs`. The guides are **not** copied into it: they are a second source of the `docs` collection, read straight out of `nuxt/content-guides/` (see `nuxt/content.config.ts`), so editing one shows up without a re-sync. `nuxt/lib/guides-sync.mjs` copies only their non-markdown assets, into `nuxt/public/docs/`, and fails the build on a path collision between the two sources. **Local assets:** `nuxt/public/docs/` (images, etc.) A page's browser title is `metaTitle || navTitle || title` (`nuxt/lib/docs-page-title.mjs`). 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/application-guide/app-delivery-methods/README.md b/nuxt/content-guides/application-guide/app-delivery-methods/index.md similarity index 100% rename from nuxt/content-guides/application-guide/app-delivery-methods/README.md rename to nuxt/content-guides/application-guide/app-delivery-methods/index.md diff --git a/nuxt/content-guides/application-guide/architectures/README.md b/nuxt/content-guides/application-guide/architectures/index.md similarity index 100% rename from nuxt/content-guides/application-guide/architectures/README.md rename to nuxt/content-guides/application-guide/architectures/index.md diff --git a/nuxt/content-guides/application-guide/README.md b/nuxt/content-guides/application-guide/index.md similarity index 100% rename from nuxt/content-guides/application-guide/README.md rename to nuxt/content-guides/application-guide/index.md diff --git a/nuxt/content-guides/application-guide/worked-examples/README.md b/nuxt/content-guides/application-guide/worked-examples/index.md similarity index 100% rename from nuxt/content-guides/application-guide/worked-examples/README.md rename to nuxt/content-guides/application-guide/worked-examples/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/ai/README.md b/nuxt/content-guides/flowfuse-nodes/ai/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/ai/README.md rename to nuxt/content-guides/flowfuse-nodes/ai/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/edge/README.md b/nuxt/content-guides/flowfuse-nodes/edge/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/edge/README.md rename to nuxt/content-guides/flowfuse-nodes/edge/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/flowfuse-tables/README.md b/nuxt/content-guides/flowfuse-nodes/flowfuse-tables/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/flowfuse-tables/README.md rename to nuxt/content-guides/flowfuse-nodes/flowfuse-tables/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/hub/README.md b/nuxt/content-guides/flowfuse-nodes/hub/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/hub/README.md rename to nuxt/content-guides/flowfuse-nodes/hub/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/README.md b/nuxt/content-guides/flowfuse-nodes/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/README.md rename to nuxt/content-guides/flowfuse-nodes/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/mcp/README.md b/nuxt/content-guides/flowfuse-nodes/mcp/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/mcp/README.md rename to nuxt/content-guides/flowfuse-nodes/mcp/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/mqtt/README.md b/nuxt/content-guides/flowfuse-nodes/mqtt/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/mqtt/README.md rename to nuxt/content-guides/flowfuse-nodes/mqtt/index.md diff --git a/nuxt/content-guides/index.md b/nuxt/content-guides/index.md new file mode 100644 index 0000000000..699909d3d1 --- /dev/null +++ b/nuxt/content-guides/index.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-guides/node-red-guide/README.md b/nuxt/content-guides/node-red-guide/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/README.md rename to nuxt/content-guides/node-red-guide/index.md diff --git a/nuxt/content-guides/node-red-guide/patterns/README.md b/nuxt/content-guides/node-red-guide/patterns/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/patterns/README.md rename to nuxt/content-guides/node-red-guide/patterns/index.md diff --git a/nuxt/content-guides/node-red-guide/worked-examples/README.md b/nuxt/content-guides/node-red-guide/worked-examples/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/worked-examples/README.md rename to nuxt/content-guides/node-red-guide/worked-examples/index.md diff --git a/nuxt/content-guides/node-red/database/README.md b/nuxt/content-guides/node-red/database/index.md similarity index 100% rename from nuxt/content-guides/node-red/database/README.md rename to nuxt/content-guides/node-red/database/index.md diff --git a/nuxt/content-guides/node-red/getting-started/editor/README.md b/nuxt/content-guides/node-red/getting-started/editor/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/editor/README.md rename to nuxt/content-guides/node-red/getting-started/editor/index.md diff --git a/nuxt/content-guides/node-red/getting-started/README.md b/nuxt/content-guides/node-red/getting-started/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/README.md rename to nuxt/content-guides/node-red/getting-started/index.md diff --git a/nuxt/content-guides/node-red/getting-started/library/README.md b/nuxt/content-guides/node-red/getting-started/library/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/library/README.md rename to nuxt/content-guides/node-red/getting-started/library/index.md diff --git a/nuxt/content-guides/node-red/getting-started/programming/README.md b/nuxt/content-guides/node-red/getting-started/programming/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/programming/README.md rename to nuxt/content-guides/node-red/getting-started/programming/index.md diff --git a/nuxt/content-guides/node-red/hardware/README.md b/nuxt/content-guides/node-red/hardware/index.md similarity index 100% rename from nuxt/content-guides/node-red/hardware/README.md rename to nuxt/content-guides/node-red/hardware/index.md diff --git a/nuxt/content-guides/node-red/README.md b/nuxt/content-guides/node-red/index.md similarity index 100% rename from nuxt/content-guides/node-red/README.md rename to nuxt/content-guides/node-red/index.md diff --git a/nuxt/content-guides/node-red/integration-technologies/README.md b/nuxt/content-guides/node-red/integration-technologies/index.md similarity index 100% rename from nuxt/content-guides/node-red/integration-technologies/README.md rename to nuxt/content-guides/node-red/integration-technologies/index.md diff --git a/nuxt/content-guides/node-red/keyboard/README.md b/nuxt/content-guides/node-red/keyboard/index.md similarity index 100% rename from nuxt/content-guides/node-red/keyboard/README.md rename to nuxt/content-guides/node-red/keyboard/index.md diff --git a/nuxt/content-guides/node-red/notification/README.md b/nuxt/content-guides/node-red/notification/index.md similarity index 100% rename from nuxt/content-guides/node-red/notification/README.md rename to nuxt/content-guides/node-red/notification/index.md diff --git a/nuxt/content-guides/node-red/peripheral/README.md b/nuxt/content-guides/node-red/peripheral/index.md similarity index 100% rename from nuxt/content-guides/node-red/peripheral/README.md rename to nuxt/content-guides/node-red/peripheral/index.md diff --git a/nuxt/content-guides/node-red/protocol/README.md b/nuxt/content-guides/node-red/protocol/index.md similarity index 100% rename from nuxt/content-guides/node-red/protocol/README.md rename to nuxt/content-guides/node-red/protocol/index.md diff --git a/nuxt/content-guides/node-red/terminology/README.md b/nuxt/content-guides/node-red/terminology/index.md similarity index 100% rename from nuxt/content-guides/node-red/terminology/README.md rename to nuxt/content-guides/node-red/terminology/index.md diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index d4475e9036..9bdd0305ab 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { defineContentConfig, defineCollection, z } from '@nuxt/content' const tierValue = z.object({ @@ -25,7 +26,21 @@ export default defineContentConfig({ }), docs: defineCollection({ type: 'page', - source: 'docs/**/*.md', + // Two sources, not one directory: FlowFuse/flowfuse's docs land in + // nuxt/content/docs (materialized there by nuxt/lib/docs-sync.mjs, which + // still needs a real git clone for per-file history - see that file), and + // this repo's own guides are read straight out of nuxt/content-guides/ with + // no copy step. `prefix: 'docs'` puts the second source's pages at the same + // `docs/...` path the first source's `docs/**/*.md` glob derives from its own + // directory name, so both land under /docs/ and a path collision between them + // fails the build (the `docs` collection's `id` is a primary key). The + // `content:file:beforeParse` hook in nuxt.config.ts stamps guide pages with + // `editUrl`/`updated` frontmatter as they're read; nuxt/lib/guides-sync.mjs + // only still copies the guides' non-markdown assets to public/docs. + source: [ + { include: 'docs/**/*.md' }, + { cwd: join(__dirname, 'content-guides'), include: '**/*.md', prefix: 'docs' }, + ], schema: z.object({ navTitle: z.string().optional(), // The browser/search-result title, when the sidebar label is too short to @@ -39,7 +54,7 @@ export default defineContentConfig({ navGroupOrder: z.number().optional(), navOrder: z.number().optional(), originalPath: z.string().optional(), - // Set only on pages overlaid from this repo's nuxt/content-guides/ tree + // Set only on pages read from this repo's nuxt/content-guides/ source // (see nuxt/lib/guides-sync.mjs). `originalPath` marks a page imported // from FlowFuse/flowfuse and the docs page builds a flowfuse edit link // from it; a page carrying `editUrl` links back here instead. @@ -57,6 +72,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-content-path.mjs b/nuxt/lib/docs-content-path.mjs new file mode 100644 index 0000000000..e9ff990aa5 --- /dev/null +++ b/nuxt/lib/docs-content-path.mjs @@ -0,0 +1,34 @@ +// Maps a docs page's path on disk to the /docs path it will be served at. +// +// /docs is assembled from two sources and they sit in different places on disk. +// FlowFuse/flowfuse's docs are materialized into nuxt/content/docs by docs-sync.mjs, so +// their path already contains the `/docs/` segment. This repo's own guides are read +// straight out of nuxt/content-guides/, which does not contain it, and the collection +// gives that source `prefix: 'docs'` to put them under the same URL space. +// +// Anything resolving a relative URL inside a page needs the served path rather than the +// on-disk one, and keying only off `/docs/` silently skipped the whole second source: +// every relative image URL in the guides reached the browser unresolved and 404'd +// against the page's own URL instead. Kept in nuxt/lib as plain JS so `node --test` can +// exercise it directly, like docs-nav.mjs. + +const GUIDES_SEGMENT = '/content-guides/' +const DOCS_SEGMENT = '/docs/' + +/** + * The `/docs/...` path a source file is served at, or `null` if it is not a docs page. + * + * @param {string} filePath absolute or repo-relative path of the source file + * @returns {string|null} + */ +export function docsPathForSourceFile (filePath) { + const path = String(filePath || '') + + const guidesIndex = path.lastIndexOf(GUIDES_SEGMENT) + if (guidesIndex !== -1) { + return DOCS_SEGMENT + path.slice(guidesIndex + GUIDES_SEGMENT.length) + } + + const docsIndex = path.lastIndexOf(DOCS_SEGMENT) + return docsIndex === -1 ? null : path.slice(docsIndex) +} diff --git a/nuxt/lib/docs-content-path.test.mjs b/nuxt/lib/docs-content-path.test.mjs new file mode 100644 index 0000000000..b4d304b9ec --- /dev/null +++ b/nuxt/lib/docs-content-path.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { docsPathForSourceFile } from './docs-content-path.mjs' +import { GUIDES_SOURCE, listGuideFiles } from './guides-sync.mjs' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..') + +test('a guide read from content-guides maps to the /docs path it is served at', () => { + // The whole reason this exists: keying off `/docs/` alone skipped this source, and + // nuxt/utils/remark-docs-links.ts then left every relative image URL in the guides + // unresolved, so the browser resolved it against the page's own URL and 404'd. + assert.equal( + docsPathForSourceFile('/repo/nuxt/content-guides/node-red/database/influxdb.md'), + '/docs/node-red/database/influxdb.md' + ) + assert.equal( + docsPathForSourceFile('/repo/nuxt/content-guides/application-guide/index.md'), + '/docs/application-guide/index.md' + ) +}) + +test('a materialized page from FlowFuse/flowfuse keeps resolving as it did', () => { + assert.equal( + docsPathForSourceFile('/repo/nuxt/content/docs/user/concepts.md'), + '/docs/user/concepts.md' + ) +}) + +test('a page from neither source is not a docs page', () => { + assert.equal(docsPathForSourceFile('/repo/nuxt/content/handbook/team.md'), null) + assert.equal(docsPathForSourceFile(''), null) + assert.equal(docsPathForSourceFile(undefined), null) +}) + +test('every guide that uses a relative asset URL is a page this can resolve', () => { + // A relative URL is only safe because something rewrites it. If a guide's path stops + // being recognised here, the rewrite goes back to silently not happening, so this + // asserts the two stay in step over the real tree rather than over a fixture. + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const unresolvable = [] + + for (const relPath of listGuideFiles(guidesDir).filter(f => f.endsWith('.md'))) { + const body = readFileSync(join(guidesDir, relPath), 'utf8') + if (!/!\[[^\]]*\]\((\.\.?\/)/.test(body)) continue + if (!docsPathForSourceFile(join(guidesDir, relPath))) unresolvable.push(relPath) + } + + assert.deepEqual(unresolvable, [], unresolvable.join('\n')) +}) diff --git a/nuxt/lib/docs-sync.mjs b/nuxt/lib/docs-sync.mjs index 766eceb612..dabde24893 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/index.md, which @nuxt/content reads in place. 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..2e2f890ebb 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/index.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/lib/guides-sync.mjs b/nuxt/lib/guides-sync.mjs index be5faeab5e..5c9762fca8 100644 --- a/nuxt/lib/guides-sync.mjs +++ b/nuxt/lib/guides-sync.mjs @@ -1,29 +1,35 @@ -// Overlays the website-authored guides onto the docs content tree. +// Wires the website-authored guides into the docs content tree. // // /docs is assembled from two repos. FlowFuse/flowfuse owns the product documentation - // how-to and reference, versioned with the code it describes - and docs-sync.mjs copies -// it in. This module copies the second source: the guides authored in *this* repo under -// nuxt/content-guides/, which explain how to shape an application rather than how to -// drive a feature, and so are not tied to a product release. +// it into nuxt/content/docs. This module covers the second source: the guides authored in +// *this* repo under nuxt/content-guides/, which explain how to shape an application rather +// than how to drive a feature, and so are not tied to a product release. // -// Both land in nuxt/content/docs, so @nuxt/content sees a single `docs` collection and -// the sidebar, breadcrumbs, prerender list, sitemap and search treat the two sources -// identically. nuxt/content/docs is gitignored and wiped on every sync, which is why the -// guides cannot simply be authored there. +// Unlike the flowfuse docs, the guides are already MDC and already live in this repo, so +// they do not need copying to become a `docs` collection page: nuxt/content.config.ts +// declares nuxt/content-guides/ as a second source of the `docs` collection (its own `cwd`, +// prefixed onto the `docs/` path so it lands next to the flowfuse pages), and the +// `content:file:beforeParse` hook in nuxt.config.ts calls injectGuideFrontmatter below to +// stamp each guide page with the same `editUrl`/`updated` provenance this module used to +// write by hand. What is left here is what a content-collection source cannot do by +// itself: copying the guides' non-markdown assets (images, mostly) to nuxt/public/docs so +// they resolve at runtime, and failing the build if a guide's path would collide with a +// page FlowFuse/flowfuse already publishes. // // Kept free of Nuxt imports, like docs-sync.mjs, so `scripts/sync_docs.mjs` can run it // before `npm install` and `node --test` can exercise it directly. import { execFileSync } from 'node:child_process' -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' // Repo-relative, so it can be both the source directory and the tail of the edit URL. export const GUIDES_SOURCE = 'nuxt/content-guides' -const EDIT_BASE = 'https://github.com/FlowFuse/website/edit/main' +export const EDIT_BASE = 'https://github.com/FlowFuse/website/edit/main' -function gitOutput (cwd, args) { +export function gitOutput (cwd, args) { try { // stderr is discarded rather than inherited: outside a git checkout (a unit test, // a tarball build) git's "not a git repository" is expected and handled below. @@ -33,21 +39,6 @@ function gitOutput (cwd, args) { } } -/** - * Where one guide file lands. Same rules docs-sync uses for the flowfuse tree - markdown - * becomes a page, README.md becomes its section index, anything else is a public asset - - * so a directory of guides nests in the sidebar exactly like a directory of docs. - */ -export function destinationFor (relPath, contentDocsDir, publicDocsDir) { - const name = basename(relPath) - const dir = dirname(relPath) - const prefix = dir === '.' ? '' : dir - - return name.endsWith('.md') - ? join(contentDocsDir, prefix, name === 'README.md' ? 'index.md' : name) - : join(publicDocsDir, prefix, name) -} - /** * Stamp build-time provenance onto a guide page. * @@ -73,36 +64,32 @@ export function injectFrontmatter (content, { editUrl, updated }) { } /** - * Copy one guide file into the docs tree. - * - * Deliberately does NOT run docs-markdown's processMarkdown: that exists to repair - * Eleventy-era markup in the flowfuse docs (Nunjucks callouts, inline custom-element - * scripts, blank lines inside raw HTML blocks). The guides are authored as MDC against - * the components in nuxt/components/content/, and those transforms would mangle them. + * Called from the `content:file:beforeParse` hook for every file @nuxt/content reads out + * of the content-guides source. `absPath` is that hook's `file.path` - the real path on + * disk, which is what lets this run entirely inside the hook rather than needing a + * separate copy step: the git history it reads is this repo's own, at the guide's real + * location, not a location this module chose. */ -export function writeGuideFile ({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) { - const srcPath = join(guidesDir, relPath) - const destPath = destinationFor(relPath, contentDocsDir, publicDocsDir) - - mkdirSync(dirname(destPath), { recursive: true }) - - if (!relPath.endsWith('.md')) { - cpSync(srcPath, destPath) - return destPath - } - - const sourcePath = `${GUIDES_SOURCE}/${relPath}` - // Argument array, not a shell string: the path comes from filenames on disk, so - // interpolating it into a shell command would be an injection path. +export function injectGuideFrontmatter (content, { repoRoot, absPath }) { + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const sourcePath = `${GUIDES_SOURCE}/${stripPrefix(guidesDir, absPath)}` const updated = gitOutput(repoRoot, ['log', '-1', '--pretty=format:%ci', '--', sourcePath]) - const raw = readFileSync(srcPath, 'utf8') - writeFileSync(destPath, injectFrontmatter(raw, { + return injectFrontmatter(content, { editUrl: `${EDIT_BASE}/${sourcePath}`, updated, - }), 'utf8') + }) +} + +/** Whether a `content:file:beforeParse` file came from the guides source. */ +export function isGuidePath (absPath, repoRoot) { + const guidesDir = join(repoRoot, GUIDES_SOURCE) + return absPath === guidesDir || absPath.startsWith(guidesDir + '/') +} - return destPath +// The guide's path under GUIDES_SOURCE, with no leading slash. +function stripPrefix (from, to) { + return to.startsWith(from) ? to.slice(from.length).replace(/^\/+/, '') : to } /** Every file under the guides tree, as paths relative to it. */ @@ -121,55 +108,60 @@ export function listGuideFiles (guidesDir, relDir = '') { } /** - * Copy the whole guides tree into nuxt/content/docs, after docs-sync has populated it. + * Copy one guide asset (a non-markdown file) into nuxt/public/docs, or remove it if it has + * gone. Markdown is not handled here: @nuxt/content reads it straight out of + * nuxt/content-guides/ as a source of the `docs` collection. + */ +export function syncGuideAssetPath ({ repoRoot, nuxtRoot, relPath }) { + if (relPath.endsWith('.md')) return + + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const destPath = join(nuxtRoot, 'public', 'docs', relPath) + + if (!existsSync(join(guidesDir, relPath))) { + rmSync(destPath, { force: true }) + return + } + + mkdirSync(dirname(destPath), { recursive: true }) + cpSync(join(guidesDir, relPath), destPath) +} + +/** + * Copy the guides' non-markdown assets into nuxt/public/docs, and fail the build if a + * guide's path would collide with a page FlowFuse/flowfuse already publishes. * - * A guide that lands on a path the flowfuse docs already occupy would silently replace - * that page - the overlay runs second - and the loss would only show up as a docs page - * mysteriously missing from production. Collisions therefore fail the build. + * The collision check used to be the only thing standing between a colliding guide and a + * docs page it would silently replace, because both were written into the same directory + * and the second write won. Now that guides are a separate content-collection source, a + * real collision - the same `docs` path served by both sources - fails anyway (the `docs` + * table's `id` is a primary key), but as a SQL constraint error naming a key, not a guide + * file. Checking here first keeps the friendlier message. */ -export function syncGuides ({ repoRoot, nuxtRoot, logger = console } = {}) { +export function syncGuideAssets ({ repoRoot, nuxtRoot, logger = console } = {}) { const guidesDir = join(repoRoot, GUIDES_SOURCE) const contentDocsDir = join(nuxtRoot, 'content', 'docs') - const publicDocsDir = join(nuxtRoot, 'public', 'docs') if (!existsSync(guidesDir)) { logger.warn(`No guides to overlay: ${GUIDES_SOURCE} does not exist`) - return { count: 0 } + return { pages: 0, assets: 0 } } const files = listGuideFiles(guidesDir) - const collisions = files.filter(relPath => - existsSync(destinationFor(relPath, contentDocsDir, publicDocsDir))) + const pages = files.filter(relPath => relPath.endsWith('.md')) + const assets = files.filter(relPath => !relPath.endsWith('.md')) + const collisions = pages.filter(relPath => existsSync(join(contentDocsDir, relPath))) if (collisions.length) { throw new Error( `Guide files collide with pages from FlowFuse/flowfuse and would overwrite them: ${collisions.join(', ')}` ) } - for (const relPath of files) { - writeGuideFile({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) - } - - logger.info(`Overlaid ${files.length} guide files from ${GUIDES_SOURCE} onto content/docs`) - return { count: files.length } -} - -/** - * Sync a single guide file, for the dev watcher. Mirrors syncDocsPath: a full re-sync on - * every save would delete and recreate every page in the collection, and @nuxt/content - * re-indexing all of them at once exhausts the dev server's heap. - */ -export function syncGuidePath ({ repoRoot, nuxtRoot, relPath }) { - const guidesDir = join(repoRoot, GUIDES_SOURCE) - const contentDocsDir = join(nuxtRoot, 'content', 'docs') - const publicDocsDir = join(nuxtRoot, 'public', 'docs') - - if (!existsSync(join(guidesDir, relPath))) { - // Same destination mapping as the write, so deleting a README removes its index.md. - rmSync(destinationFor(relPath, contentDocsDir, publicDocsDir), { force: true }) - return + for (const relPath of assets) { + syncGuideAssetPath({ repoRoot, nuxtRoot, relPath }) } - writeGuideFile({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) + logger.info(`Copied ${assets.length} guide assets to public/docs; ${pages.length} guide pages read directly from ${GUIDES_SOURCE}`) + return { pages: pages.length, assets: assets.length } } diff --git a/nuxt/lib/guides-sync.test.mjs b/nuxt/lib/guides-sync.test.mjs index b3971b90a1..96a0dc1306 100644 --- a/nuxt/lib/guides-sync.test.mjs +++ b/nuxt/lib/guides-sync.test.mjs @@ -1,15 +1,18 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { GUIDES_SOURCE, - destinationFor, injectFrontmatter, + injectGuideFrontmatter, + isGuidePath, listGuideFiles, - syncGuides, + syncGuideAssetPath, + syncGuideAssets, } from './guides-sync.mjs' const silent = { info: () => {}, warn: () => {}, error: () => {} } @@ -43,24 +46,6 @@ test('a timestamp git could not supply is left out, not emitted empty', () => { assert.ok(!/updated:/.test(without), 'an empty timestamp must not leave a valueless key') }) -test('a README becomes its section index, so a directory of guides nests like a directory of docs', () => { - assert.equal( - destinationFor('application-guide/README.md', '/content/docs', '/public/docs'), - '/content/docs/application-guide/index.md' - ) - assert.equal( - destinationFor('application-guide/architectures/it.md', '/content/docs', '/public/docs'), - '/content/docs/application-guide/architectures/it.md' - ) -}) - -test('non-markdown files are served as assets rather than parsed as pages', () => { - assert.equal( - destinationFor('application-guide/images/oee.png', '/content/docs', '/public/docs'), - '/public/docs/application-guide/images/oee.png' - ) -}) - test('provenance is added to existing frontmatter without disturbing it', () => { const out = injectFrontmatter('---\ntitle: Foundations\n---\n\n# Foundations\n', { editUrl: 'https://example.test/edit', @@ -76,38 +61,122 @@ test('a guide with no frontmatter still gets a block', () => { assert.equal(out, '---\neditUrl: e\nupdated: u\n---\n# Foundations\n') }) -test('the whole guides tree lands in the docs content tree, stamped with an edit link back to this repo', () => { - const { root, nuxtRoot, contentDocsDir, publicDocsDir, cleanup } = scratch() +test('isGuidePath matches only files under the guides source', () => { + const root = '/repo' + assert.equal(isGuidePath(join(root, GUIDES_SOURCE, 'application-guide/index.md'), root), true) + assert.equal(isGuidePath(join(root, 'nuxt/content/docs/user/index.md'), root), false) + // Not a false-positive on a directory that merely shares the prefix. + assert.equal(isGuidePath(join(root, 'nuxt/content-guides-other/index.md'), root), false) +}) + +test('injectGuideFrontmatter stamps an edit URL back to this repo, keyed off the real file path', () => { + const { root, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '---\ntitle: Guide\n---\n\n# Guide\n') + + const out = injectGuideFrontmatter('---\ntitle: Guide\n---\n\n# Guide\n', { + repoRoot: root, + absPath: join(root, GUIDES_SOURCE, 'application-guide/index.md'), + }) + + assert.match(out, /editUrl: https:\/\/github\.com\/FlowFuse\/website\/edit\/main\/nuxt\/content-guides\/application-guide\/index\.md/) + // Not a git checkout, so gitOutput has nothing to report and the key is left out + // rather than written empty: a valueless key is YAML null, which the collection + // schema takes as neither a string nor absent. + assert.ok(!/updated:/.test(out), 'an unanswerable timestamp must not leave a valueless key') + assert.match(out, /title: Guide/) + } finally { + cleanup() + } +}) + +test('injectGuideFrontmatter reads this repo\'s own git history for the guide, not flowfuse\'s', () => { + const { root, cleanup } = scratch() + try { + execFileSync('git', ['init', '-q'], { cwd: root }) + execFileSync('git', ['config', 'user.email', 'test@example.test'], { cwd: root }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }) + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') + execFileSync('git', ['add', '.'], { cwd: root }) + execFileSync('git', ['commit', '-q', '-m', 'add guide'], { cwd: root }) + + const out = injectGuideFrontmatter('# Guide\n', { + repoRoot: root, + absPath: join(root, GUIDES_SOURCE, 'application-guide/index.md'), + }) + + assert.doesNotMatch(out, /updated: \n/) + assert.match(out, /updated: \d{4}-\d{2}-\d{2}/) + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath copies a non-markdown asset to public/docs', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/diagram.svg'), '') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/diagram.svg' }) + + assert.equal(readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'), 'utf8'), '') + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath removes the copy when the asset is gone', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(publicDocsDir, 'application-guide/diagram.svg'), '') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/diagram.svg' }) + + assert.throws(() => readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'))) + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath ignores markdown - that is @nuxt/content\'s job now', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/index.md' }) + + assert.throws(() => readFileSync(join(publicDocsDir, 'application-guide/index.md'))) + } finally { + cleanup() + } +}) + +test('syncGuideAssets copies only the non-markdown files', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() try { - write(join(root, GUIDES_SOURCE, 'application-guide/README.md'), '---\ntitle: Guide\n---\n\n# Guide\n') - write(join(root, GUIDES_SOURCE, 'application-guide/architectures/it.md'), '---\ntitle: IT\n---\n\n# IT\n') + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') write(join(root, GUIDES_SOURCE, 'application-guide/diagram.svg'), '') - mkdirSync(contentDocsDir, { recursive: true }) - const { count } = syncGuides({ repoRoot: root, nuxtRoot, logger: silent }) + const result = syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }) - assert.equal(count, 3) - const index = readFileSync(join(contentDocsDir, 'application-guide/index.md'), 'utf8') - assert.match(index, /editUrl: https:\/\/github\.com\/FlowFuse\/website\/edit\/main\/nuxt\/content-guides\/application-guide\/README\.md/) - assert.match(index, /title: Guide/) - assert.ok(readFileSync(join(contentDocsDir, 'application-guide/architectures/it.md'), 'utf8')) + assert.deepEqual(result, { pages: 1, assets: 1 }) assert.equal(readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'), 'utf8'), '') } finally { cleanup() } }) -test('a guide that would overwrite a page from FlowFuse/flowfuse fails the build', () => { +test('a guide that would collide with a page from FlowFuse/flowfuse fails the build', () => { const { root, nuxtRoot, contentDocsDir, cleanup } = scratch() try { - // The overlay runs after the product docs are copied in, so without this guard a - // colliding guide would silently replace a docs page and the loss would only show - // up as a page missing from production. + // @nuxt/content would also refuse this - the docs collection's `id` is a primary + // key - but as a SQL constraint error, not a message naming the file. This check + // runs first so the build fails with the friendlier one. write(join(root, GUIDES_SOURCE, 'user/concepts.md'), '# Concepts\n') write(join(contentDocsDir, 'user/concepts.md'), '# Concepts from flowfuse\n') assert.throws( - () => syncGuides({ repoRoot: root, nuxtRoot, logger: silent }), + () => syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }), /collide with pages from FlowFuse\/flowfuse/ ) } finally { @@ -118,7 +187,7 @@ test('a guide that would overwrite a page from FlowFuse/flowfuse fails the build test('a missing guides directory is reported, not fatal', () => { const { root, nuxtRoot, cleanup } = scratch() try { - assert.deepEqual(syncGuides({ repoRoot: root, nuxtRoot, logger: silent }), { count: 0 }) + assert.deepEqual(syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }), { pages: 0, assets: 0 }) } finally { cleanup() } diff --git a/nuxt/modules/docs-source.ts b/nuxt/modules/docs-source.ts index db3a8a91b3..ad869680a9 100644 --- a/nuxt/modules/docs-source.ts +++ b/nuxt/modules/docs-source.ts @@ -7,7 +7,7 @@ import { join, basename, dirname } from 'node:path' // @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly import { syncDocs } from '../lib/docs-sync.mjs' // @ts-ignore same -import { syncGuides } from '../lib/guides-sync.mjs' +import { GUIDES_SOURCE, syncGuideAssets } from '../lib/guides-sync.mjs' // @ts-ignore same import { docsRedirectRules, prerenderableRoutes } from '../lib/docs-redirects.mjs' @@ -35,21 +35,28 @@ export default defineNuxtModule({ const contentDocsDir = join(nuxtRoot, 'content', 'docs') const repoRoot = dirname(nuxtRoot) + const guidesDir = join(repoRoot, GUIDES_SOURCE) - // Order matters: syncDocs wipes content/docs before writing, so the guides overlaid - // from this repo have to land after it, not before. await syncDocs({ repoRoot, nuxtRoot, logger }) - syncGuides({ repoRoot, nuxtRoot, logger }) + // The guide pages themselves are a second source of the `docs` collection (see + // content.config.ts) and are never copied into content/docs - this only copies + // their non-markdown assets and fails the build on a path collision with a page + // from FlowFuse/flowfuse. Order no longer matters against syncDocs because of + // that: nothing here writes into contentDocsDir any more. + syncGuideAssets({ repoRoot, nuxtRoot, logger }) - if (!existsSync(contentDocsDir)) return - - // Collected after the overlay, so the guide pages get prerendered with the rest - // of /docs and need no route list of their own in nuxt.config. - const docsPages = collectPages(contentDocsDir, '/docs') + // Collected from both of the `docs` collection's sources, so every page gets + // prerendered with the rest of /docs and neither source needs a route list of its + // own in nuxt.config. contentDocsDir covers FlowFuse/flowfuse's docs, materialized + // by syncDocs; guidesDir covers this repo's guides, read directly. + const docsPages = [ + ...(existsSync(contentDocsDir) ? collectPages(contentDocsDir, '/docs') : []), + ...(existsSync(guidesDir) ? collectPages(guidesDir, '/docs') : []), + ] // A floor rather than a log line, following the same call in nuxt.config.ts for the - // integrations. Collecting nothing means the tree above did not land, and on the - // netlify preset unprerendered routes still SSR, so the only symptom would be a - // thinner static site and slow cold pages - nothing that looks like a failure. + // integrations. Both arms above swallow a missing tree, so collecting nothing means + // a source did not land; on the netlify preset unprerendered routes still SSR, so + // the only symptom would be a thinner static site and slow cold pages. // Checked on what was collected, not on what survives the redirect filter below: // that filter is meant to drop routes, so a floor on its output would be a floor // on how many redirects the docs happen to declare. diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index cc2943714b..c732d4c377 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -8,6 +8,10 @@ import { BLOG_TAGS } from './composables/useBlogList' import { redirects } from './redirects' import { sitemapProblems } from './lib/sitemap-coverage.mjs' import site from './data/site.json' +// @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly +import { injectGuideFrontmatter, isGuidePath } from './lib/guides-sync.mjs' + +const REPO_ROOT = join(__dirname, '..') // Collect all handbook routes from content files for SSG prerendering function collectHandbookRoutes(dir: string, basePath: string): string[] { @@ -597,11 +601,18 @@ export default defineNuxtConfig({ // under it. These collections' frontmatter still writes "meta:", so rewrite the key // to "structuredData:" before parsing rather than editing hundreds of content files. 'content:file:beforeParse' ({ file, collection }) { - if (!['blog', 'webinars', 'docs'].includes(collection.name)) return - file.body = file.body.replace( - /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/, - (block) => block.replace(/^meta:[ \t]*\r?$/m, 'structuredData:') - ) + if (['blog', 'webinars', 'docs'].includes(collection.name)) { + file.body = file.body.replace( + /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/, + (block) => block.replace(/^meta:[ \t]*\r?$/m, 'structuredData:') + ) + } + // The `docs` collection's second source reads nuxt/content-guides/ directly + // (see content.config.ts and nuxt/lib/guides-sync.mjs) - this is where those + // pages get the editUrl/updated provenance a copy step used to write by hand. + if (collection.name === 'docs' && file.path && isGuidePath(file.path, REPO_ROOT)) { + file.body = injectGuideFrontmatter(file.body, { repoRoot: REPO_ROOT, absPath: file.path }) + } }, // Enumerate /integrations/{id}/ routes at config-time so SSG prerenders them. // Can't use Nuxt's $fetch here — it only exists at nitro runtime. 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(() => {