diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 71380244cc..2dce06b162 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -29,11 +29,13 @@ This is scoped to `.njk` on purpose and does not extend to `.md`: new content in ### Production build order ``` -clean:nuxt → build:js:nuxt → prod:postcss-nuxt → prod:eleventy-nuxt → prod:nuxt +clean:nuxt → build:js:nuxt → blueprints → prod:postcss-nuxt → prod:eleventy-nuxt → prod:nuxt ``` 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). +The `blueprints` step resolves `src/blueprints/` from `FlowFuse/blueprint-library` (private) via `nuxt/lib/blueprints-sync.mjs` — same local → sibling → clone precedence as docs, but the clone step authenticates with a minted GitHub App installation token (`GH_BOT_APP_ID`/`GH_BOT_APP_KEY`) since that repo isn't public. Netlify deploys straight from `main`; there is no `live` branch anymore. + ## Dev commands ```bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4385752e15..9ed057e7ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,51 +1,20 @@ name: Build Site on: + # FlowFuse/flowfuse's `Publish Documentation` workflow dispatches this by name after a + # docs PR merges to main, since a docs change there doesn't push anything to this repo + # for Netlify to notice on its own. workflow_dispatch: - push: - branches: - - main schedule: + # Blueprint-library changes don't push here either, so rebuild periodically to pick + # those up even if nothing else triggers a deploy in between. - cron: "30 9 * * *" - cron: "5 16 * * 3,4" jobs: - build_deploy: + trigger_rebuild: if: ${{ github.repository == 'FlowFuse/website' }} runs-on: ubuntu-latest steps: - - name: Check out website repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: 'website' - - name: Generate a token - id: generate_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.GH_BOT_APP_ID }} - private-key: ${{ secrets.GH_BOT_APP_KEY }} - owner: ${{ github.repository_owner }} - - name: Check out FlowFuse/blueprint-library repository (to access the blueprints) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: 'FlowFuse/blueprint-library' - ref: main - path: 'blueprint-library' - token: ${{ steps.generate_token.outputs.token }} - - name: Install jq - run: sudo apt-get -qy install jq - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - cache: 'npm' - cache-dependency-path: './website/package-lock.json' - - run: npm run blueprints - working-directory: 'website' - - name: Commit Latest Blueprints - 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 src/blueprints/* -A -f - git commit -a -m "Bot: update blueprints" - - name: Push to 'live' branch - run: git push --force origin HEAD:live - working-directory: './website' + - name: Trigger a Netlify production rebuild + run: curl --fail --silent --show-error -X POST "$NETLIFY_BUILD_HOOK" + env: + NETLIFY_BUILD_HOOK: ${{ secrets.NETLIFY_BUILD_HOOK }} diff --git a/README.md b/README.md index 063e86005e..ab65105e25 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,16 @@ 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 the [Build Site](.github/workflows/build.yml) action updating the `live` branch, committing onto it the -blueprints pulled from [FlowFuse/blueprint-library](https://github.com/FlowFuse/blueprint-library). +It is hosted on Netlify, which watches the `main` branch directly and deploys on every commit to it. +Netlify's own build resolves everything it needs at build time — product documentation from `main` of +[FlowFuse/flowfuse](https://github.com/FlowFuse/flowfuse), and blueprints from +[FlowFuse/blueprint-library](https://github.com/FlowFuse/blueprint-library) (see `npm run blueprints` / +`nuxt/lib/blueprints-sync.mjs`) — so nothing needs to be pre-fetched and committed to a separate branch first. -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. +A commit to `flowfuse/flowfuse` or `blueprint-library` doesn't push anything to this repo, so it wouldn't otherwise +trigger a Netlify rebuild on its own. The [Build Site](.github/workflows/build.yml) action covers that gap: it's +dispatched by `flowfuse/flowfuse`'s `Publish Documentation` workflow after a docs PR merges, and also runs on a +schedule to pick up blueprint-library changes — either way it just calls a Netlify build hook to rebuild `main`. ## Repository structure @@ -107,6 +112,27 @@ Nothing needs configuring for that to happen. Every build resolves the docs in t `npm run dev` and `npm start` also watch the resolved docs and re-sync each file as it changes, so an edit appears without restarting. `npm run dev:nuxt` on its own does not include that watcher; run `npm run dev:docs` beside it if you want one. +### Running Blueprints + +Blueprints are maintained in the (private) [FlowFuse/blueprint-library](https://github.com/FlowFuse/blueprint-library) repo. To work with them locally, clone that repository alongside this one, the same way as `flowfuse` above: + +``` +/ + /website + /blueprint-library +``` + +Every build resolves blueprints in this order, and logs which one it used: + +| Order | Source | Used when | +|-------|--------|-----------| +| 1 | `BLUEPRINTS_LOCAL=/path/to/blueprint-library` | The env var is set. A path that does not exist is an error, not a fallback. | +| 2 | A sibling checkout: `../blueprint-library` | It exists. This is what local development relies on. | +| 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 `src/blueprints`, which is gitignored. `npm start` 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. + ## llms.txt `/llms.txt` (and `/llms-full.txt`) are generated by the [`nuxt-llms`](https://github.com/nuxtlabs/nuxt-llms) module, configured in `nuxt/nuxt.config.ts` under the `llms` key. Sections are built from `@nuxt/content` collections (`docs`, `blog`, `changelog`, `ebooks`, `whitepapers`) plus a small hardcoded list of standalone Nuxt routes (pricing, integrations, etc.) that aren't backed by a collection. diff --git a/nuxt/lib/blueprints-sync.mjs b/nuxt/lib/blueprints-sync.mjs new file mode 100644 index 0000000000..755e30f49c --- /dev/null +++ b/nuxt/lib/blueprints-sync.mjs @@ -0,0 +1,256 @@ +// Resolves the FlowFuse blueprint library for a build and copies its content into +// src/blueprints (11ty's blueprint source - see .eleventy.js's setUseGitIgnore(false) +// note). Mirrors nuxt/lib/docs-sync.mjs's local -> sibling -> clone precedence, but the +// source repo (FlowFuse/blueprint-library) is private, so the clone step authenticates +// with a minted GitHub App installation token instead of cloning anonymously. + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, cpSync } from 'node:fs' +import { basename, join, relative } from 'node:path' +import { tmpdir } from 'node:os' + +// Imported lazily (inside cloneBlueprints, not here) because it pulls in @octokit/auth-app. +// CI checks out blueprint-library as a sibling and calls `npm run blueprints` before +// `npm install` runs - see nuxt/lib/docs-sync.mjs's own note on staying dependency-free - +// so a static import here would crash a build that never even takes the clone path. Only +// Netlify's production build (no sibling checkout) reaches the clone path, and by then +// npm install has already completed. + +const REPO_OWNER = 'FlowFuse' +const REPO_NAME = 'blueprint-library' +const REPO_URL = `https://github.com/${REPO_OWNER}/${REPO_NAME}.git` +const DEFAULT_REF = 'main' +const CLONE_ATTEMPTS = 3 +const CLONE_BACKOFF_MS = 2000 + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Decide where the blueprints come from. Pure: touches nothing, so the precedence is + * testable. + * + * 1. `BLUEPRINTS_LOCAL` - an explicit checkout path + * 2. a sibling checkout of blueprint-library + * 3. a clone, authenticated with the GitHub App - only if credentials are configured + * 4. skip - matches the previous copy_blueprints.js behaviour for contributors without + * access to the (private) blueprint-library repo + */ +export function resolveSource ({ repoRoot, env = process.env, exists = existsSync }) { + const local = env.BLUEPRINTS_LOCAL + if (local) { + if (!exists(local)) { + throw new Error(`BLUEPRINTS_LOCAL is set but ${local} does not exist`) + } + return { kind: 'local', dir: local } + } + + const sibling = join(repoRoot, '..', 'blueprint-library') + if (exists(sibling)) { + return { kind: 'sibling', dir: sibling } + } + + if (env.GH_BOT_APP_ID && env.GH_BOT_APP_KEY) { + return { kind: 'clone', ref: env.BLUEPRINTS_REF || DEFAULT_REF } + } + + return { kind: 'skip' } +} + +/** + * Sparse-clone the blueprint library 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. Never let the minted token reach a thrown + * error's message - execFileSync embeds the full command (URL included) in its own + * error, so failures are reported from stderr text with the token stripped out. + */ +async function cloneBlueprints (ref, env, logger) { + const { mintInstallationToken } = await import('./github-app-token.mjs') + + let lastMessage = 'unknown error' + for (let attempt = 1; attempt <= CLONE_ATTEMPTS; attempt++) { + const tmpDir = join(tmpdir(), `blueprint-library-${process.pid}-${attempt}`) + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) + + // Minted fresh each attempt so a transient failure here gets the same retry + + // redaction as the clone/checkout below, rather than failing the build outright. + let token + try { + token = await mintInstallationToken({ + appId: env.GH_BOT_APP_ID, + privateKey: env.GH_BOT_APP_KEY, + owner: REPO_OWNER, + repo: REPO_NAME, + }) + } catch (err) { + lastMessage = err?.message || String(err) + if (attempt === CLONE_ATTEMPTS) break + + const wait = CLONE_BACKOFF_MS * attempt + logger.warn(`Blueprint token mint attempt ${attempt}/${CLONE_ATTEMPTS} failed, retrying in ${wait}ms`) + await sleep(wait) + continue + } + + const authedUrl = REPO_URL.replace('https://', `https://x-access-token:${token}@`) + const redact = (text) => text.split(token).join('***') + + try { + // Blobless but not shallow: dating a blueprint 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', authedUrl, tmpDir], { stdio: 'pipe' }) + execFileSync('git', ['checkout', ref], { cwd: tmpDir, stdio: 'pipe' }) + return tmpDir + } catch (err) { + lastMessage = redact(err?.stderr?.toString() || err?.message || String(err)) + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) + + if (attempt === CLONE_ATTEMPTS) break + + const wait = CLONE_BACKOFF_MS * attempt + logger.warn(`Blueprint clone attempt ${attempt}/${CLONE_ATTEMPTS} failed, retrying in ${wait}ms`) + await sleep(wait) + } + } + + throw new Error(`Failed to clone ${REPO_OWNER}/${REPO_NAME} after ${CLONE_ATTEMPTS} attempts: ${lastMessage}`) +} + +function gitOutput (cwd, args) { + try { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + } catch { + return '' + } +} + +/** + * Copy one blueprint markdown file, stamping it with its last-commit date and rewriting + * its `image:` frontmatter path to match where it lands under src/blueprints. Ported + * as-is from the previous scripts/copy_blueprints.js. + */ +function writeBlueprintMarkdown ({ sourceRoot, srcPath, destPath, inputRelDir }) { + const relPath = relative(sourceRoot, srcPath) + const updated = gitOutput(sourceRoot, ['log', '-1', '--pretty=format:%ci', '--', relPath]) + + const content = readFileSync(srcPath, 'utf8') + let body = `---\nupdated: ${updated}\n---\n${content}` + if (/^---/.test(content)) { + // The original file starts with yaml front-matter, so remove the double-delimiter + // we've just introduced. + body = body.replace(/---\r?\n---\r?\n/s, '') + } + + // tileImage's shortcode (.eleventy.js) resolves item.data.image relative to 11ty's + // input folder (src/), not as a filesystem or site-root path - so this stays relative, + // e.g. "blueprints/foo/bar/img.png", never "src/blueprints/..." or "/blueprints/...". + const imageRegex = /^image:\s*(\S.+)$/m + if (imageRegex.test(body)) { + body = body.replace(imageRegex, (match, p1) => { + const relImage = p1.replace(/^"\.\//, '').replace(/"$/, '') + return `image: ${join(inputRelDir, relImage)}` + }) + } + + writeFileSync(destPath, body) +} + +function copyTree (srcDir, destDir, sourceRoot, inputRelDir) { + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + + const srcPath = join(srcDir, entry.name) + if (entry.isDirectory()) { + const lowerCaseName = entry.name.toLowerCase() + copyTree(srcPath, join(destDir, lowerCaseName), sourceRoot, join(inputRelDir, lowerCaseName)) + continue + } + + const destPath = join(destDir, entry.name.replace(/README/, 'index')) + if (entry.name.endsWith('.md')) { + writeBlueprintMarkdown({ sourceRoot, srcPath, destPath, inputRelDir }) + } else { + cpSync(srcPath, destPath) + } + } +} + +// Removes only the entries under destDir that no longer exist in the source - never +// submit.njk (this repo's own "Submit Your Own" page, not something blueprint-library +// provides) and never an entry copyTree is about to repopulate anyway. Deliberately +// narrower than docs-sync.mjs's full wipe: copyTree already overwrites every file in +// place on each sync (11ty sees a cheap "changed" event), so wiping unaffected blueprints +// too would turn that into a delete+recreate of the entire tree on every sync - noisy for +// 11ty's watcher and briefly 404s a page mid-rebuild for no reason. +function clearOrphans (destDir, currentNames) { + if (!existsSync(destDir)) return + for (const entry of readdirSync(destDir, { withFileTypes: true })) { + if (entry.name === 'submit.njk' || currentNames.has(entry.name)) continue + rmSync(join(destDir, entry.name), { recursive: true, force: true }) + } +} + +function directoryNames (dir) { + return readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')) +} + +/** + * Populate src/blueprints from `dir` (one category folder per top-level entry, one + * blueprint per folder below that) and return the manifest describing what was published. + * Pruned one level at a time - at the category level and again inside each still-current + * category - since that's the actual unit that gets renamed or removed upstream, without + * having to fully tree-diff every nested asset to catch it. + */ +function writeBlueprints ({ dir, websiteRoot, kind, ref }) { + const destRoot = join(websiteRoot, 'src', 'blueprints') + const categories = directoryNames(dir) + clearOrphans(destRoot, new Set(categories.map(entry => entry.name))) + + for (const category of categories) { + const categorySrcDir = join(dir, category.name) + const categoryDestDir = join(destRoot, basename(categorySrcDir)) + clearOrphans(categoryDestDir, new Set(directoryNames(categorySrcDir).map(entry => entry.name))) + copyTree(categorySrcDir, categoryDestDir, dir, join('blueprints', basename(categorySrcDir))) + } + + return { + source: kind, + ref: ref || gitOutput(dir, ['rev-parse', '--abbrev-ref', 'HEAD']), + sha: gitOutput(dir, ['rev-parse', 'HEAD']), + syncedAt: new Date().toISOString(), + } +} + +/** + * Populate src/blueprints and return the manifest describing what was published, or null + * if there was no source to sync from (matches the previous copy_blueprints.js's + * "skipping" behaviour for contributors without access to blueprint-library). + */ +export async function syncBlueprints ({ repoRoot, env = process.env, logger = console } = {}) { + const source = resolveSource({ repoRoot, env }) + + if (source.kind === 'skip') { + logger.info('Blueprint library not found and no GH_BOT_APP_ID/GH_BOT_APP_KEY configured - skipping') + return null + } + + let manifest + if (source.kind === 'clone') { + logger.info(`Cloning ${REPO_OWNER}/${REPO_NAME} from ${source.ref}...`) + const tmpDir = await cloneBlueprints(source.ref, env, logger) + try { + manifest = writeBlueprints({ dir: tmpDir, websiteRoot: repoRoot, kind: source.kind, ref: source.ref }) + } finally { + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) + } + } else { + logger.info(`Using ${source.kind} blueprints from ${source.dir}`) + manifest = writeBlueprints({ dir: source.dir, websiteRoot: repoRoot, kind: source.kind }) + } + + logger.info(`Blueprints synced from ${manifest.source} (${manifest.ref} ${manifest.sha.slice(0, 8) || 'unknown'})`) + return manifest +} diff --git a/nuxt/lib/github-app-token.mjs b/nuxt/lib/github-app-token.mjs new file mode 100644 index 0000000000..6a41a9c86c --- /dev/null +++ b/nuxt/lib/github-app-token.mjs @@ -0,0 +1,33 @@ +// Mints a short-lived GitHub App installation token so a build can clone a private repo +// without a long-lived PAT. Uses @octokit/auth-app (GitHub's own library) rather than +// hand-rolled JWT signing - it already handles PEM key formats, clock skew, and caching. + +import { createAppAuth } from '@octokit/auth-app' + +/** + * Mint an installation access token (valid ~1 hour) for the GitHub App installed on + * `owner/repo`. Uses the same App credentials (GH_BOT_APP_ID / GH_BOT_APP_KEY) the + * `Build Site` Actions workflow already uses to push blueprint updates, so Netlify's + * production build can clone the private blueprint-library repo the same way. + */ +export async function mintInstallationToken ({ appId, privateKey, owner, repo }) { + const auth = createAppAuth({ appId, privateKey }) + + // auth-app only mints installation tokens by id, so look the installation up first + // using app-level (JWT) auth - the same two-step flow GitHub's REST API requires. + const { token: appJwt } = await auth({ type: 'app' }) + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/installation`, { + headers: { + Authorization: `Bearer ${appJwt}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + if (!res.ok) { + throw new Error(`Failed to look up the GitHub App installation for ${owner}/${repo}: ${res.status} ${res.statusText}`) + } + const { id: installationId } = await res.json() + + const { token } = await auth({ type: 'installation', installationId }) + return token +} diff --git a/package-lock.json b/package-lock.json index 6f036f0d45..5e3016e985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@11ty/eleventy-fetch": "^4.0.0", "@flowfuse/flow-renderer": "^0.5.0", "@netlify/blobs": "^10.7.9", + "@octokit/auth-app": "^8.3.0", "@xmldom/xmldom": "^0.8.15", "algoliasearch": "^4.25.3", "codeowners": "^5.1.1", @@ -4214,6 +4215,153 @@ } } }, + "node_modules/@octokit/auth-app": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.3.0.tgz", + "integrity": "sha512-/UaKmJCsOc5XBZwhnFiGNdLH/FkDF8lYtBn1QlKxtX7IpgaRB/XOXjixFtERAknyUZHxp3oDuoiG0En4VptJSg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.4", + "@octokit/auth-oauth-user": "^6.0.3", + "@octokit/request": "^10.0.13", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.4.tgz", + "integrity": "sha512-Pe3du5LrC6dlv10b0RbarBlJtIT1Urq8BFoQklK8WmBw8YKnGgrANn7cQmHiA1v8AVihgi7YutV8MncP57I4og==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.4", + "@octokit/auth-oauth-user": "^6.0.3", + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.4.tgz", + "integrity": "sha512-M/+34rmkxMvUnnTo/uqNeVRCXJoJ+5N34eNTbL1KMtIyJpedCTsu/iFL1cWdL+w5hQMlYt6xzXoyux2gn0OVnw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^6.0.3", + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.3.tgz", + "integrity": "sha512-t4OKUhrI5britmpRiSzPAz1TJPyUN/Hw1HEE3Hz/uElxcmnf/YCRSKtFRNc5gy4yJFTlXgk34H1lvxiotQJQvQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.4", + "@octokit/oauth-methods": "^6.0.3", + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.4.tgz", + "integrity": "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", + "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.4.tgz", + "integrity": "sha512-96RsnxS7Hk/BQhUA1Qo2pmcYP6LWQRWMdo7bVbNE7ZCkFLhSUYBXVUj9tVnvhl4jzbwHYt/dcIG+ioY427b2sg==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.13", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "license": "MIT" + }, + "node_modules/@octokit/request": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.15.tgz", + "integrity": "sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz", + "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -11345,6 +11493,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.0.0.tgz", + "integrity": "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -16305,6 +16466,12 @@ "@types/json-schema": "^7.0.15" } }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -25666,6 +25833,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -26279,6 +26455,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", + "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index e8a716b8e1..24c24d6bcb 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ ], "scripts": { "test": "node --test nuxt/server/lib/*.test.mjs nuxt/lib/*.test.mjs", - "dev": "concurrently \"npm run dev:eleventy\" \"npm run dev:docs\" \"npm run dev:postcss\" \"npm run dev:postcss-nuxt\" \"dotenv -- npm run dev --workspace=nuxt\"", + "dev": "concurrently \"npm run dev:eleventy\" \"npm run dev:docs\" \"npm run dev:blueprints\" \"npm run dev:postcss\" \"npm run dev:postcss-nuxt\" \"dotenv -- npm run dev --workspace=nuxt\"", "start": "npm-run-all2 clean:dev build:js blueprints --parallel dev:*", "build:js": "terser -c -m -o _site/js/cc.min.js node_modules/vanilla-cookieconsent/dist/cookieconsent.umd.js src/js/cookieconsent-config.js && cp node_modules/@flowfuse/flow-renderer/index.min.js _site/js/flowrenderer.min.js", "build": "dotenv -v NODE_ENV=production -- npm-run-all2 clean build:js --parallel prod:*", @@ -23,7 +23,7 @@ "dev:netlify": "npx netlify dev -c \"dotenv -- npx @11ty/eleventy --serve --quiet --incremental\"", "dev:postcss": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./_site/css/style.css --config ./postcss.config.js -w", "dev:postcss-nuxt": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./nuxt/public/css/style.css --config ./postcss.config.js -w", - "blueprints": "node scripts/copy_blueprints.js", + "blueprints": "node scripts/copy_blueprints.mjs", "docs": "node scripts/sync_docs.mjs", "index:algolia": "node scripts/index-algolia.js", "dev:eleventy": "dotenv -- npx @11ty/eleventy --serve --port 8080 --quiet", @@ -47,32 +47,32 @@ "@11ty/eleventy-plugin-syntaxhighlight": "^4.2.0", "@flowforge/forge-ui-components": "^0.2.2", "@kevingimbel/eleventy-plugin-mermaid": "^2.0.0", + "@parcel/watcher": "2.5.6", + "@resvg/resvg-js": "2.6.2", "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.14", + "@takumi-rs/core": "1.8.4", "@types/markdown-it-attrs": "^4.1.3", "@types/markdown-it-footnote": "^3.0.4", "chokidar": "^3.6.0", "concurrently": "^9.0.0", "del-cli": "^5.0.0", "dotenv-cli": "^7.4.4", - "@parcel/watcher": "2.5.6", - "@resvg/resvg-js": "2.6.2", - "@takumi-rs/core": "1.8.4", - "esbuild": "0.28.1", - "oxc-minify": "0.131.0", - "oxc-parser": "0.134.0", - "oxc-transform": "0.131.0", - "rollup": "4.60.3", "eleventy-plugin-code-clipboard": "git+https://github.com/joepavitt/eleventy-plugin-code-clipboard.git", + "esbuild": "0.28.1", "html-minifier-terser": "^7.2.0", "markdown-it-anchor": "^8.6.7", "markdown-it-footnote": "^3.0.3", "netlify-plugin-cache": "^1.0.3", "nodemon": "^2.0.20", "npm-run-all2": "^7.0.1", + "oxc-minify": "0.131.0", + "oxc-parser": "0.134.0", + "oxc-transform": "0.131.0", "postcss": "^8.5.24", "postcss-cli": "^9.0.1", "postcss-minify": "^1.1.0", + "rollup": "4.60.3", "seedrandom": "^3.0.5", "spacetime": "^7.4.7", "tailwindcss": "^4.0.0", @@ -101,6 +101,7 @@ "@11ty/eleventy-fetch": "^4.0.0", "@flowfuse/flow-renderer": "^0.5.0", "@netlify/blobs": "^10.7.9", + "@octokit/auth-app": "^8.3.0", "@xmldom/xmldom": "^0.8.15", "algoliasearch": "^4.25.3", "codeowners": "^5.1.1", diff --git a/scripts/copy_blueprints.js b/scripts/copy_blueprints.js deleted file mode 100644 index b0fb74e502..0000000000 --- a/scripts/copy_blueprints.js +++ /dev/null @@ -1,87 +0,0 @@ -const fs = require('fs/promises'); -const { existsSync, readdirSync } = require('fs'); - -const path = require('path'); -const util = require('util'); -const exec = util.promisify(require('child_process').exec); - -async function copyFiles(src, dest) { - const files = await fs.readdir(src, { withFileTypes: true }); - for (const file of files) { - if (!file.name.startsWith('.')) { - if (file.isDirectory()) { - const lowerCaseFileName = file.name.toLowerCase(); - const newSrc = path.join(src, file.name); - const newDest = path.join(dest, lowerCaseFileName); - await fs.mkdir(newDest, { recursive: true }); - await copyFiles(newSrc, newDest); - } else { - const srcFile = path.join(src, file.name); - const destFile = path.join(dest, file.name.replace(/README/, 'index')); - - if (!file.name.endsWith('.md')) { - await fs.copyFile(srcFile, destFile); - } else { - const { stdout } = await exec(`git log -1 --pretty=format:%ci ${file.name}`, { - cwd: src, - }); - const header = '---\n' + - `updated: ${stdout}\n` + - '---\n'; - let content = await fs.readFile(srcFile, 'utf-8'); - let body = header + content; - if (/^---/.test(content)) { - // The original file starts with yaml front-matter, so - // remove the double-delimter we've just introduced - body = body.replace(/---\r?\n---\r?\n/s, ''); - } - - // Use a regular expression to find the "image" key in the front matter - // that is not commented out and has a non-space value - const imageRegex = /^image:\s*(\S.+)$/m; - - // If the "image" key is found, replace its value with the new relative path - if (imageRegex.test(body)) { - body = body.replace(imageRegex, (match, p1) => { - // Remove "./ and the " from the end - p1 = p1.replace(/^"\.\//, '').replace(/"$/, ''); - return `image: ${path.join(dest.replace('src/', ''), p1)}`; - }); - } - - await fs.writeFile(destFile, body); - } - } - } - } -}; - -(async () => { - // Check we are in the root of the website repo - if (!existsSync('src')) { - console.log('Run this from the top of the website repository'); - process.exit(-1); - } - - // Go find the blueprint-library folder - const blueprintsDir = '../blueprint-library'; // Blueprints repository - if (!existsSync(blueprintsDir)) { - console.log(`Blueprint library not found ${blueprintsDir} - skipping`); - process.exit(); - } - - readdirSync(blueprintsDir, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory() && !dirent.name.startsWith('.')) - .map(dirent => path.join (blueprintsDir, dirent.name)) - .forEach(async element => { - console.log(element); - try { - const packFile = await fs.readFile('package.json'); - const dest = 'src/blueprints'; - await copyFiles(element, path.join(dest, path.basename(element))); - } catch (error) { - console.error('Error reading or copying files:', error); - } - }); - -})(); diff --git a/scripts/copy_blueprints.mjs b/scripts/copy_blueprints.mjs new file mode 100644 index 0000000000..7884ff0493 --- /dev/null +++ b/scripts/copy_blueprints.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +// Populates src/blueprints outside of a Nuxt build, so `npm start`/the 11ty build can +// resolve blueprints the same way a production build does. Mirrors scripts/sync_docs.mjs. + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { syncBlueprints } from '../nuxt/lib/blueprints-sync.mjs' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + +await syncBlueprints({ repoRoot })