From 997f975f9acfd277676af011c70eb82489729c79 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 11:01:33 -0400 Subject: [PATCH] fix(frontend-vite): generate routes before build and add a clean-clone CI build (D-0536) routes.generated.tsx is gitignored and had no generator, so vite build failed from a clean clone. Add scripts/generate-routes.mjs (reads each page's F-NCHAT-VITE-ROUTE header), run it as pre-dev/build/type-check/test, and add a frontend-vite workflow that checks out the sibling packages repo, builds the linked @nself/* dist and builds + tests the app. --- .github/workflows/frontend-vite.yml | 94 ++++++++++++++++++++ frontend-vite/README.md | 36 +++++++- frontend-vite/package.json | 5 ++ frontend-vite/scripts/generate-routes.mjs | 26 ++++++ frontend-vite/scripts/routes-lib.mjs | 100 ++++++++++++++++++++++ frontend-vite/scripts/routes-lib.test.mjs | 59 +++++++++++++ 6 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/frontend-vite.yml create mode 100644 frontend-vite/scripts/generate-routes.mjs create mode 100644 frontend-vite/scripts/routes-lib.mjs create mode 100644 frontend-vite/scripts/routes-lib.test.mjs diff --git a/.github/workflows/frontend-vite.yml b/.github/workflows/frontend-vite.yml new file mode 100644 index 000000000..6131da93a --- /dev/null +++ b/.github/workflows/frontend-vite.yml @@ -0,0 +1,94 @@ +name: Frontend (Vite) + +# Builds frontend-vite from a clean clone: generates the route table, type-checks and +# runs `vite build`. frontend-vite links five @nself/* packages from the nself-org/packages +# repo as a SIBLING directory (link:../../packages/@nself/), so this job checks that +# repo out next to the workspace exactly as the desktop-* workflows do. + +on: + pull_request: + paths: + - 'frontend-vite/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/frontend-vite.yml' + push: + branches: [main] + paths: + - 'frontend-vite/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/frontend-vite.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-vite-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build + test frontend-vite + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Pinned, not floating, so the build is reproducible (same sha as the desktop + # workflows). Bump all of them together when frontend-vite needs newer @nself/* code. + # Checked out into a subdirectory and MOVED to the sibling path: pnpm writes relative + # node_modules links that resolve against the physical path, so they dangle through + # a symlink (see the desktop-* workflows). + - name: Checkout @nself packages (sibling workspace dir) + uses: actions/checkout@v7 + with: + repository: nself-org/packages + ref: 8a5cc53106fae71474f822003645d1f51dcfcd95 + path: _nself_packages + persist-credentials: false + + - name: Move @nself packages to the sibling path pnpm expects + shell: bash + run: | + PARENT_DIR="$(dirname "$GITHUB_WORKSPACE")" + rm -rf "$PARENT_DIR/packages" + mv "$GITHUB_WORKSPACE/_nself_packages" "$PARENT_DIR/packages" + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install pnpm deps + run: pnpm install --frozen-lockfile --ignore-scripts + + # The linked packages export TypeScript source and import i18next, @urql/core, ... + # from the sibling's own node_modules, so install them from the sibling's lockfile. + - name: Install @nself packages deps (sibling's own lockfile) + shell: bash + run: pnpm --dir "$(dirname "$GITHUB_WORKSPACE")/packages" install --frozen-lockfile --ignore-scripts + + # frontend-vite/tsconfig.json maps @nself/* to ../../packages/@nself//dist/index.d.ts. + # Without a dist, tsc falls through to the sibling's raw src and applies THIS app's + # noUnusedLocals to it, failing on code that is not ours. + - name: Build the linked @nself packages (dist + d.ts) + shell: bash + run: | + pnpm --dir "$(dirname "$GITHUB_WORKSPACE")/packages" \ + --filter @nself/errors --filter @nself/auth-core --filter @nself/graphql-client \ + --filter @nself/i18n --filter @nself/ui build + + # `pnpm build` regenerates src/routes.generated.tsx (prebuild), type-checks, then vite builds. + - name: Build frontend-vite + run: pnpm --filter @nchat/frontend-vite build + + - name: Test frontend-vite + run: pnpm --filter @nchat/frontend-vite test diff --git a/frontend-vite/README.md b/frontend-vite/README.md index 89bdee050..f7ca6f426 100644 --- a/frontend-vite/README.md +++ b/frontend-vite/README.md @@ -23,17 +23,47 @@ react-router ``. Pages currently render the `@nself/ui` `AsyncScreen` emp ```bash pnpm dev # vite dev server on :3000 -pnpm build # tsc --noEmit && vite build -pnpm type-check # tsc --noEmit +pnpm build # generate:routes, then tsc --noEmit && vite build +pnpm type-check # generate:routes, then tsc --noEmit +pnpm test # generate:routes, then vitest +pnpm generate:routes # rebuild src/routes.generated.tsx (runs automatically before the above) ``` +`src/routes.generated.tsx` is gitignored (`*.generated.*`) and rebuilt from the `F-NCHAT-VITE-ROUTE` +header in each `src/pages/*Page.tsx` by `scripts/generate-routes.mjs`. A new page needs that header; +a public (no-auth) page also needs its path added to `PUBLIC_PATHS` in `scripts/routes-lib.mjs`. + +### Building from a clean clone + +`@nself/*` are `link:../../packages/@nself/` deps, so `nself-org/packages` must sit next to +this repo (`/nchat`, `/packages`). Install both, then build the linked packages +once so `tsconfig.json` can resolve their `dist/index.d.ts`: + +```bash +pnpm install --frozen-lockfile # in nchat +pnpm --dir ../packages install --frozen-lockfile +pnpm --dir ../packages --filter @nself/errors --filter @nself/auth-core \ + --filter @nself/graphql-client --filter @nself/i18n --filter @nself/ui build +pnpm --filter @nchat/frontend-vite build +``` + +CI runs exactly this in `.github/workflows/frontend-vite.yml`. + +### i18n note + +The app imports only `@nself/i18n` (`NselfI18nProvider`); it has no `i18next` / `react-i18next` +imports of its own, so there is one i18n context today. `@nself/i18n` carries its own +i18next 23 (the declared `react-i18next` here is unused). If the app ever calls `useTranslation` +directly, dedupe `i18next` + `react-i18next` first (vite `resolve.dedupe`) or the two copies +will have separate contexts. + ## Layout ``` src/ main.tsx entry: urql + ReactQuery + Router + auth + i18n providers App.tsx root : public flat, protected under RequireAuth + AppLayout - routes.generated.tsx GENERATED route table (107 lazy pages) — regenerate, don't hand-edit + routes.generated.tsx GENERATED (gitignored) route table, one lazy page per src/pages file components/ RequireAuth, AppLayout, PlaceholderScreen lib/graphql-client.ts urql singleton via @nself/graphql-client pages/ 107 lazy page placeholders (one per legacy page group) diff --git a/frontend-vite/package.json b/frontend-vite/package.json index 56c78321b..be9c9e4a7 100644 --- a/frontend-vite/package.json +++ b/frontend-vite/package.json @@ -6,11 +6,16 @@ "private": true, "type": "module", "scripts": { + "generate:routes": "node scripts/generate-routes.mjs", + "predev": "pnpm generate:routes", "dev": "vite --port 3000", + "prebuild": "pnpm generate:routes", "build": "tsc --noEmit && vite build", "preview": "vite preview --port 3000", "start": "vite preview --port 3000", + "pretype-check": "pnpm generate:routes", "type-check": "tsc --noEmit", + "pretest": "pnpm generate:routes", "test": "vitest run --passWithNoTests" }, "dependencies": { diff --git a/frontend-vite/scripts/generate-routes.mjs b/frontend-vite/scripts/generate-routes.mjs new file mode 100644 index 000000000..78ddcdc25 --- /dev/null +++ b/frontend-vite/scripts/generate-routes.mjs @@ -0,0 +1,26 @@ +/** + * Purpose: Writes src/routes.generated.tsx from the page files in src/pages. + * Inputs: src/pages/*Page.tsx (route read from each file's header). + * Outputs: src/routes.generated.tsx (gitignored). Exit 0 ok, 1 on a bad page header. + * Usage: node scripts/generate-routes.mjs (also: pnpm generate:routes) + * SOT: F-NCHAT-VITE-ROUTES-01 + */ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parsePages, renderRoutes } from './routes-lib.mjs' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const pagesDir = join(root, 'src', 'pages') + +try { + const pages = readdirSync(pagesDir) + .filter((f) => f.endsWith('Page.tsx')) + .map((file) => ({ file, source: readFileSync(join(pagesDir, file), 'utf8') })) + const rows = parsePages(pages) + writeFileSync(join(root, 'src', 'routes.generated.tsx'), renderRoutes(rows)) + console.log(`generate-routes: wrote ${rows.length} routes`) +} catch (err) { + console.error(`generate-routes: ${err.message}`) + process.exit(1) +} diff --git a/frontend-vite/scripts/routes-lib.mjs b/frontend-vite/scripts/routes-lib.mjs new file mode 100644 index 000000000..607a2ce9a --- /dev/null +++ b/frontend-vite/scripts/routes-lib.mjs @@ -0,0 +1,100 @@ +/** + * Purpose: Pure helpers that turn the page files under src/pages into the react-router + * route table (src/routes.generated.tsx). + * Inputs: [{ file, source }] — page file names (e.g. "AdminUsersIdPage.tsx") + their text. + * Each page declares its URL in its header: `SOT: F-NCHAT-VITE-ROUTE — /admin/users/:id`. + * Outputs: parsePages() -> sorted [{ component, path }]; renderRoutes() -> TSX source text. + * Constraints:No I/O, no side effects, so tests can call it directly. "/" is the HomePage entry, + * not a list row. PUBLIC_PATHS is the only auth decision; every other route is protected. + * SOT: F-NCHAT-VITE-ROUTES-01 + */ + +const ROUTE_RE = /F-NCHAT-VITE-ROUTE\s+—\s+(\S+)/ + +// Routes served without the RequireAuth gate (login, signup, /auth/*, invite, setup, offline, +// marketing/demo/docs). Everything else is nested under in App.tsx. +export const PUBLIC_PATHS = new Set([ + '/api-docs', + '/api-docs/bots', + '/auth/2fa-backup', + '/auth/2fa-verify', + '/auth/callback-complete', + '/auth/forgot-password', + '/auth/magic-link', + '/auth/resend-verification', + '/auth/reset-password', + '/auth/signin', + '/auth/signup', + '/auth/verify-email', + '/auth/verify-magic-link', + '/demo', + '/demo/animations', + '/invite/:code', + '/login', + '/offline', + '/search-demo', + '/setup', + '/setup/:step', + '/signup', + '/test-env', +]) + +export const HOME_PATH = '/' + +/** Parse page files into { component, path } rows. Throws on a page with no route header. */ +export function parsePages(pages) { + const rows = pages.map(({ file, source }) => { + const match = ROUTE_RE.exec(source) + if (!match) throw new Error(`${file}: missing "SOT: F-NCHAT-VITE-ROUTE — " header`) + return { component: file.replace(/\.tsx$/, ''), path: match[1] } + }) + const seen = new Map() + for (const row of rows) { + if (seen.has(row.path)) { + throw new Error(`duplicate route ${row.path}: ${seen.get(row.path)} and ${row.component}`) + } + seen.set(row.path, row.component) + } + if (!seen.has(HOME_PATH)) throw new Error('no page declares the "/" route (HomePage)') + return rows.sort((a, b) => a.component.localeCompare(b.component)) +} + +const row = ({ path, component }) => ` { path: '${path}', Component: ${component} },` + +/** Render the full routes.generated.tsx text. */ +export function renderRoutes(rows) { + const home = rows.find((r) => r.path === HOME_PATH) + const list = rows.filter((r) => r.path !== HOME_PATH) + const lazyLine = ({ component }) => `const ${component} = lazy(() => import('@/pages/${component}'))` + const byPath = (a, b) => a.path.localeCompare(b.path) + const pub = list.filter((r) => PUBLIC_PATHS.has(r.path)).sort(byPath) + const prot = list.filter((r) => !PUBLIC_PATHS.has(r.path)).sort(byPath) + return `/** + * GENERATED BY scripts/generate-routes.mjs — DO NOT HAND EDIT. + * Purpose: Route table for the ɳChat Vite SPA, one entry per src/pages/*Page.tsx + * (${rows.length} total), read from each page's "F-NCHAT-VITE-ROUTE" header. + * Outputs: PUBLIC_ROUTES + PROTECTED_ROUTES arrays + HomePage, consumed by App.tsx. + * Constraints:Gitignored (*.generated.*); \`pnpm generate:routes\` rebuilds it and runs + * automatically before dev, build, type-check and test. + * Dynamic segments preserved: Next [id] -> :id, [...slug] -> *. + * SOT: F-NCHAT-VITE-ROUTES-01 + */ +import { lazy, type LazyExoticComponent, type ComponentType } from 'react' + +export interface RouteDef { + path: string + Component: LazyExoticComponent +} + +${list.map(lazyLine).join('\n')} +export const ${home.component} = lazy(() => import('@/pages/${home.component}')) + +export const PUBLIC_ROUTES: RouteDef[] = [ +${pub.map(row).join('\n')} +] + +export const PROTECTED_ROUTES: RouteDef[] = [ +${prot.map(row).join('\n')} +] +` +} diff --git a/frontend-vite/scripts/routes-lib.test.mjs b/frontend-vite/scripts/routes-lib.test.mjs new file mode 100644 index 000000000..7b4119d19 --- /dev/null +++ b/frontend-vite/scripts/routes-lib.test.mjs @@ -0,0 +1,59 @@ +/** + * Purpose: Guards the route generator: every page declares a unique route, the public + * allowlist has no stale entries, and the rendered table is well formed. + * SOT: F-NCHAT-VITE-ROUTES-01 + */ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { HOME_PATH, PUBLIC_PATHS, parsePages, renderRoutes } from './routes-lib.mjs' + +const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages') +const realPages = readdirSync(pagesDir) + .filter((f) => f.endsWith('Page.tsx')) + .map((file) => ({ file, source: readFileSync(join(pagesDir, file), 'utf8') })) + +const page = (file, path) => ({ file, source: `/** SOT: F-NCHAT-VITE-ROUTE — ${path} */` }) + +describe('routes generator', () => { + const rows = parsePages(realPages) + + it('reads a route from every page and keeps them unique', () => { + expect(rows).toHaveLength(realPages.length) + expect(new Set(rows.map((r) => r.path)).size).toBe(rows.length) + expect(rows.some((r) => r.path === HOME_PATH)).toBe(true) + }) + + it('has no stale entries in the public allowlist', () => { + const paths = new Set(rows.map((r) => r.path)) + for (const p of PUBLIC_PATHS) expect(paths.has(p), `${p} has no page`).toBe(true) + }) + + it('renders a marked, complete table', () => { + const out = renderRoutes(rows) + expect(out).toContain('GENERATED BY scripts/generate-routes.mjs — DO NOT HAND EDIT') + expect(out).toContain("export const HomePage = lazy(() => import('@/pages/HomePage'))") + expect(out).toContain("{ path: '/login', Component: LoginPage },") + const listed = out.match(/^ {2}\{ path: /gm) ?? [] + expect(listed).toHaveLength(rows.length - 1) + const [pub, prot] = out.split('export const PROTECTED_ROUTES') + expect(pub).toContain("path: '/login'") + expect(prot).not.toContain("path: '/login'") + expect(prot).toContain("path: '/chat'") + }) + + it('rejects a page without a route header', () => { + expect(() => parsePages([{ file: 'XPage.tsx', source: '// nothing' }])).toThrow(/missing/) + }) + + it('rejects duplicate routes', () => { + expect(() => + parsePages([page('HomePage.tsx', '/'), page('APage.tsx', '/a'), page('BPage.tsx', '/a')]), + ).toThrow(/duplicate route \/a/) + }) + + it('requires a "/" page', () => { + expect(() => parsePages([page('APage.tsx', '/a')])).toThrow(/"\/"/) + }) +})