Skip to content
Merged
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 a clone of `FLOWFUSE_DOCS_REF` (default `main`) — this is what Netlify production deploys use. 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
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
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
Comment thread
ZJvandeWeg marked this conversation as resolved.
blueprints pulled from [FlowFuse/blueprint-library](https://github.com/FlowFuse/blueprint-library).

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.
Netlify is then configured to watch the `live` branch for any changes, once detected, it will automatically pull the contents of this branch and deploy to our production site. Product documentation is not part of that snapshot — Netlify clones it directly from `main` of [FlowFuse/flowfuse](https://github.com/FlowFuse/flowfuse) during its own build.

## Repository structure

Expand Down Expand Up @@ -95,6 +95,16 @@ 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 | A clone of `FLOWFUSE_DOCS_REF` (default `main`) | Nothing above applied. This is what Netlify production deploys use. |

`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`.

## 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 +119,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
186 changes: 186 additions & 0 deletions nuxt/lib/docs-sync.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// 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. a clone of `FLOWFUSE_DOCS_REF`
*/
export function resolveSource ({ repoRoot, 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 }
}
}

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, env })

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
}
54 changes: 54 additions & 0 deletions nuxt/lib/docs-sync.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'

import { resolveSource } from './docs-sync.mjs'

const repoRoot = '/repo/website'

const resolve = (env, present = []) => resolveSource({
repoRoot,
env,
exists: (path) => present.includes(path),
})

test('an explicit path wins over a sibling checkout', () => {
const source = resolve(
{ FLOWFUSE_DOCS_LOCAL: '/elsewhere/flowfuse' },
['/elsewhere/flowfuse/docs', '/repo/flowfuse/docs'],
)

assert.deepEqual(source, { kind: 'local', docsDir: '/elsewhere/flowfuse/docs' })
})

test('an explicit path already ending in /docs is used as given', () => {
const source = resolve(
{ FLOWFUSE_DOCS_LOCAL: '/elsewhere/flowfuse/docs' },
['/elsewhere/flowfuse/docs'],
)

assert.equal(source.docsDir, '/elsewhere/flowfuse/docs')
})

test('a mistyped explicit path throws rather than falling back', () => {
assert.throws(
() => resolve({ FLOWFUSE_DOCS_LOCAL: '/typo' }, ['/repo/flowfuse/docs']),
/FLOWFUSE_DOCS_LOCAL is set but/,
)
})

test('a sibling checkout is found without configuration', () => {
const source = resolve({}, ['/repo/flowfuse/docs'])

assert.deepEqual(source, { kind: 'sibling', docsDir: '/repo/flowfuse/docs' })
})

test('the dev-env checkout is preferred over a bare sibling', () => {
const source = resolve({}, ['/repo/dev-env/packages/flowfuse/docs', '/repo/flowfuse/docs'])

assert.equal(source.docsDir, '/repo/dev-env/packages/flowfuse/docs')
})

test('cloning falls back to main and honours an explicit ref', () => {
assert.deepEqual(resolve({}), { kind: 'clone', ref: 'main' })
assert.equal(resolve({ FLOWFUSE_DOCS_REF: 'maintenance' }).ref, 'maintenance')
})
Loading