Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ path in a comment names what a page used to be, not a file that still exists.
other repositories at build time (product docs, the Blueprint Library)
- **Files served as-is**: `nuxt/public/`, a tracked asset tree rather than build output
- **CSS**: two Tailwind v4 builds on every page: `nuxt/assets/css/style.css`, compiled by PostCSS to `nuxt/public/css/style.css`, and `nuxt/assets/css/theme.css`, bundled by Nuxt and loaded second
- **Search**: Algolia (`scripts/index-algolia.js`), indexed from the built output
- **Search**: Algolia (`scripts/index-algolia.mjs`), indexed from the built output
- **Hosting**: Netlify, from `nuxt/dist`

### Production build order
Expand Down Expand Up @@ -81,8 +81,7 @@ nuxt/
├── composables/
├── utils/
└── pages/
scripts/ # sync_docs.mjs, sync_blueprints.mjs, their watchers, index-algolia.js
lib/ # search-index.js, shared with scripts/index-algolia.js
scripts/ # sync_docs.mjs, sync_blueprints.mjs, their watchers, index-algolia.mjs
```

---
Expand Down
2 changes: 1 addition & 1 deletion .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{
"name": "nuxt",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=nuxt", "dev"],
"runtimeArgs": ["run", "dev:nuxt"],
"port": 3000,
"autoPort": true
}
Expand Down
38 changes: 28 additions & 10 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
.DS_Store
node_modules
.env

# Nuxt build output
nuxt/.nuxt/
nuxt/.output/
nuxt/dist/
nuxt/.data/

# Local Netlify folder
.netlify

# Synced at build time by nuxt/modules/docs-source.ts (from FlowFuse/flowfuse) and
# nuxt/modules/blueprints-source.ts (from FlowFuse/blueprint-library).
nuxt/content/docs/
# Synced from FlowFuse/blueprint-library by nuxt/modules/blueprints-source.ts.
nuxt/public/docs/
nuxt/content/blueprints/
nuxt/public/blueprints/

# nuxt/public/ is a tracked asset tree. Only these parts of it are generated during a build:
nuxt/public/css/
nuxt/public/js/cc.min.js
nuxt/public/js/flowrenderer.min.js

# Left in existing checkouts by the retired Eleventy build: its output and its
# eleventy-fetch cache. Nothing writes these any more.
# Left in existing checkouts by the retired Eleventy build: its output, its eleventy-fetch
# cache, and what it wrote into nuxt/public/. Nothing writes these any more.
_site
.cache
nuxt/public/img/
nuxt/public/_includes/
nuxt/public/sitemap-legacy.xml
nuxt/public/js/ai-expert-modal.js
nuxt/public/js/hm-promo-banner.js

# Local development config
.vscode/

.env

# Local Netlify folder
.netlify


deno.lock
deno.lock
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ schedule to pick up blueprint-library changes — either way it just calls a Net

## Repository structure

This repository is an **npm workspace**. The whole site is one [Nuxt 4](https://nuxt.com/) application in `nuxt/`:
The whole site is one [Nuxt 4](https://nuxt.com/) application in `nuxt/`, with a single `package.json` at the repository root:

| Directory | Purpose |
|-----------|---------|
Expand All @@ -28,7 +28,7 @@ This repository is an **npm workspace**. The whole site is one [Nuxt 4](https://
| `nuxt/assets` | Stylesheets, the two icon sets (`icons/` for `<UIcon>`, `nav-icons/` for `<NavIcon>`), the cookie-consent source |
| `nuxt/lib`, `nuxt/server/lib` | Plain-JS helpers with `node --test` unit tests |
| `scripts/` | Build-time syncs for the two external content sources, and the Algolia indexer |
| *(root)* | The workspace: npm scripts, Tailwind and PostCSS config, Netlify config |
| *(root)* | `package.json` (dependencies and npm scripts), PostCSS config, Netlify config |

The site was previously generated by [Eleventy](https://www.11ty.dev/) and was migrated to Nuxt
page group by page group, using the [Strangler Fig pattern](https://martinfowler.com/bliki/StranglerFigApplication.html).
Expand All @@ -55,7 +55,7 @@ is a reference to what a page used to be.

## Running locally

Clone the repository, then install all dependencies (workspace packages are included automatically):
Clone the repository, then install all dependencies:

```bash
npm install
Expand Down Expand Up @@ -127,7 +127,7 @@ Every build resolves blueprints in this order, and logs which one it used:
| 3 | A clone, authenticated with a minted GitHub App installation token (`GH_BOT_APP_ID`/`GH_BOT_APP_KEY`) | Nothing above applied, and those env vars are set. This is what Netlify production deploys use — `blueprint-library` is private, so this can't clone anonymously the way docs does. |
| 4 | Skipped | Nothing above applied. Matches the previous behaviour for contributors without access to the private repo. |

