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
94 changes: 94 additions & 0 deletions .github/workflows/frontend-vite.yml
Original file line number Diff line number Diff line change
@@ -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/<name>), 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/<name>/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
36 changes: 33 additions & 3 deletions frontend-vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,47 @@ react-router `<Route>`. 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/<name>` deps, so `nself-org/packages` must sit next to
this repo (`<parent>/nchat`, `<parent>/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 <Routes>: 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)
Expand Down
5 changes: 5 additions & 0 deletions frontend-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 26 additions & 0 deletions frontend-vite/scripts/generate-routes.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
100 changes: 100 additions & 0 deletions frontend-vite/scripts/routes-lib.mjs
Original file line number Diff line number Diff line change
@@ -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 <RequireAuth> 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 — <path>" 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<ComponentType>
}

${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')}
]
`
}
59 changes: 59 additions & 0 deletions frontend-vite/scripts/routes-lib.test.mjs
Original file line number Diff line number Diff line change
@@ -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(/"\/"/)
})
})
Loading