Migrate website from 11ty to Nuxt 4#5091
Conversation
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).
Convert the three gated whitepaper pages to a single data-driven whitepaper/[...slug].vue reproducing layouts/whitepaper-gated.njk, reusing SocialProof + HubSpotForm. /whitepaper is now a Nuxt-owned proxy prefix. Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, all three Nuxt-rendered (uns whitepaper visually confirmed: hero, social-proof logos, sticky download form).
Replicate the 3 job-posting redirect stubs (JS window.location.replace to external greenhouse postings) as a data-driven jobs/[...slug].vue. Only the engineering-manager opening has a live URL; the other two mirror the unset site.openings keys. noindex preserved. /jobs is a Nuxt-owned proxy prefix. Verified: build green, link-checker 0/0, route diff 0 dropped, all three Nuxt-rendered.
…mponent Convert /partners/ (index with cloud/hardware/solutions partner grids), certify-hardware, ctrlx, and referral-sign-up to native Vue. Add reusable Icon.vue + scripts/copy_icons.js (copies the 119 legacy icon SVGs into the Nuxt project so a Vite glob can inline them by name) to replace per-page icon inlining. referral-sign-up reuses HubSpotForm. /partners is a Nuxt-owned proxy prefix. Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, all four Nuxt-rendered (certify-hardware visually confirmed with icons).
careers + sign-up (redirects), email-signature (static), free-consultation (form + social proof), and contact-us + book-demo (new shared MqlContact component reproducing layouts/mql-contact.njk, with HubSpotMeeting embed for the demo scheduler). Reuses Icon, SocialProof, HubSpotForm. Added each as an exact NUXT_ROUTES entry + prerender route. Fixed a nested-<p> hydration mismatch on contact-us. Verified: build green, prerender 0 errors, link-checker 0/0, route diff 0 dropped, all six Nuxt-rendered (contact-us visually confirmed).
Two clean form pages: education (hero + scroll-to-form + HubSpot contact form) and professional-services (centered hero + two service rows + HubSpot form), reusing HubSpotForm. Added as exact NUXT_ROUTES + prerender routes. Verified: build green, link-checker 0/0, route diff 0 dropped, both Nuxt-rendered.
Centered hero + community/chat cards + submit-a-ticket HubSpot form. The Algolia search box and HubSpot chat widget depend on site-wide scripts not yet ported, so they degrade gracefully (empty search container, chat button no-ops) — noted as fidelity gaps; the ticket form and route are fully functional. Verified: build green, link-checker 0/0, route diff 0 dropped, Nuxt-rendered.
The hybrid 11ty passthrough has been replaced by native Nuxt asset/sitemap build steps, so the production build is now plain build:nuxt (nuxt generate). Removes the @11ty/* dev/build scripts and the eleventy plugin devDependencies that are no longer invoked.
…dev proxy
The site is now generated entirely by Nuxt, so the 11ty engine and its
templating are dead code. Deletes .eleventy.js, the lib/ helpers it used, the
dev-only legacy proxy middleware, src/_data/eleventyComputed.js, and the
.njk/_includes templates 11ty rendered.
Keeps the handful of src/ files the Nuxt copy_* build scripts still read as
data: src/redirects.njk (static _redirects body), src/node-red/index.njk +
core-nodes/index.njk (FAQ + catalog index parsed by copy_node_red), and
src/_includes/{components/icons,core-nodes,hardware} (icons + node-red markdown
includes). All content markdown, _data, and static assets are retained as
build inputs.
The prerender routes are slash-less, so SSR matched route.params.id cleanly, but the client loads the canonical trailing-slash URL, which gives the catch-all param a trailing empty segment. The naive join produced "<id>/", so the indexData.find() missed and the page threw a 404 on hydration — tearing down the server-rendered content. Filter empty segments before joining, matching the .filter(Boolean) pattern already used by the other data-driven [...slug] pages.
eleventy-img was only used by the deleted lib/image-handler.js (11ty image pipeline); no remaining script or the Nuxt app imports it. Removing it prunes its exclusive transitive deps from the lockfile. @11ty/eleventy-fetch is kept — copy_integrations.js and copy_node_red.js use it as a build-time fetch cache.
Relative markdown/HTML image refs in generated content rendered verbatim and 404'd (the browser resolved them against the page URL). Three copy-script gaps caused it: markdown image titles with embedded quotes broke the blog regex; customer-stories and node-red core-node use-case images were resolved against the wrong base dir; and docs/handbook HTML <img> tags were never matched by the markdown-only regexes. Add scripts/normalize_content_images.js, a build step (wired into both build chains after the content copy scripts) that rewrites every still-relative image ref to the absolute path copy_assets publishes it at, resolving file-dir-first then src/ root as 11ty's image-handler did. Rewrites 95 refs in 36 files; intentional placeholders in handbook how-to guides are left untouched.
The content globs only scanned the deleted 11ty src/**/*.njk templates and .eleventy.js, never nuxt/**/*.vue. @layer components classes now used only in Nuxt components (notably .ff-nav-dropdown) were purged, so the header mega-menu rendered fully expanded on every page and pricing feature tables showed inactive content. Add the Nuxt app paths (components/layouts/pages, app vue, content markdown) to the content array. Compiled style.css grows 121KB -> 183KB and the nav renders correctly again.
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.
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. |
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.

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