`npm run blueprints` runs that resolution on its own, writing `nuxt/content/blueprints` and `nuxt/public/blueprints`, both gitignored. `npm run dev` re-runs it whenever the resolved source changes (`scripts/watch_blueprints.js`), but only when it found a source to watch in the first place — no sibling checkout means no watching.
`npm run blueprints` runs that resolution on its own, writing `nuxt/content/blueprints` and `nuxt/public/blueprints`, both gitignored. `npm run dev` re-runs it whenever the resolved source changes (`scripts/watch_blueprints.mjs`), but only when it found a source to watch in the first place — no sibling checkout means no watching.

## llms.txt

Expand Down
27 changes: 0 additions & 27 deletions nuxt/.gitignore

This file was deleted.

6 changes: 3 additions & 3 deletions nuxt/assets/css/style.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@layer vendor, theme, base, components, utilities;

@config "../../../tailwind.config.js";
@config "../../tailwind.config.js";
@import "tailwindcss";
/* Utility classes for the nav and footer live in this data file rather than in
a template, so name it explicitly: automatic source detection skips JSON. */
Expand Down Expand Up @@ -117,8 +117,8 @@
@import "./roadmap.css";
@import "./zoom-style.css";
@import "./node-red-style.css";
@import "../../../node_modules/@flowforge/forge-ui-components/dist/forge-ui-components.css" layer(vendor);
@import "../../../node_modules/vanilla-cookieconsent/dist/cookieconsent.css" layer(components);
@import "@flowforge/forge-ui-components/dist/forge-ui-components.css" layer(vendor);
@import "vanilla-cookieconsent/dist/cookieconsent.css" layer(components);

/* forge-ui-components resets bare h1-h3 for its own standalone docs site.
Neutralize within the vendor layer itself (source order wins ties inside
Expand Down
2 changes: 1 addition & 1 deletion nuxt/components/AlgoliaSearch.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
// Algolia autocomplete over the shared `prod_netlify` index. The index is built by
// scripts/index-algolia.js, which scans nuxt/dist after the Nuxt build and derives
// scripts/index-algolia.mjs, which scans nuxt/dist after the Nuxt build and derives
// `category` from the first path segment, so /handbook/* and /docs/* are both covered.
const props = withDefaults(defineProps<{
/** Algolia filter expression, e.g. `category:docs`. Omit to search everything. */
Expand Down
3 changes: 1 addition & 2 deletions nuxt/lib/chrome.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { REPO_ROOT as repo } from './repo-root.mjs'

const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const chrome = JSON.parse(readFileSync(join(repo, 'nuxt/data/chrome.json'), 'utf8'))

const navLinks = chrome.header.dropdowns.flatMap(d => d.columns.flatMap(c => c.links))
Expand Down
3 changes: 1 addition & 2 deletions nuxt/lib/guides-frontmatter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { findDuplicateSiblingNavTitles, findPagesWithUnreachableTitle } from './guides-frontmatter.mjs'
import { GUIDES_SOURCE } from './guides-sync.mjs'
import { isDirectory } from './meta-title-length.mjs'
import { REPO_ROOT as repoRoot } from './repo-root.mjs'

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const guidesDir = join(repoRoot, GUIDES_SOURCE)

