Migrate website from 11ty to Nuxt 4#5091
Conversation
Additional detailsHigh-level changes
Dev & build commandsnpm install # npm workspaces; nuxt/ is a workspace
npm run dev # nuxt dev (3000) + postcss + docs + blueprints watchers
npm run build # → build:nuxt → copy_* steps then `nuxt generate`
npm run build:nuxt:skip-images # faster iteration (skips image processing)
Verification gates
The route-parity check is the proof for the "URLs never change" constraint: the CI
Known / deferred items (non-blocking)
See |
There was a problem hiding this comment.
Trivy found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
|
We need to find a way to break this down, no one can review 809 files. 😅 |
✅ Deploy Preview for flowforge-website ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Adds tooling to prove the Nuxt build serves a superset of the legacy 11ty routes (zero dropped URLs, trailing slashes preserved): route extractor, diff checker, end-to-end verify script, and the committed 11ty route baseline (1069 routes).
Separate one-time baseline capture (capture-baseline.sh) from per-build verification (verify-routes.sh) so the diff keeps catching dropped URLs even after a section is removed from the 11ty build.
Hybrid build (11ty copied into nuxt/public + Nuxt-native /terms, /privacy-policy) produces a superset of the frozen 1069-route 11ty baseline. Confirms the strangler-fig output drops no URLs.
Allowlist the sprite host so the Nuxt dev server works behind the *.sprites.app proxy, and record migration progress, the route-parity proof, and the remaining scope/blockers in migration/STATUS.md.
Render the handbook via @nuxt/content at its original /handbook/... URLs (trailing slashes preserved) instead of 11ty: - scripts/copy_handbook.js generates nuxt/content from src/handbook, rewriting relative .md links to absolute handbook URLs and relative images to /handbook-media (copied into public). - handbook collection + pages/handbook/[...slug].vue with sidebar nav (HandbookNavTree) and TOC; routes prerendered from handbook.routes.json. - legacy proxy yields /handbook* to Nuxt in dev. - The one .njk-templated and one space-named handbook page stay on 11ty. - Skip link-checker best-practice STYLE inspections (not broken links) that legacy prose violates; failOnError stays on. Verified: nuxt generate green, link-checker 0 errors/0 warnings, route diff 0 dropped URLs (Nuxt superset of the 1069-route baseline).
11ty now ignores the handbook pages Nuxt owns (via generated nuxt/handbook.migrated-sources.json manifest); only the 3 bespoke straggler pages (2 .njk + 1 space-named .md) still build on 11ty. Verified: build green, link-checker 0/0, route diff 0 dropped.
Render the changelog at its original URLs via Nuxt instead of 11ty: - scripts/copy_changelog.js generates nuxt/content/changelog from src/changelog (relative links/images rewritten) and a combined, date-desc card index (170 entries + 9 blog posts tagged 'changelog') matching 11ty's collection so pagination yields the identical pages. - pages/changelog/[...slug].vue serves entries, the paginated index (/changelog/ + /changelog/1..9/, 19/page), with author/date/issues from the generated index (single source of truth shared with the feed). - server/routes/changelog/index.xml.get.ts reproduces the Atom feed. - .eleventy.js ignores the 170 entries + index.njk + feed-changelog.njk; legacy proxy yields /changelog* to Nuxt. Verified: nuxt generate green, link-checker 0/0, route diff 0 dropped (Nuxt superset of the 1069-route baseline).
scripts/copy_customer_stories.js generates nuxt/content/customer-stories + a metadata index (brand/quote/challenge/solution/logo) since @nuxt/content doesn't surface nested frontmatter. pages/customer-stories/[...slug].vue serves the index grid + story pages (hero, quote, Challenge/Solution sidebar) at identical URLs; legacy proxy yields /customer-stories* to Nuxt. 11ty keeps building these (Nuxt prerender overwrites in the merged output) because collections.stories is consumed by other pages that remain on 11ty (node-red/index, landing/tulip, thank-you/contact, llms). Verified: build green, link-checker 0/0, route diff 0 dropped.
Reproduces the legacy 11ty renderFlow shortcode: renders a Node-RED flow client-side via the bundled @flowfuse/flow-renderer (window.FlowRenderer, loaded through a runtime module script). Flow JSON is passed base64-encoded to survive MDC parsing. Verified in a real browser: renders nodes, wires, labels and zoom controls. Unblocks faithful migration of the 188 renderFlow embeds across node-red and blog.
Render /webinars/ (index + 39 detail pages) and /ask-me-anything/ (3 pages) natively via @nuxt/content, preserving every URL incl. trailing slashes. - scripts/copy_events.js generates webinars + ama collections from src/webinars and src/ask-me-anything, resolving hosts (team+guests) and per-page metadata (date/time/duration/video/hubspot) into events.index.json, and emits events.routes.json for prerendering. - Shared EventDetail + HubSpotForm components; webinars [...slug] handles the index and detail, ask-me-anything [...slug] reuses EventDetail. - The one webinar whose filename contains a literal space is left on 11ty (Nuxt prerenderer cannot resolve unsafe-char routes); 11ty still emits it so route parity holds. Dir-index (index.md) maps to the directory URL. - Removed /webinars + /ask-me-anything from the legacy proxy. Verified: build green, prerender 0 errors, route diff 0 dropped (superset), pages confirmed Nuxt-rendered.
Render /ebooks/<slug>/ natively via @nuxt/content, reusing the HubSpotForm component for the gated download. copy_ebooks.js generates the ebooks collection + metadata index (title/cover/contentTable/hubspot) from src/ebooks; images resolved to absolute /images URLs. Removed /ebooks from the legacy proxy. Verified: build green, prerender 0 errors, route diff 0 dropped, page Nuxt-rendered with working download form.
…tes) Convert three bespoke 11ty solution pages to native Vue pages, reproducing the hand-crafted marketing markup (hero, feature grids, advantage grid, CTA, UNS learn cards). Icons inlined as SVG; sign-up shortcode resolved to the app URL; lite-youtube replaced with a responsive iframe. Added each exact route to the legacy proxy NUXT_ROUTES and to nitro.prerender.routes. Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, all three confirmed Nuxt-rendered.
Add the remaining three bespoke solution pages (data-integration, mes, it-ot-middleware) as native Vue, reproducing the hand-crafted marketing markup: feature grids, ffIconLg icons inlined as SVG, architecture/pictogram sections, resources (case-study + whitepaper cards), deployment options, and a reusable FaqAccordion component. With all six migrated, /solutions is now a Nuxt-owned prefix in the legacy proxy (individual route entries removed). Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, all six confirmed Nuxt-rendered.
Convert the two comparison landing pages to native Vue, reproducing the data-driven hero, feature grids, comparison tables, switch steps, and CTA. Add a reusable SocialProof.vue (homeLogos carousel) shared by both. Icons inlined as SVG, sign-up shortcode resolved, lite-youtube -> responsive iframe. /vs is now a Nuxt-owned proxy prefix. Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, both pages Nuxt-rendered (kepware visually confirmed).
scripts/visual-check.js drives a headless Chrome over a representative URL per migrated cluster, capturing page errors, first-party 404s and render signals (screenshots to /tmp/smoke). Document the final verification results and the two known non-blocking items (hydration warnings on a few bespoke pages; flow-renderer limitation on flows with group/junction nodes) in migration/STATUS.md.
Ports the rotating hero background images, indigo full-bleed hero, screenshot bridge, and updated metrics (red styling) from src/index.njk into the native Nuxt homepage; 11ty source remains deleted.
…dbus categories - Render the TL;DR / first-answer block on blog posts (tldr frontmatter now captured in blog.index.json) and show author job titles + 'Updated' date label, matching the upstream post layout. - Add plc/mqtt/opcua/modbus to the blog category map so /blog/<cat>/ routes generate natively (the new upstream category landing pages), and wire the 'See All PLC Articles' button on the PLC landing page.
- Docs/handbook: sidebar is now a collapsible disclosure below lg and the two-column layout shifts from md to lg, so tablet (768px) no longer overflows and mobile no longer dumps the full nav tree above the content. - Cap all <img> to their container width (ported pages hardcoded style=max-width:NNNpx without width:100%, overflowing narrow viewports); also fixed the it-ot-middleware architecture diagram. - Normalise blog card/hero image paths to absolute in copy_blog.js so relative frontmatter image refs stop 404ing on index/category pages.
- Make code fences and wide tables scroll within prose instead of forcing the page wider on mobile/tablet (no max-width was set, so long lines overflowed). - Restore the docs landing-page tile grids (offering + product-feature): the 11ty CSS for these classes was lost in migration, leaving inert grid-cols utilities and unstyled stacked text; re-add a responsive card grid.
…ile/tablet The native docs/handbook pages lay out their own 3 columns on an inner wrapper, but the outer div still carried the legacy .handbook class whose flex/grid container (built for the old 11ty direct-child DOM) sized its flex item to content min-width — forcing ~920px and overflowing narrow viewports (545px at 375px). Scope a .handbook-shell override to render those wrappers as plain blocks; node-red and the legacy-static pages keep .handbook unchanged.
Blank lines inside the ff-*-tiles HTML containers (left by multi-line inline SVGs) terminated the markdown HTML block, so the indented continuation rendered as <pre> blocks of raw HTML on the docs landing page. Strip blank lines within those containers during content generation so they render as the card grid.
Picks up the new upstream blog posts/tags (incl. NIS2), the new category routes, TL;DR + absolute card-image paths from copy_blog, and the upstream mqtt-in docs update; reconciles package-lock.json after the rebase.
…ication scripts/responsive-check.js: scripted 4-viewport Playwright sweep (no MCP) with overflow detection. Updates the committed route-diff evidence (Dropped: 0) and documents this session in migration/STATUS.md.
The [dev] command still launched 'npx @11ty/eleventy --watch', whose engine
was deleted in the 11ty teardown, so 'netlify dev' would fail. Point it at the
Nuxt dev server ('npm run dev', port 3000) and drop the stale Eleventy labels
on the image cache paths (the paths themselves are still valid).
- nuxt.config.ts: drop the hardcoded '<sprite>.sprites.app' Vite allowedHosts
entry. Replace with a $development-only allowlist sourced from the
NUXT_DEV_ALLOWED_HOSTS env var, so nothing host-specific ships in the repo
while devs behind a proxy can still allowlist their host.
- scripts/responsive-check.js, scripts/visual-check.js: resolve Playwright via
a normal require('playwright') (with an install hint) instead of hardcoded
/home/sprite npx-cache paths; make the Chrome executable opt-in via CHROME_PATH
rather than a hardcoded /usr/bin path. Add qa:responsive / qa:smoke npm
aliases so they are discoverable dev-QA tools.
- README.md: rewrite the repo-structure, dev-server, and build sections to describe the single Nuxt 4 static build (src/ is now a data source only). Remove the Strangler-Fig / 11ty-proxy / port-8080 / 'legacy-only mode' text and the Nuxt 3 references. - .claude/CLAUDE.md: replace the deleted layouts/*.njk references (per content type + the Layouts table) with the Nuxt pages/components that render each section; drop the removed eleventyComputed.js data row. - migration/README.md: note the frozen baseline is 1178 routes; 'hybrid' -> static. - migration/PR_DESCRIPTION.md: new — what changed, why, dev/build commands, verification gates, and deferred items.
- VERIFICATION.md: rewrite as the final-state record (1178->1186 routes, Dropped 0; link-checker 0 of 1180 failing; responsive sweep 60 captures, 0 overflow) instead of the stale 1069-route handbook-increment snapshot. - STATUS.md: add a 'FINAL STATE (PR-ready)' summary at the top with the current numbers, and flag the running log below as historical so the older interim counts don't read as current.
Vues default transformAssetUrls rewrites <img src="/foo.png"> into a Rollup import. Under SKIP_IMAGES (which test.yml runs via build:nuxt:skip-images), copy_assets skips populating nuxt/public, so the imports cannot resolve and the production build errors out. Setting vite.vue.template.transformAssetUrls .includeAbsolute=false leaves root-relative URLs as plain runtime paths served from public/. Production deploys (no SKIP_IMAGES) and the prior local builds were unaffected; this only matters when the assets directory is empty at build time.
11tys addPassthroughCopy ran unconditionally regardless of SKIP_IMAGES; the flag only disabled the @11ty/eleventy-img optimization step. The post-migration copy_assets.js conflated the two, gating the raw copy on SKIP_IMAGES and producing a build output with no images at all in test mode. This broke CIs static link checker (4788 missing image refs). Drop the SKIP_IMAGES gate so raw originals always copy. The flag becomes a no-op here, reserved as a placeholder for any future image-optimization pipeline (e.g. @nuxt/image) where it would once again mean disable optimization, not skip copying.
The Netlify _redirects body extraction stripped frontmatter with a \n-only delimiter; allow an optional \r so CRLF-checked-out source still produces a clean _redirects file.
@nuxt/content (via MDC) slugs heading ids with github-slugger, which strips
'?', ':', '(', ')', '&', periods and emoji. The legacy 11ty build used
markdown-it-anchor's default slugify, which keeps them, and the prose anchors
throughout the docs/handbook/blog were written against that form (e.g.
'#what-is-udp?', '#ideal-customer-profile-(icp)', '#🔁-iterative-improvement').
Add an mdc.config.ts that injects a rehype plugin (before MDC's compileHast, so
the id flows to both the heading and the generated TOC) re-deriving each heading
id with the markdown-it-anchor algorithm in raw, non-percent-encoded form so it
is byte-identical to the raw href fragments authors wrote.
…ernal links) Reproduce 11ty's internal-link rewriting (*/abc.md#x -> */abc/#x, */README#x -> */#x) for the absolute links that the per-collection copy scripts don't touch — chiefly the externally-synced docs and handbook, including raw-HTML <a> tags that the markdown-only copy regexes never matched. Done as a rehype plugin so it covers every collection and both markdown- and HTML-authored links in one place, adding the directory trailing slash the routes expect (while leaving real file extensions like .zip/.pdf alone).
… links) 11ty left blog relative links untouched and let the browser resolve them against the page URL; @nuxt/content resolves them against the page path treated as a file, dropping a segment, so '../../04/foo' in a /blog/2022/05/<post>/ page became '/blog/04/foo' (year lost) instead of '/blog/2022/04/foo'. Resolve relative link targets in copy_blog.js against the post's rendered URL (a directory) before the content is handed to @nuxt/content, matching 11ty.
Port 11ty's fuzzy anchor-repair: rewrite broken in-page '#fragment' links to the real heading id they were meant to reach. Generalises 11ty's period-only match to a normalised (lower-cased, alphanumerics-only) match so it also bridges the residue MDC's id post-processing introduces that the slugger plugin can't pre-empt (collapsed consecutive dashes, leading-digit '_' prefix). Cross-page aware and only ever touches already-broken links. Wired into both build chains between 'prod:nuxt' and 'sitemap'.
…headings
The mdc.config.ts file-discovery produced an empty #mdc-configs template in this
setup, so the slugger/link plugins never ran. Register them instead through
content.build.markdown.rehypePlugins as in-process `instance` functions (which
@nuxt/content resolves directly), and drop the dead mdc.config.ts. Also honor an
explicit trailing {#custom-id} on a heading (11ty's markdown-it-attrs), using it
as the id and stripping it from the visible text.
@nuxt/content resolves relative links against the page path treated as a file, dropping a path segment (e.g. ./mcp-tool from .../mcp/index -> /node-red/flowfuse/ mcp-tool instead of .../mcp/mcp-tool). 11ty resolved them relative to the source file (its rewriteHandbookLinks ../-prepend made that equivalent). Resolve every relative internal link (with or without .md, README/index -> directory) against the source dir at copy time, for markdown and raw-HTML <a> links alike, so @nuxt/content never sees a relative link to mis-resolve.
The blog relative-link resolver treated a malformed `lhttps:` typo URL as a relative path (new URL parsed the scheme), turning an externally-ignored link into a broken internal one. Skip any target carrying a URI scheme. Regenerated blog content reflects the dimension-3 relative-link resolution.
A webinar linked a sibling .zip via a relative path; it was neither resolved nor published. Resolve relative downloadable assets at copy time and serve them from /events-media/, alongside the existing image handling.
Cross-page anchor links arrive percent-encoded (e.g. ':' -> '%3A'); decode the fragment before matching so it can be reconciled with the literal heading id.
The first/last-page Prev/Next links were only hidden with opacity, leaving a broken href (e.g. /blog/20, /changelog/10) in the DOM for the link checker to flag. Render them with v-if so the hidden link is not emitted at all.
Completes the registration the prior commit intended: nuxt.config.ts registers
the anchor-slugs + strip-internal-md rehype plugins via
content.build.markdown.rehypePlugins (instance functions), and anchor-slugs.mjs
honors explicit {#custom-id} headings. (These were inadvertently left unstaged
when the mdc.config.ts removal was committed.)
After the renderer-alignment work changed copy_node_red.js/copy_blog.js and
related rehype plugins, regenerated content now matches the upstream source
files in src/node-red and src/blog. The dropped mqtt Dynamic-Control-Operations
sections never existed in src/node-red/flowfuse/mqtt/mqtt-{in,out}.md (verified
absent on origin/main too), so the regeneration corrects hallucinated content
from an earlier interim copy run. integrations.index.json reflects fresh npm
download counts.

Description
Completes the 11ty to Nuxt 4 migration for the marketing website. Every existing URL is preserved (route-parity gate enforced), and the production build is now pure
nuxt generate.@nuxt/contentcollections..eleventy.js,src/11ty templating, the legacy proxy middleware, and the 11ty build steps inpackage.jsonare gone.FlowFuse/flowfuseviascripts/copy_docs.js(contract unchanged); a newscripts/copy_docs_nuxt.jsstep generatesnuxt/content/docs/*at build time.npm run build:nuxt(runs the copy scripts thennuxt generate).netlify.tomlalready points atnuxt/.output/public.migration/routes-11ty.txt;migration/verify-routes.shconfirmsDropped: 0(1186 routes, a superset of the 1178 baseline including upstream additions like the new blog categories and the NIS2 post).nuxt-link-checker: 0 of 1180 failing.Full per-cluster notes, the verification harness, the route diff, and the long-form summary live under
migration/(seemigration/PR_DESCRIPTION.md).Related Issue(s)
Closes #4867
Checklist