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
9 changes: 5 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The site is migrating from Eleventy (11ty) to Nuxt 3. Nuxt is the primary framew
| Section | Status |
|---------|--------|
| `/handbook/**` | **Migrated** — served by Nuxt (`nuxt/content/handbook/`) |
| `/docs/**` | **Migrated** — served by Nuxt; source cloned from `flowfuse/flowfuse` at build time |
| `/docs/**` | **Migrated** — served by Nuxt; source resolved from `flowfuse/flowfuse` at build time |
| All other routes | Still on 11ty, proxied through Nuxt in dev |

### Production build order
Expand All @@ -26,7 +26,7 @@ The site is migrating from Eleventy (11ty) to Nuxt 3. Nuxt is the primary framew
clean:nuxt → build:js:nuxt → prod:postcss-nuxt → prod:eleventy-nuxt → prod:nuxt
```

The `docs-source` Nuxt module runs automatically during `prod:nuxt` and sparse-clones `docs/` from `flowfuse/flowfuse` (public repo, no token needed). 11ty outputs to `nuxt/public/` so Nuxt can serve 11ty-generated assets. `nuxt/public/` is gitignored (fully build-generated).
The `docs-source` Nuxt module runs automatically during `prod:nuxt` and calls `nuxt/lib/docs-sync.mjs` to resolve `docs/` from `flowfuse/flowfuse` (see **Local docs development** below). 11ty outputs to `nuxt/public/` so Nuxt can serve 11ty-generated assets. `nuxt/public/` is gitignored (fully build-generated).

## Dev commands

Expand All @@ -35,12 +35,13 @@ npm start # all watchers in parallel (11ty + nuxt + postcss + bluep
npm run dev # eleventy + postcss + nuxt only
npm run dev:eleventy # 11ty only, port 8080 (legacy; most work doesn't need this)
npm run dev:nuxt # Nuxt only, port 3000 — use this for handbook, docs, and migrated pages
npm run docs # resolve product docs into nuxt/content/docs, no build
npm run build # production build
```

> When working on the handbook, docs, or other migrated sections, `npm run dev:nuxt` is sufficient. `npm start` is only needed when also touching 11ty-served pages.
>
> **Local docs development:** set `FLOWFUSE_DOCS_LOCAL=/path/to/flowfuse` to point the docs module at a local checkout instead of cloning from GitHub. If the env var is not set and `nuxt/content/docs/` already exists, that cached copy is used. If neither is true, the module clones fresh from GitHub (public, no token needed).
> **Local docs development:** a checkout of `flowfuse/flowfuse` sitting next to this repo (`../flowfuse`) is picked up automatically, with no configuration. Full resolution order, which every build logs: `FLOWFUSE_DOCS_LOCAL` (explicit path, and a path that does not exist is an error), then a sibling checkout, then the snapshot committed to `live` when `FLOWFUSE_DOCS_SNAPSHOT` is set (Netlify only), then a clone of `FLOWFUSE_DOCS_REF` (default `main`). CI relies on the sibling rule: `FlowFuse/flowfuse`'s `Publish Documentation` workflow checks itself out next to the website so a docs PR is validated against its own changes.

## Directory layout

Expand All @@ -61,7 +62,7 @@ nuxt/
│ ├── handbook/ # Handbook pages (edit here)
│ └── docs/ # Product docs (build-generated, gitignored — do not edit)
├── modules/
│ └── docs-source.ts # Clones docs from flowfuse/flowfuse at build time
│ └── docs-source.ts # Wires docs into Nuxt; resolution lives in nuxt/lib/docs-sync.mjs
├── composables/
│ ├── useHandbookNav.ts
│ └── useDocsNav.ts
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: 'website'
- name: Check out FlowFuse/flowfuse repository (to access the docs)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: 'FlowFuse/flowfuse'
ref: main
path: 'flowfuse'
# Full history: each docs page is dated from its own last commit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hold on, we shouldn't need this. We're not doing a shallow clone?