function scratch (files) {
Expand Down
10 changes: 9 additions & 1 deletion nuxt/lib/guides-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,15 @@ export function writeGuideFile ({ guidesDir, repoRoot, contentDocsDir, publicDoc
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.
const updated = gitOutput(repoRoot, ['log', '-1', '--pretty=format:%ci', '--', sourcePath])
//
// The newest commit that added or changed the file, followed back through renames. A
// move that leaves the file untouched (-M100% makes it an R100) is filtered out, so
// moving the guides tree does not stamp every guide with the date of the move. A move
// that also edited the file is not detected as a rename at 100%, so it shows up as an
// add and dates the file, as an edit should.
const updated = gitOutput(repoRoot, [
'log', '-1', '--follow', '-M100%', '--diff-filter=AM', '--pretty=format:%ci', '--', sourcePath,
])

const raw = readFileSync(srcPath, 'utf8')
writeFileSync(destPath, injectFrontmatter(raw, {
Expand Down
39 changes: 38 additions & 1 deletion nuxt/lib/guides-sync.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test } from 'node:test'
import { execFileSync } from 'node:child_process'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
Expand All @@ -10,6 +11,7 @@ import {
injectFrontmatter,
listGuideFiles,
syncGuides,
writeGuideFile,
} from './guides-sync.mjs'

const silent = { info: () => {}, warn: () => {}, error: () => {} }
Expand Down Expand Up @@ -88,7 +90,7 @@ test('the whole guides tree lands in the docs content tree, stamped with an edit

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.ok(index.includes(`editUrl: https://github.com/FlowFuse/website/edit/main/${GUIDES_SOURCE}/application-guide/README.md\n`))
assert.match(index, /title: Guide/)
assert.ok(readFileSync(join(contentDocsDir, 'application-guide/architectures/it.md'), 'utf8'))
assert.equal(readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'), 'utf8'), '<svg/>')
Expand Down Expand Up @@ -139,3 +141,38 @@ test('listGuideFiles walks nested directories and skips dotfiles', () => {
cleanup()
}
})

test('updated is the last change to a guide, not the commit that moved it', () => {
const { root, contentDocsDir, publicDocsDir, cleanup } = scratch()
const git = (date, ...args) => execFileSync('git', args, {
cwd: root,
env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
stdio: ['ignore', 'pipe', 'ignore'],
})
const commit = (date, message) => git(date, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message)
try {
git('2026-01-01T00:00:00Z', 'init', '-q')
write(join(root, 'old', 'a.md'), '# A\n')
write(join(root, 'old', 'b.md'), '# B\n')
git('2026-01-01T00:00:00Z', 'add', '-A')
commit('2026-01-01T00:00:00Z', 'add')
write(join(root, 'old', 'a.md'), '# A, edited\n')
git('2026-02-01T00:00:00Z', 'add', '-A')
commit('2026-02-01T00:00:00Z', 'edit a')
// Move the tree to where GUIDES_SOURCE says the guides live now, editing b on the way.
mkdirSync(dirname(join(root, GUIDES_SOURCE)), { recursive: true })
git('2026-03-01T00:00:00Z', 'mv', 'old', GUIDES_SOURCE)
write(join(root, GUIDES_SOURCE, 'b.md'), '# B, edited while moving\n')
git('2026-03-01T00:00:00Z', 'add', '-A')
commit('2026-03-01T00:00:00Z', 'move')

const updated = relPath => {
const dest = writeGuideFile({ guidesDir: join(root, GUIDES_SOURCE), repoRoot: root, contentDocsDir, publicDocsDir, relPath })
return readFileSync(dest, 'utf8').match(/^updated: (\S+)/m)?.[1]
}
assert.equal(updated('a.md'), '2026-02-01', 'a pure move keeps the date of the last edit')
assert.equal(updated('b.md'), '2026-03-01', 'a move that also edited the file counts as the edit')
} finally {
cleanup()
}
})
6 changes: 2 additions & 4 deletions nuxt/lib/mdc-bindings.test.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join, relative } from 'node:path'

import { findFiles } from './find-files.mjs'
import { GUIDES_SOURCE } from './guides-sync.mjs'
import { isDirectory } from './meta-title-length.mjs'
import { bindableMustaches, unclosedMdcBlocks } from './mdc-bindings.mjs'

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
import { REPO_ROOT } from './repo-root.mjs'

// The trees this repository owns and always has in a checkout. FlowFuse/flowfuse's own
// docs go through the same MDC pipeline and the same function checks them, but they are
Expand Down
5 changes: 2 additions & 3 deletions nuxt/lib/meta-title-length.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { join } from 'node:path'

import { MAX_META_TITLE_LENGTH, findOverlongMetaTitles, isDirectory } from './meta-title-length.mjs'
import { REPO_ROOT as repoRoot } from './repo-root.mjs'

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const blogDir = join(repoRoot, 'nuxt/content/blog')
const changelogDir = join(repoRoot, 'nuxt/content/changelog')

Expand Down
5 changes: 2 additions & 3 deletions nuxt/lib/node-red-help.test.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join, relative } from 'node:path'

import { findFiles } from './find-files.mjs'
import { GUIDES_SOURCE } from './guides-sync.mjs'
import { isDirectory } from './meta-title-length.mjs'
import {
extractHelp, helpRefsIn, helpUrlFor, isSafeHelpRef, sanitiseHelp, unclaimedHelpNames,
} from './node-red-help.mjs'
import { REPO_ROOT } from './repo-root.mjs'

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
const GUIDES_DIR = join(REPO_ROOT, GUIDES_SOURCE)

const block = (name, body) => `<script type="text/html" data-help-name="${name}">${body}</script>`
Expand Down
5 changes: 2 additions & 3 deletions nuxt/lib/redirects-node-red.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@
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 { join } from 'node:path'

import { GUIDES_SOURCE, listGuideFiles } from './guides-sync.mjs'
import { REPO_ROOT as repoRoot } from './repo-root.mjs'

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const read = rel => readFileSync(join(repoRoot, rel), 'utf8')

const withSlashes = path => '/' + path.split('/').filter(Boolean).join('/') + '/'
Expand Down
20 changes: 20 additions & 0 deletions nuxt/lib/repo-root.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

// The root of the checkout: the closest directory at or above `start` that holds
// package-lock.json. Found rather than written as a fixed run of `..`, so a caller keeps
// working when the file it lives in moves up or down a level. Closest rather than
// furthest, so a git worktree nested inside another checkout (.claude/worktrees/)
// resolves to itself and not to the checkout around it.
export function findRepoRoot (start) {
let dir = start
while (!existsSync(join(dir, 'package-lock.json'))) {
const parent = dirname(dir)
if (parent === dir) throw new Error(`No package-lock.json at or above ${start}`)
dir = parent
}
return dir
}

export const REPO_ROOT = findRepoRoot(dirname(fileURLToPath(import.meta.url)))
27 changes: 27 additions & 0 deletions nuxt/lib/repo-root.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { findRepoRoot } from './repo-root.mjs'

test('findRepoRoot returns the closest directory holding package-lock.json', (t) => {
const root = mkdtempSync(join(tmpdir(), 'repo-root-'))
t.after(() => rmSync(root, { recursive: true, force: true }))
// An outer checkout with a worktree nested inside it, as .claude/worktrees/ does.
const worktree = join(root, '.claude', 'worktrees', 'topic')
mkdirSync(join(worktree, 'nuxt', 'lib'), { recursive: true })
writeFileSync(join(root, 'package-lock.json'), '{}')
writeFileSync(join(worktree, 'package-lock.json'), '{}')

assert.equal(findRepoRoot(join(worktree, 'nuxt', 'lib')), worktree)
assert.equal(findRepoRoot(worktree), worktree)
assert.equal(findRepoRoot(join(root, '.claude')), root)
})

test('findRepoRoot fails loudly when there is no package-lock.json above', (t) => {
const root = mkdtempSync(join(tmpdir(), 'repo-root-'))
t.after(() => rmSync(root, { recursive: true, force: true }))
assert.throws(() => findRepoRoot(root), /No package-lock.json/)
})
Loading
Loading