`npm run sync` in web/ copies the build output (web/dist, from npm run build) to internal/frontend/dist, which the binary embeds. Go can't embed from outside the package's directory (web/ is also outside the Go module), hence the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles, and a binary built without syncing first serves no frontend. job-search serve serves server.frontend.dir if it is set, or else the embedded build, or warns that there is no frontend. scripts/build.sh builds and syncs the frontend before building, running npm ci first for a release or when web/node_modules is missing.
380 lines
20 KiB
Markdown
380 lines
20 KiB
Markdown
# Frontend code conventions
|
|
|
|
These are the conventions for the frontend in `web/`: a Vite, SolidJS,
|
|
Tailwind CSS (v4) and TypeScript app.
|
|
|
|
TypeScript, ESLint and Prettier check what they can (see
|
|
[Tooling](#tooling)); the rest are applied by hand and checked in review.
|
|
|
|
## Tooling
|
|
|
|
```bash
|
|
npm run check # typecheck, lint and format:check; run before committing
|
|
npm run typecheck # tsc -b: both tsconfigs (plain tsc would check neither)
|
|
npm run lint # eslint .
|
|
npm run format:check # prettier --check .
|
|
npm run format # prettier --write .: formats every file
|
|
npm run generate:api # regenerate src/types/api.ts from the backend's OpenAPI description
|
|
npm run build # typecheck, then vite build into dist/
|
|
npm run sync # copy dist/ to ../internal/frontend/dist/, which the backend embeds
|
|
npm run dev # dev server; /api and /ws go to the backend (API_PROXY_TARGET, default localhost:9393)
|
|
```
|
|
|
|
- **TypeScript** runs in `strict` mode, with `noUncheckedIndexedAccess`
|
|
(an indexed value may be `undefined` until checked). `tsconfig.app.json`
|
|
also sets `verbatimModuleSyntax`, `erasableSyntaxOnly`,
|
|
`noUnusedLocals`, `noUnusedParameters` and `noFallthroughCasesInSwitch`:
|
|
in particular, no TypeScript-only runtime syntax such as constructor
|
|
parameter properties or `enum`, and types are imported with
|
|
`import type`. `tsconfig.node.json` checks `vite.config.ts` and
|
|
`scripts/`.
|
|
- **ESLint** (`eslint.config.js`): ESLint's and typescript-eslint's
|
|
recommended rules, with type information, so it catches unsafe uses of
|
|
`any` and promises that are dropped; eslint-plugin-solid's rules;
|
|
eslint-comments' rules; simple-import-sort; and the rules below.
|
|
- **Prettier** (`.prettierrc.json`) formats everything: see
|
|
[Formatting](#formatting). `eslint-config-prettier` turns off the ESLint
|
|
rules that would disagree with it.
|
|
- Generated code (`src/types/api.ts`) isn't linted or formatted, and is
|
|
never edited by hand.
|
|
|
|
### Linters that check these conventions
|
|
|
|
| Rule | Checks |
|
|
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
| `simple-import-sort/imports`, `/exports` | Imports in two groups, each sorted ([Imports](#imports)); `--fix` sorts them |
|
|
| `no-restricted-imports` | Imports from another folder use `@/`, not `../` ([Imports](#imports)) |
|
|
| `@typescript-eslint/consistent-type-imports` | Types are imported as types, never as values ([Imports](#imports)) |
|
|
| `@typescript-eslint/naming-convention` | camelCase, PascalCase and UPPER_SNAKE_CASE where [Naming](#naming) says; backend keys are exempt |
|
|
| `@typescript-eslint/no-explicit-any`, `no-unsafe-*` | No `any`, and no untyped values used as if typed ([TypeScript](#typescript)) |
|
|
| `@typescript-eslint/explicit-module-boundary-types` | Exported functions have their parameter and return types written out ([TypeScript](#typescript)) |
|
|
| `@typescript-eslint/no-misused-promises`, `no-floating-promises` | Promises are awaited or handled, including in event handlers ([SolidJS](#solidjs)) |
|
|
| `solid/no-destructure`, `solid/reactivity` | Props aren't destructured, and reactive values are read where they can update ([SolidJS](#solidjs)) |
|
|
| `solid/prefer-for` | Lists use `<For>` ([SolidJS](#solidjs)) |
|
|
| `solid/no-innerhtml` | No `innerHTML` from data ([Security](#security)) |
|
|
| `@eslint-community/eslint-comments/require-description` | Every `eslint-disable` comment says why |
|
|
| `eqeqeq` | `===` and `!==`, never `==` |
|
|
|
|
ESLint's `no-unassigned-vars` is off for `.tsx` files: Solid assigns refs
|
|
itself (`ref={button}`), which ESLint can't see. Any other rule is turned
|
|
off only for a line, naming the rule and saying why after `--`:
|
|
|
|
```ts
|
|
// eslint-disable-next-line rule-name -- why the rule is wrong here
|
|
```
|
|
|
|
Most `no-unsafe-*` reports don't need one: give the `any` value a type of
|
|
`unknown` and narrow it, as for `JSON.parse`:
|
|
|
|
```ts
|
|
const parsed: unknown = JSON.parse(release.data);
|
|
if (isRelease(parsed)) { ... }
|
|
```
|
|
|
|
## Files and folders
|
|
|
|
| Folder | Holds |
|
|
| ----------------- | -------------------------------------------------------------------------------------------------- |
|
|
| `src/api/` | The only code that talks to the backend: HTTP (`client.ts`) and its WebSocket events (`events.ts`) |
|
|
| `src/types/` | The backend's types (generated, see [Backend types](#backend-types)) and the app's own |
|
|
| `src/primitives/` | Solid primitives: state and loading, no markup (`createX.ts`) |
|
|
| `src/components/` | Components, one exported component per file |
|
|
| `src/lib/` | Plain functions: no Solid, no DOM where avoidable |
|
|
| `scripts/` | Development scripts run by npm, in TypeScript (`generateApiTypes.ts`, `syncDist.ts`) |
|
|
|
|
- Components' files are PascalCase, named after the component
|
|
(`MarkMenu.tsx`). Other files are usually camelCase (`format.ts`); the
|
|
type files' PascalCase names (`Opportunities.ts`) are fine too.
|
|
- A file is named after what it exports, exactly: `OpportunityTable.tsx`
|
|
exports `OpportunityTable`. An import must name the file as it is
|
|
spelt on disk.
|
|
- The top of `src/` holds only the entry point (`index.tsx`), the app's
|
|
layout (`App.tsx`), the stylesheet (`index.css`) and Vite's type
|
|
declarations (`vite-env.d.ts`).
|
|
- `components/styles.ts` holds the Tailwind class strings shared by
|
|
several components (see [Styling](#styling)).
|
|
|
|
## Imports
|
|
|
|
Two groups, separated by a blank line: packages (`solid-js`, `node:fs`,
|
|
...) first, then the app's own modules, those from other folders (`@/`)
|
|
before those from the same folder (`./`). Each group is sorted by module;
|
|
a type import comes before the value import from the same module.
|
|
simple-import-sort checks this, and `npm run lint -- --fix` sorts them.
|
|
|
|
```ts
|
|
import { createSignal, onCleanup } from 'solid-js';
|
|
|
|
import { api } from '@/api/client';
|
|
import type { OpportunityMark } from '@/types/Annotations';
|
|
import { normaliseMarks } from '@/types/Annotations';
|
|
import { MARK_BG } from './styles';
|
|
```
|
|
|
|
- An import from another folder of `src` starts with `@/`, which is
|
|
`src/` (set in both `tsconfig.app.json` and `vite.config.ts`):
|
|
`@/api/client`, not `../api/client`. One from the same folder starts
|
|
with `./`. So an import reads the same from every file, and doesn't
|
|
change when the importing file moves. `../` isn't used
|
|
(`no-restricted-imports` reports it).
|
|
- Types are imported with `import type`, in their own statement rather
|
|
than inline (`import { api, type Api }`). `consistent-type-imports`
|
|
checks that types are imported as types; the separate statement is
|
|
kept by hand.
|
|
|
|
## Exports
|
|
|
|
- Exports are named: `export function MarkMenu`, not
|
|
`export default function`. A named export is imported under one name
|
|
everywhere, so renaming and searching are reliable.
|
|
- A default export only where a tool requires one.
|
|
|
|
## Naming
|
|
|
|
Names are descriptive: `opportunity`, `classification`, `controller`.
|
|
Short names are fine where the meaning is obvious from a few lines
|
|
around them: a one-line callback's parameter (`(mark) =>` or
|
|
`(m) =>`), an event (`e` or `event`), a caught error (`err`), a plain
|
|
index (`i`).
|
|
|
|
| Kind | Case | Example |
|
|
| ------------------------------------------------------------------ | ------------------------------- | --------------------------------------- |
|
|
| Variables, functions, props | camelCase | `toggleMark`, `opportunityTitle` |
|
|
| Components, types, interfaces | PascalCase | `MarkMenu`, `OpportunityAnnotation` |
|
|
| Module-level constants holding fixed data (numbers, lookups, keys) | UPPER_SNAKE_CASE | `MENU_WIDTH`, `MARK_BG`, `STORAGE_KEYS` |
|
|
| Tailwind class strings | camelCase | `buttonQuiet`, `fieldLabel` |
|
|
| Primitives | `create` + PascalCase | `createOpportunityAnnotations` |
|
|
| Event props | `on` + PascalCase | `onToggle`, `onChange` |
|
|
| Booleans | `is`, `has`, `can`, `should`... | `isSaving`, `hasDocuments` |
|
|
|
|
- A word keeps one case throughout a name: `opportunityTitle`, not
|
|
`OpportunityTitle` for a prop, and `OPPORTUNITY_MARKS`, not
|
|
`Opportunity_MARKS`. A local variable is never PascalCase
|
|
(`opportunities`, not `Opportunities`).
|
|
- Initialisms keep one case: `apiBase`, `API_BASE`, `uuid`, `OCDSRelease`.
|
|
- Backend field names are kept as the backend sends them (`buyer_name`,
|
|
`has_documents`): see [Backend types](#backend-types). A boolean's
|
|
prefix applies to the app's own names.
|
|
|
|
## TypeScript
|
|
|
|
- No `any`. A value of unknown shape is `unknown`, narrowed before use
|
|
(`err instanceof Error`, a type guard such as `isOpportunityMark`).
|
|
- A type assertion (`as T`) only where TypeScript can't know a fact the
|
|
code does, such as the shape of `fetch` JSON; never to silence an error.
|
|
Non-null assertions (`!`) only for elements that must exist, such as the
|
|
root element, and Solid refs assigned before use
|
|
(`let button!: HTMLButtonElement`).
|
|
- With `noUncheckedIndexedAccess`, a value read by index or key
|
|
(`marks[0]`, `byUuid[uuid]`) may be `undefined`: check it, or give a
|
|
default with `??`.
|
|
- Fixed lists are `as const` arrays, with a union type derived from them,
|
|
rather than an `enum` (which `erasableSyntaxOnly` forbids):
|
|
|
|
```ts
|
|
export const OPPORTUNITY_MARKS = ['hidden', 'skipped'] as const;
|
|
export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number];
|
|
```
|
|
|
|
- Object shapes are `interface`s; unions and derived types are `type`s.
|
|
- Exported functions have their parameter and return types written out
|
|
(`explicit-module-boundary-types`); others where the type isn't
|
|
obvious.
|
|
|
|
## Formatting
|
|
|
|
Prettier formats every file (`npm run format`), so formatting isn't
|
|
reviewed. Its settings: two-space indentation, semicolons, single quotes
|
|
in TypeScript and double quotes in JSX attributes, trailing commas, and a
|
|
100-character line. prettier-plugin-tailwindcss sorts the classes in
|
|
`class` strings into Tailwind's order, so the same classes always read the
|
|
same way.
|
|
|
|
Blank lines are still applied by hand: one between a file's top-level
|
|
declarations, and between the steps of a long function.
|
|
|
|
## SolidJS
|
|
|
|
Solid components run once; only reactive reads (signals, store fields,
|
|
`props.x`) update the page. The conventions keep reactivity working:
|
|
|
|
- **Props are never destructured**, in the parameter list or the body:
|
|
`props.marks`, not `({ marks })`. Destructuring reads the value once and
|
|
stops it updating. Derived values are functions:
|
|
`const marks = () => annotation().marks`.
|
|
- Default prop values come from `mergeProps`, and props passed on to a
|
|
child are separated with `splitProps`: both keep the props reactive,
|
|
where destructuring wouldn't.
|
|
- A component's props are an `interface Props` in its file. Callbacks are
|
|
`on…` props; components don't reach into shared state they weren't
|
|
given.
|
|
- Lists use `<For>`, conditions `<Show>` and `<Switch>`/`<Match>`, not
|
|
`.map()` or `&&` in JSX.
|
|
- State lives in primitives (`src/primitives/`): a `createX()` function
|
|
returning an interface of accessors and actions, documented on the
|
|
interface. Components call primitives; primitives don't render.
|
|
- Data is loaded with `createResource`, which tracks loading and errors
|
|
and refetches when its source changes. `<Suspense>` shows a placeholder
|
|
while resources load, and an `<ErrorBoundary>` around a part of the
|
|
page shows an error in it instead of breaking the whole page.
|
|
- Work started in a primitive or component is cleaned up with
|
|
`onCleanup`: listeners removed, timers cleared, and fetches aborted
|
|
through an `AbortController`. An aborted request isn't reported as an
|
|
error.
|
|
- A change may be applied optimistically, before the request, where
|
|
instant feedback matters and the request almost always succeeds (as
|
|
marks do in `createOpportunityAnnotations`). It is then undone if the
|
|
request fails, and the failure shown.
|
|
- An event handler doesn't return a promise: Solid ignores it, so a
|
|
rejection would go unnoticed. A handler calls an async function that
|
|
handles its own errors, and marks the call with `void`:
|
|
`onClick={() => void annotations.toggleMark(uuid, mark)}`.
|
|
|
|
## Styling
|
|
|
|
- Styles are Tailwind utility classes in `class`. Conditional classes use
|
|
`classList`. No CSS files beyond `src/index.css`.
|
|
- Class names are always written out in full in the source: Tailwind only
|
|
generates classes it can see, so never build them at runtime
|
|
(`` `bg-mark-${mark}` ``). A lookup from a value to its classes is a
|
|
record with every class written out, as `MARK_BG` in `styles.ts` is.
|
|
- Colours and fonts are theme tokens in the `@theme` block of
|
|
`src/index.css` (`bg-surface`, `text-muted`), not raw values. Dark mode
|
|
swaps the tokens under `.dark`, so components never use `dark:`
|
|
variants.
|
|
- Class strings used by more than one component go in
|
|
`components/styles.ts`; ones used by one component are constants in its
|
|
file.
|
|
|
|
## Backend types
|
|
|
|
The backend's types are generated from its OpenAPI 3.1 description
|
|
(`/api/openapi.json`) into `src/types/api.ts`, by `npm run generate:api`
|
|
(`scripts/generateApiTypes.ts`). They're never written by hand (with one
|
|
exception, the WebSocket events, below), so they can't drift from the
|
|
backend.
|
|
|
|
- `src/types/Opportunities.ts` gives the generated types the names the
|
|
app uses (`Opportunity`, `OpportunityPage`, `Problem`); code imports
|
|
them from there, not from `api.ts`.
|
|
- After a change to the backend's types or handlers, regenerate: run
|
|
`go generate ./...` in the repository, then `npm run generate:api`, and
|
|
commit `api.ts` with the change. With the backend running,
|
|
`npm run generate:api -- http://localhost:9393/api/openapi.json` reads
|
|
the description from it instead.
|
|
- Keys are the Go structs' `json` tags, snake_case included
|
|
(`latest_release_date`).
|
|
- A field the backend can send as `null` (a Go pointer, or a slice that
|
|
can be nil) is typed `| null`, from the description's `x-nullable`, so
|
|
TypeScript makes the code handle it: a list is read with `?? []`
|
|
(`opportunity.classifications ?? []`).
|
|
- Times are RFC 3339 strings (`DateTimeString`), converted to `Date` only
|
|
for display.
|
|
- The one exception is the WebSocket events (`src/types/Events.ts`): the
|
|
OpenAPI description only covers HTTP routes, so the events' envelopes are
|
|
written by hand, to match the backend's events, and use the generated
|
|
types for their contents. Messages are parsed as `unknown` and narrowed
|
|
(`src/api/events.ts`); unknown events are ignored.
|
|
|
|
## API calls
|
|
|
|
- All requests go through `src/api/client.ts`: one function per
|
|
operation on the exported `api` object, and one `request` helper that
|
|
builds the URL from `VITE_API_BASE_URL` (default `/api`), sets the
|
|
headers and turns a failed response into an `ApiError`.
|
|
- Every function that loads data takes an optional `AbortSignal`.
|
|
- Path segments taken from data are encoded with `encodeURIComponent`.
|
|
- Unsafe requests (PUT, DELETE, POST) send the CSRF token the backend
|
|
issues at `GET /api/csrf`, in the header it names; the client fetches
|
|
and keeps it, so callers don't handle it.
|
|
|
|
## Errors
|
|
|
|
- Errors are caught as `unknown` and turned into text with one helper
|
|
(`errorText`), never by assuming they are `Error`s.
|
|
- A message shown to the user says what didn't happen, in plain words,
|
|
and then the cause: `Your marks didn't load: …`. Plain or typographic
|
|
quotes and apostrophes are both fine.
|
|
- Errors are shown where the user is looking, with `role="alert"`, and can
|
|
be retried or dismissed; they're never only logged to the console.
|
|
|
|
## Security
|
|
|
|
- Data from the API is shown as text, never as HTML: no `innerHTML`
|
|
with it (`solid/no-innerhtml` reports one). Releases and descriptions
|
|
come from outside sources, so HTML in them could run scripts.
|
|
- Only settings that are safe to publish go in `VITE_` variables: Vite
|
|
builds them into the JavaScript every visitor downloads. Nothing
|
|
secret is ever in the frontend.
|
|
- `.env` holds local settings and isn't committed; `.env.example`
|
|
lists every variable, with a comment on what it does.
|
|
|
|
## Formatting values
|
|
|
|
- Dates, money and numbers are formatted for display by the functions in
|
|
`lib/format.ts`, with `Intl` and a fixed locale (`en-GB`) and currency
|
|
(`GBP`), so they read the same on every machine. Components don't call
|
|
`toLocaleString` or format by hand.
|
|
- A missing or zero value (a null date, the zero time) is shown as a dash
|
|
or left empty, not as a misleading default such as `01/01/0001`.
|
|
|
|
## Comments
|
|
|
|
- Exported functions, components, primitives, interfaces and their
|
|
non-obvious fields have a `/** … */` comment saying what they are or do.
|
|
- `//` comments explain why code is the way it is
|
|
(`// Tailwind only generates classes it can see …`), not what the next
|
|
line does.
|
|
- JSX comments use `{/* … */}`.
|
|
|
|
### Spelling
|
|
|
|
Comments, documentation and user-facing text use British English
|
|
("normalise", "colour", "behaviour"). Names that must match something
|
|
external keep its spelling (`Authorization`, CSS `color`).
|
|
|
|
## Accessibility
|
|
|
|
- Every interactive element is a real `<button>` (with `type="button"`),
|
|
`<input>`, `<label>` or link, so it works with a keyboard.
|
|
- Controls without visible text have an `aria-label`; one whose text is
|
|
ambiguous in a table row includes what it belongs to ("Marks for
|
|
<title>").
|
|
- Menus and popups set `aria-expanded` and `aria-controls`, close on
|
|
Escape, and return focus to the control that opened them.
|
|
- Status that changes on its own (loading, saving, errors) uses
|
|
`role="alert"`, `aria-busy` or a live region.
|
|
|
|
## Browser storage
|
|
|
|
- `localStorage` is only used through `lib/storage.ts`
|
|
(`readStored`, `writeStored`), which never throws: storage can be
|
|
disabled or full, and a preference then just isn't kept.
|
|
- Keys are listed in `STORAGE_KEYS`, prefixed with the app's name.
|
|
- Only preferences are stored (theme, panel state); data comes from the
|
|
API every time.
|
|
|
|
## Dependencies
|
|
|
|
- `package-lock.json` is committed, and installs use `npm ci`, so every
|
|
machine gets the same versions.
|
|
- Development tools (linters, formatters, generators) are pinned to exact
|
|
versions, as a new version can report new problems or generate
|
|
different code. The app's own dependencies use `^` ranges, held by the
|
|
lock file.
|
|
- A new or upgraded package is a release at least two weeks old, and is
|
|
added deliberately, in its own change.
|
|
|
|
## Tests
|
|
|
|
There are no tests yet. When they're added:
|
|
|
|
- They use Vitest, which shares Vite's configuration, and sit next to the
|
|
file they test as `name.test.ts`.
|
|
- They start with the code in `src/lib/` and `src/api/`, which has no UI
|
|
to render.
|
|
- Components are tested with `@solidjs/testing-library`, through what
|
|
the user sees and does (text, roles, clicks), not their internals.
|
|
- No ESLint plugin checks accessibility in Solid well, so component tests
|
|
run an accessibility check (`axe`) on what they render.
|