fetch-depth: 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do a partial clone, not a shallow one.

- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
Expand All @@ -37,8 +45,17 @@ jobs:
node-version: 24
cache: 'npm'
cache-dependency-path: './website/package-lock.json'
- run: npm run docs
working-directory: 'website'
- run: npm run blueprints
working-directory: 'website'
- name: Commit Latest Docs
run: |
cd ./website
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add nuxt/content/docs nuxt/public/docs -A -f

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, please don't. Why not clone on build time? You're reverting to do everything that wasn't great about this previously

git commit -a -m "Bot: update docs"
- name: Commit Latest Blueprints
run: |
cd ./website
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
This repository contains the source of the FlowFuse website.

It is hosted on Netlify with each commit to the `main` branch being automatically deployed to the live site.
This works by a GitHub action automatically updating the `live` branch to includes documentation pulled from the `main` branch of the [FlowFuse/flowfuse](https://github.com/FlowFuse/flowfuse)
repository, when changes are pushed to `main`.
This works by the [Build Site](.github/workflows/build.yml) action updating the `live` branch, committing onto it the
product documentation pulled from the `main` branch of [FlowFuse/flowfuse](https://github.com/FlowFuse/flowfuse).

Netlify is then configured to watch the `live` branch for any changes, once detected, it will automatically pull the contents of this branch (docs included) and deploy to our production site.

Expand Down Expand Up @@ -95,6 +95,23 @@ The documentation for FlowFuse is maintained in the core [FlowFuse repo](https:/

The `npm run dev` (and `npm start`) commands will retrieve the documentation from that folder and inject them into the site automatically. The docs will be available at http://localhost:3000/docs.

Nothing needs configuring for that to happen. Every build resolves the docs in this order, and logs which one it used:

| Order | Source | Used when |
|-------|--------|-----------|
| 1 | `FLOWFUSE_DOCS_LOCAL=/path/to/flowfuse` | The env var is set. A path that does not exist is an error, not a fallback. |
| 2 | A sibling checkout: `../flowfuse`, `../flowforge` or `../dev-env/packages/flowfuse` | One of those has a `docs/` directory. This is what CI relies on. |
| 3 | The snapshot committed to `live` | `FLOWFUSE_DOCS_SNAPSHOT` is set, which Netlify does. Production deploys never clone. |
| 4 | A clone of `FLOWFUSE_DOCS_REF` (default `main`) | Nothing above applied. |

`npm run docs` runs that resolution on its own, without a full build, writing `nuxt/content/docs` and `nuxt/public/docs`. Both are generated, and neither is committed on `main`.

If the docs and handbook pages fail to render locally while the rest of the site is fine, you are hitting [nuxt#35253](https://github.com/nuxt/nuxt/issues/35253). Give the build its own temp directory:

```bash
export TMPDIR=/tmp/nuxt
```

## How to add blog posts

See the [Blog section of the Marketing Handbook](https://flowfuse.com/handbook/marketing/content-strategy/blog/) for instructions on writing and publishing blog posts.
Expand All @@ -109,7 +126,10 @@ To make a documentation update *and* make it live on the website:

1. PR the documentation update to the `main` branch of [FlowFuse/flowfuse](https://github.com/FlowFuse/flowfuse)
2. Get the PR reviewed and merged in the normal manner.
3. Manually kick-off a website rebuild by clicking 'Run workflow' on [this page](https://github.com/FlowFuse/website/actions/workflows/build.yml).

That repository's `Publish Documentation` workflow builds this site against the PR's docs before it can merge, then
triggers a website rebuild once it lands. A rebuild can also be started by hand with 'Run workflow' on
[this page](https://github.com/FlowFuse/website/actions/workflows/build.yml).

## Acknowledgements

Expand Down
3 changes: 3 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ publish = "nuxt/dist"

[build.environment]
NODE_OPTIONS = "--max-old-space-size=4096"
# Deploy the docs snapshot the build workflow commits to 'live' instead of cloning
# flowfuse at deploy time. Branches carrying no snapshot fall back to cloning.
FLOWFUSE_DOCS_SNAPSHOT = "1"

[functions]
directory = "nuxt/.netlify/functions-internal"
Expand Down
197 changes: 197 additions & 0 deletions nuxt/lib/docs-sync.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Resolves the FlowFuse product docs for a build and copies them into nuxt/content/docs.
// Kept free of Nuxt imports so `scripts/sync_docs.mjs` can run it before `npm install`.

import { execFileSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { tmpdir } from 'node:os'

import { processMarkdown } from './docs-markdown.mjs'

const REPO_URL = 'https://github.com/FlowFuse/flowfuse.git'
const DEFAULT_REF = 'main'
const CLONE_ATTEMPTS = 3
const CLONE_BACKOFF_MS = 2000

// Whatever checkout sits next to the website repo wins. CI puts the flowfuse repo there,
// so a build validates the docs of the caller's checkout rather than whatever main
// happens to be. The release pipeline depends on this.
const SIBLING_PATHS = ['../dev-env/packages/flowfuse', '../flowfuse', '../flowforge']

export const MANIFEST_FILE = '.source.json'

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))

/**
* Decide where the docs come from. Pure: touches nothing, so the precedence is testable.
*
* 1. `FLOWFUSE_DOCS_LOCAL` - an explicit checkout path
* 2. a sibling checkout of flowfuse
* 3. the snapshot committed to the `live` branch (`FLOWFUSE_DOCS_SNAPSHOT` builds only)
* 4. a clone of `FLOWFUSE_DOCS_REF`
*/
export function resolveSource ({ repoRoot, contentDocsDir, env = process.env, exists = existsSync }) {
const local = env.FLOWFUSE_DOCS_LOCAL
if (local) {
const docsDir = local.endsWith('/docs') ? local : join(local, 'docs')
// A typo here would otherwise fall through and quietly publish main's docs.
if (!exists(docsDir)) {
throw new Error(`FLOWFUSE_DOCS_LOCAL is set but ${docsDir} does not exist`)
}
return { kind: 'local', docsDir }
}

for (const sibling of SIBLING_PATHS) {
const docsDir = join(repoRoot, sibling, 'docs')
if (exists(docsDir)) {
return { kind: 'sibling', docsDir }
}
}

if (env.FLOWFUSE_DOCS_SNAPSHOT && exists(join(contentDocsDir, MANIFEST_FILE))) {
return { kind: 'snapshot' }
}

return { kind: 'clone', ref: env.FLOWFUSE_DOCS_REF || DEFAULT_REF }
}

/**
* Sparse-clone the docs into a temp dir and return its path.
*
* A transient network failure here would otherwise fail the entire production deploy, so
* each attempt gets a clean temp dir and the network steps are retried with backoff. The
* caller owns cleanup of the returned dir.
*/
async function cloneDocs (ref, logger) {
let lastError

for (let attempt = 1; attempt <= CLONE_ATTEMPTS; attempt++) {
const tmpDir = join(tmpdir(), `flowfuse-docs-${process.pid}-${attempt}`)
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })

try {
// Blobless but not shallow: dating a page needs that page's history, and a
// --depth=1 clone stamps every page with the same commit date.
execFileSync('git', ['clone', '--filter=blob:none', '--no-checkout', REPO_URL, tmpDir], { stdio: 'pipe' })
execFileSync('git', ['sparse-checkout', 'set', 'docs'], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['checkout', ref], { cwd: tmpDir, stdio: 'pipe' })
return tmpDir
} catch (err) {
lastError = err
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })

if (attempt === CLONE_ATTEMPTS) break

const wait = CLONE_BACKOFF_MS * attempt
logger.warn(`Docs clone attempt ${attempt}/${CLONE_ATTEMPTS} failed, retrying in ${wait}ms`)
await sleep(wait)
}
}

const reason = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`Failed to clone FlowFuse docs from ${REPO_URL} after ${CLONE_ATTEMPTS} attempts: ${reason}`)
}

function gitOutput (cwd, args) {
try {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
} catch {
return ''
}
}

function copyDocsDir (srcDir, repoRoot, contentDir, publicDir, version) {
mkdirSync(contentDir, { recursive: true })
mkdirSync(publicDir, { recursive: true })

for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue

const srcPath = join(srcDir, entry.name)
const destName = entry.name === 'README.md' ? 'index.md' : entry.name

if (entry.isDirectory()) {
copyDocsDir(srcPath, repoRoot, join(contentDir, entry.name), join(publicDir, entry.name), version)
} else if (entry.name.endsWith('.md')) {
const relFromRepo = relative(repoRoot, srcPath)
const originalPath = relative(join(repoRoot, 'docs'), srcPath)

// Argument array, not a shell string: relFromRepo comes from filenames in the
// source repo, so quoting it into a shell command would be an injection path.
const updated = gitOutput(repoRoot, ['log', '-1', '--pretty=format:%ci', '--', relFromRepo])

const raw = readFileSync(srcPath, 'utf8')
writeFileSync(join(contentDir, destName), processMarkdown(raw, originalPath, updated, version), 'utf8')
} else {
cpSync(srcPath, join(publicDir, entry.name))
}
}
}

function writeDocs ({ docsDir, sourceRoot, contentDocsDir, publicDocsDir, kind, ref }) {
let version = ''
try {
version = JSON.parse(readFileSync(join(sourceRoot, 'package.json'), 'utf8')).version || ''
} catch { /* not fatal */ }

rmSync(contentDocsDir, { recursive: true, force: true })
rmSync(publicDocsDir, { recursive: true, force: true })
copyDocsDir(docsDir, sourceRoot, contentDocsDir, publicDocsDir, version)

const manifest = {
source: kind,
ref: ref || gitOutput(sourceRoot, ['rev-parse', '--abbrev-ref', 'HEAD']),
sha: gitOutput(sourceRoot, ['rev-parse', 'HEAD']),
version,
syncedAt: new Date().toISOString(),
}
writeFileSync(join(contentDocsDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')

return manifest
}

/**
* Populate nuxt/content/docs and nuxt/public/docs, and return the manifest describing
* what was published.
*/
export async function syncDocs ({ repoRoot, nuxtRoot, env = process.env, logger = console } = {}) {
const contentDocsDir = join(nuxtRoot, 'content', 'docs')
const publicDocsDir = join(nuxtRoot, 'public', 'docs')
const source = resolveSource({ repoRoot, contentDocsDir, env })

if (source.kind === 'snapshot') {
const manifest = JSON.parse(readFileSync(join(contentDocsDir, MANIFEST_FILE), 'utf8'))
logger.info(`Using committed docs snapshot: ${manifest.ref} ${manifest.sha.slice(0, 8)} synced ${manifest.syncedAt}`)
return manifest
}

let manifest
if (source.kind === 'clone') {
logger.info(`Cloning FlowFuse docs from ${source.ref}...`)
const tmpDir = await cloneDocs(source.ref, logger)
try {
manifest = writeDocs({
docsDir: join(tmpDir, 'docs'),
sourceRoot: tmpDir,
contentDocsDir,
publicDocsDir,
kind: source.kind,
ref: source.ref,
})
} finally {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
}
} else {
logger.info(`Using ${source.kind} docs from ${source.docsDir}`)
manifest = writeDocs({
docsDir: source.docsDir,
sourceRoot: join(source.docsDir, '..'),
contentDocsDir,
publicDocsDir,
kind: source.kind,
})
}

logger.info(`Docs synced from ${manifest.source} (${manifest.ref} ${manifest.sha.slice(0, 8) || 'unknown'}, version ${manifest.version || 'unknown'})`)
return manifest
}
Loading