feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
Release / release (push) Failing after 1m18s
Release / release (push) Failing after 1m18s
This commit is contained in:
commit
4505b37abc
30 files changed
+4826
No files matched your search
@@ -0,0 +1,45 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 26
|
||||||
|
registry-url: ${{ secrets.CI_URL }}/api/packages/${{ secrets.CI_OWNER }}/npm/
|
||||||
|
scope: '@koszewscy'
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci --no-fund
|
||||||
|
|
||||||
|
- name: Check
|
||||||
|
run: |
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
working-directory: dist
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
package="$(node -p 'const p = require("./package.json"); `${p.name}@${p.version}`')"
|
||||||
|
if npm view "$package" version >/dev/null 2>&1; then
|
||||||
|
echo "$package is already published, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
npm publish
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
/tmp/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||||
|
"typescript/triple-slash-reference": "off"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
## ADR-1: The toolkit is TypeScript source, linked into the applications
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: manage-ui and sql-query-webui share their server helpers and web components. The
|
||||||
|
applications run their servers with Node's type stripping and build their web code with Vite.
|
||||||
|
The toolkit is to be published to Gitea later; until then it is developed next to them.
|
||||||
|
|
||||||
|
Decision: The toolkit ships its `.ts` and `.tsx` files without a build, with three entry points:
|
||||||
|
`./server`, `./web` and `./vite`. The applications depend on the checkout by a `file:` path, which
|
||||||
|
npm links; Node runs the server helpers from the checkout's real path, where type stripping
|
||||||
|
applies, and Vite compiles the web code. The server helpers import only Node's modules at run
|
||||||
|
time, and types of Fastify; the Vite helpers import only types of Vite, taking `loadEnv`'s answer
|
||||||
|
from the application. The web code's React, MUI and Emotion come from the application, which
|
||||||
|
dedupes them with `TOOLKIT_RESOLVE`. The peer dependencies are optional, as an application uses
|
||||||
|
only some of the entry points, and are development dependencies for the toolkit's own checks.
|
||||||
|
|
||||||
|
Consequences: The applications need the checkout at `../sk-react-toolkit`, and their container
|
||||||
|
builds cannot install it until it is published. Node refuses type stripping in `node_modules`,
|
||||||
|
so the published package is compiled (ADR-2).
|
||||||
|
|
||||||
|
## ADR-2: The published package is built in dist/ and published to Gitea by a tag
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: An installed package lives in `node_modules`, where Node does not strip types, so the
|
||||||
|
published package has to be JavaScript, while the linked checkout stays TypeScript source. The
|
||||||
|
repository is on Gitea, which has an npm registry per owner and Actions mostly compatible with
|
||||||
|
GitHub's.
|
||||||
|
|
||||||
|
Decision: `build.mjs` compiles the toolkit with `tsconfig.build.json`, which rewrites the `.ts`
|
||||||
|
import extensions to `.js`, into `dist/` with declarations, and writes `dist/package.json` with
|
||||||
|
exports of the compiled files and declarations; the package published is `dist/`, while the
|
||||||
|
source `package.json` stays private with exports of the source. The name is
|
||||||
|
`@koszewscy/sk-react-toolkit`, scoped to the owner, so an application takes only this scope from
|
||||||
|
Gitea; it imports it as `sk-react-toolkit` by an npm alias, so the switch from the checkout to the
|
||||||
|
published package changes only the dependency. The package is built and published only by the
|
||||||
|
`Release` workflow, which follows linux-cloud-router's: it runs for each tag and on demand, with
|
||||||
|
the secrets `CI_URL`, `CI_OWNER` and `CI_TOKEN`, and skips a version already published.
|
||||||
|
`actions/setup-node` gives npm the registry of the package's scope and the token, so the package
|
||||||
|
carries no registry; `release.mjs` refuses a tag `x.y.z` that is not the version of
|
||||||
|
`package.json`.
|
||||||
|
|
||||||
|
Consequences: A release raises the version in both CHANGELOG.md and `package.json`, and a
|
||||||
|
re-run, or a tag `x.y.z-n`, publishes nothing. The declarations of `renderApp` reference the
|
||||||
|
declaration of the `virtual:font` module, which `build.mjs` copies into `dist/`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
|
||||||
|
|
||||||
|
- `sk-react-toolkit/server`: `HttpError`, `strict`, the error and not-found handlers, the log,
|
||||||
|
`fail`, `gitVersion`, `exitOnSignals` and `readJsonObject`.
|
||||||
|
- `sk-react-toolkit/web`: `request` and `ApiError`, the theme and `renderApp`, the font styles,
|
||||||
|
`Card`, `ThemeMenu`, `SwitcherMenu`, `TextInput`, `RowDialog`, `useValidation` and
|
||||||
|
`createBrowserSettings`.
|
||||||
|
- `sk-react-toolkit/vite`: the `VITE_FONT` font plugin and the resolve options that keep one copy
|
||||||
|
of React, MUI and Emotion.
|
||||||
|
- `npm run build` builds the published package, `@koszewscy/sk-react-toolkit`, compiled to
|
||||||
|
JavaScript with declarations, in `dist/`. The `Release` workflow of Gitea Actions checks, builds
|
||||||
|
and publishes each tag to the Gitea npm registry, skipping a version already published.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Instructions for this repository
|
||||||
|
|
||||||
|
## CHANGELOG.md
|
||||||
|
|
||||||
|
- `CHANGELOG.md` records the changes of each version, newest entry first. `release.mjs` reads it to commit, tag and push.
|
||||||
|
- An entry is a `## x.y.z` heading, a blank line, one summary line, a blank line, and the changes as `-` items.
|
||||||
|
- The summary line is the commit message: a type prefix as in the git history (`feature:`, `fix:`, `update:`), then one sentence. It must not start with `-` or `#`.
|
||||||
|
- An entry whose version is already tagged (`git tag`) is released and must not be changed. Changes after it go into a new entry above it.
|
||||||
|
- Record every change you make in the newest untagged entry; when there is none, add one. Raise the patch version for fixes and the minor version for features.
|
||||||
|
- Items describe what changed for the user, not the reasoning; reasons go to `ADR.md`.
|
||||||
|
- Do not run `release.mjs` without `-n`; committing, tagging and pushing need the user's approval.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# sk-react-toolkit
|
||||||
|
|
||||||
|
The shared code of the web tools built as a Fastify server in TypeScript and a React application
|
||||||
|
with MUI built by Vite, such as azure-platform's manage-ui and sql-query-webui. It is TypeScript
|
||||||
|
source: Node 26 runs the server helpers, and the application's Vite build compiles the web code.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
The package is `@koszewscy/sk-react-toolkit`, published to the npm registry of Gitea,
|
||||||
|
`https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/`. An application imports it as
|
||||||
|
`sk-react-toolkit`, by an alias. Its `.npmrc` names the registry of the scope:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
@koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "sk-react-toolkit": "npm:@koszewscy/sk-react-toolkit@^0.1.0" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
While the toolkit and an application change together, the application depends on the checkout
|
||||||
|
next to its own repository instead, the TypeScript source:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "sk-react-toolkit": "file:../sk-react-toolkit" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
npm links the checkout into `node_modules`, and `npm ci` keeps the link. The web application adds
|
||||||
|
the toolkit's resolve options and font plugin to its `vite.config.ts`, so React, MUI and Emotion
|
||||||
|
come from its own `node_modules`, and allows the dev server to read the checkout:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { font, fontChoice, TOOLKIT_RESOLVE } from 'sk-react-toolkit/vite'
|
||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))],
|
||||||
|
resolve: TOOLKIT_RESOLVE,
|
||||||
|
server: { fs: { allow: ['..', '../../sk-react-toolkit'] } },
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
|
||||||
|
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
|
||||||
|
code; `vite` for the Vite helpers.
|
||||||
|
|
||||||
|
## sk-react-toolkit/server
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `HttpError` | an error answered with its status and `{ error }`, with `problems` and a `detail` message for a refused write |
|
||||||
|
| `strict(properties)` | the JSON schema of an object with exactly these properties, all required |
|
||||||
|
| `errorHandler(log)` | Fastify's error handler: `{ error }`, or `{ error, problems, message }`; unexpected 5xx errors logged with their stack |
|
||||||
|
| `notFoundHandler(webDir, buildHint)` | 404 for `/api` paths, `index.html` for the others, 503 with the hint while the frontend is not built |
|
||||||
|
| `createLog`, `timestampsOn`, `QUIET`, `Log` | the log of a server, with optional timestamps and requests; `QUIET` for tests |
|
||||||
|
| `fail(message)` | prints the error in red and ends the process with status 2 |
|
||||||
|
| `gitVersion(dir, variable)` | `git describe` of the checkout, else the environment variable, else `dev` |
|
||||||
|
| `exitOnSignals(close)` | closes the server on Ctrl-C and SIGTERM, ending the process after 5 seconds at most |
|
||||||
|
| `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file |
|
||||||
|
|
||||||
|
## sk-react-toolkit/web
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `request`, `ApiError`, `Validation` | a JSON call to the backend, its error with the problems of a refused write, and the answer of a validate endpoint |
|
||||||
|
| `appTheme(colorSchemes?)`, `renderApp(options)` | the theme (light, dark and system, Regular font, small text fields) and the startup that retries the first call while showing Connecting |
|
||||||
|
| `REGULAR`, `MONOSPACED`, `verbatim` | the font styles and the attributes of inputs the browser must not change |
|
||||||
|
| `Card` | a centered panel with a title and a message |
|
||||||
|
| `ThemeMenu` | the button cycling light, dark and system theme |
|
||||||
|
| `SwitcherMenu`, `SwitcherItem` | a button naming the item chosen, whose menu chooses another, such as a page or a connection |
|
||||||
|
| `TextInput` | a text field with clear and show/hide buttons |
|
||||||
|
| `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook |
|
||||||
|
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
|
||||||
|
|
||||||
|
## sk-react-toolkit/vite
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` |
|
||||||
|
| `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
|
||||||
|
and `npm run lint` check the code. `npm run build` (`build.mjs`) builds the published package in
|
||||||
|
`dist/`: the code compiled to JavaScript with its declarations, README.md, CHANGELOG.md and a
|
||||||
|
`package.json` whose exports name the compiled files.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
`./release.mjs` commits, tags the version of the newest CHANGELOG.md entry and pushes `main` with
|
||||||
|
the tag; `-n` prints the git commands instead. The version of a release of the code, `x.y.z`, is
|
||||||
|
that of `package.json`, so a release raises both. A tag `x.y.z-n` releases changes that are not
|
||||||
|
code changes; its version is already published, so its run publishes nothing.
|
||||||
|
|
||||||
|
The `Release` workflow, `.gitea/workflows/release.yml`, builds and publishes the package on Gitea
|
||||||
|
Actions for each tag and on demand: it installs the dependencies, typechecks and lints the code,
|
||||||
|
builds the package and publishes `dist/` to the npm registry of the owner `CI_OWNER` on the server
|
||||||
|
`CI_URL`, with `CI_TOKEN`, a token with the package scope; all three are secrets of the
|
||||||
|
repository. A version already published is skipped, as the registry refuses the same version
|
||||||
|
again.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Builds the published package in dist/: the toolkit compiled to JavaScript with its declarations, the declaration of
|
||||||
|
// the font plugin's virtual module, README.md, CHANGELOG.md and a package.json whose exports name the compiled files.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = import.meta.dirname;
|
||||||
|
const DIST = join(ROOT, 'dist');
|
||||||
|
const ENTRIES = ['server', 'web', 'vite'];
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`build: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(DIST, { recursive: true, force: true });
|
||||||
|
const tsc = spawnSync(join(ROOT, 'node_modules', '.bin', 'tsc'), ['-p', join(ROOT, 'tsconfig.build.json')], { stdio: 'inherit' });
|
||||||
|
if (tsc.error) fail(`tsc: ${tsc.error.message}; run npm ci first`);
|
||||||
|
if (tsc.status !== 0) process.exit(tsc.status ?? 1);
|
||||||
|
|
||||||
|
// The compiled renderApp keeps its import of virtual:font, so its declarations reference the module's declaration.
|
||||||
|
copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-font.d.ts'));
|
||||||
|
const renderApp = join(DIST, 'web', 'renderApp.d.ts');
|
||||||
|
writeFileSync(renderApp, `/// <reference path="./virtual-font.d.ts" />\n${readFileSync(renderApp, 'utf8')}`);
|
||||||
|
|
||||||
|
for (const file of ['README.md', 'CHANGELOG.md']) copyFileSync(join(ROOT, file), join(DIST, file));
|
||||||
|
|
||||||
|
const source = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||||
|
const published = {
|
||||||
|
name: source.name,
|
||||||
|
version: source.version,
|
||||||
|
description: source.description,
|
||||||
|
type: source.type,
|
||||||
|
engines: source.engines,
|
||||||
|
repository: source.repository,
|
||||||
|
exports: Object.fromEntries(ENTRIES.map((entry) => [`./${entry}`, { types: `./${entry}/index.d.ts`, default: `./${entry}/index.js` }])),
|
||||||
|
peerDependencies: source.peerDependencies,
|
||||||
|
peerDependenciesMeta: source.peerDependenciesMeta,
|
||||||
|
};
|
||||||
|
writeFileSync(join(DIST, 'package.json'), `${JSON.stringify(published, null, 2)}\n`);
|
||||||
|
console.log(`${published.name}@${published.version} in ${DIST}`);
|
||||||
Generated
+3749
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.koszewscy.waw.pl/koszewscy/sk-react-toolkit.git"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
"./server": "./server/index.ts",
|
||||||
|
"./web": "./web/index.ts",
|
||||||
|
"./vite": "./vite/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fastify/static": "^10.1.7",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.5.0",
|
||||||
|
"@mui/material": "^9.5.0",
|
||||||
|
"fastify": "^5.12.7",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"vite": "^8.3.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@fastify/static": { "optional": true },
|
||||||
|
"fastify": { "optional": true },
|
||||||
|
"vite": { "optional": true },
|
||||||
|
"@emotion/react": { "optional": true },
|
||||||
|
"@emotion/styled": { "optional": true },
|
||||||
|
"@mui/icons-material": { "optional": true },
|
||||||
|
"@mui/material": { "optional": true },
|
||||||
|
"react": { "optional": true },
|
||||||
|
"react-dom": { "optional": true }
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fastify/static": "^10.1.7",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.5.0",
|
||||||
|
"@mui/material": "^9.5.0",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"fastify": "^5.12.7",
|
||||||
|
"oxlint": "^1.89.0",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Commits everything, tags the version of the newest CHANGELOG.md entry and pushes main with the tags.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
|
||||||
|
const CHANGELOG = `${import.meta.dirname}/CHANGELOG.md`;
|
||||||
|
|
||||||
|
const usage = `Usage: release.mjs [-h|--help] [-n|--dry-run] [--no-push] [-m|--message <message>]
|
||||||
|
|
||||||
|
-m, --message commit message; default: the summary line of the newest CHANGELOG.md entry,
|
||||||
|
the line between blank lines right after its "## x.y.z" or "## x.y.z-n" heading
|
||||||
|
|
||||||
|
The tag is the version of the heading: x.y.z for a release of the code, which must be the version
|
||||||
|
of package.json and which the build workflow publishes, x.y.z-n, n from 2, for a later release
|
||||||
|
of the same code with changes that are not code changes, which is not published.
|
||||||
|
-n, --dry-run print the git commands instead of running them
|
||||||
|
--no-push commit and tag, but do not push`;
|
||||||
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: {
|
||||||
|
message: { type: 'string', short: 'm' },
|
||||||
|
'dry-run': { type: 'boolean', short: 'n', default: false },
|
||||||
|
'no-push': { type: 'boolean', default: false },
|
||||||
|
help: { type: 'boolean', short: 'h', default: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.help) {
|
||||||
|
console.log(usage);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`release: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs git in the repository, or prints the command on a dry run; stops with git's exit status when it fails.
|
||||||
|
function git(...args) {
|
||||||
|
if (values['dry-run']) {
|
||||||
|
console.log(['git', ...args].map((arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`)).join(' '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { status } = spawnSync('git', args, { cwd: import.meta.dirname, stdio: 'inherit' });
|
||||||
|
if (status !== 0) process.exit(status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The newest entry: its version from the first "## x.y.z" or "## x.y.z-n" heading, n from 2, and its summary line when
|
||||||
|
// the heading is followed by a blank line, one line that is not a "-" item, and another blank line.
|
||||||
|
const lines = readFileSync(CHANGELOG, 'utf8').split('\n');
|
||||||
|
const heading = lines.findIndex((line) => line.startsWith('## '));
|
||||||
|
if (heading === -1) fail(`${CHANGELOG} has no "## x.y.z" entry`);
|
||||||
|
const tag = lines[heading].slice(3).trim();
|
||||||
|
const [version, revision, ...rest] = tag.split('-');
|
||||||
|
const parts = version.split('.');
|
||||||
|
const number = (part) => part !== '' && String(Number(part)) === part;
|
||||||
|
if (rest.length > 0 || parts.length !== 3 || !parts.every(number) || (revision !== undefined && !(number(revision) && Number(revision) >= 2))) {
|
||||||
|
fail(`${CHANGELOG}: "${tag}" is not a version such as 0.8.0 or 0.8.0-2`);
|
||||||
|
}
|
||||||
|
// The tag of a release of the code is the version of package.json, which the build workflow publishes.
|
||||||
|
const packageVersion = JSON.parse(readFileSync(`${import.meta.dirname}/package.json`, 'utf8')).version;
|
||||||
|
if (revision === undefined && version !== packageVersion) {
|
||||||
|
fail(`${CHANGELOG}: ${tag} is not the version of package.json, ${packageVersion}`);
|
||||||
|
}
|
||||||
|
const [above, line, below] = lines.slice(heading + 1, heading + 4);
|
||||||
|
const summary = above?.trim() === '' && below?.trim() === '' && line?.trim() && !line.startsWith('-') && !line.startsWith('#') ? line.trim() : undefined;
|
||||||
|
|
||||||
|
const message = values.message ?? summary;
|
||||||
|
if (!message) {
|
||||||
|
fail(`no commit message: pass -m, or put a summary line between blank lines right after the newest heading of ${CHANGELOG}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: import.meta.dirname }).status === 0) {
|
||||||
|
fail(`tag ${tag} exists; add a new CHANGELOG.md entry first`);
|
||||||
|
}
|
||||||
|
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', message);
|
||||||
|
git('tag', tag);
|
||||||
|
if (!values['no-push']) git('push', 'origin', 'main', '--tags');
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type {} from '@fastify/static'
|
||||||
|
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { Log } from './log.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error answered with its status code and message as { error }. A refused write also carries the problems by
|
||||||
|
* field and a message about what it would break elsewhere, answered as { error, problems, message }.
|
||||||
|
*/
|
||||||
|
export class HttpError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
problems?: Record<string, string>
|
||||||
|
detail?: string
|
||||||
|
|
||||||
|
constructor(statusCode: number, message: string, problems?: Record<string, string>, detail?: string) {
|
||||||
|
super(message)
|
||||||
|
this.statusCode = statusCode
|
||||||
|
this.problems = problems
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A JSON schema of an object with exactly the properties given, all required. With Fastify's removeAdditional off, a
|
||||||
|
* body with another property is refused with 400.
|
||||||
|
*/
|
||||||
|
export function strict(properties: Record<string, unknown>) {
|
||||||
|
return { type: 'object', required: Object.keys(properties), additionalProperties: false, properties }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error handler of setErrorHandler: the status of the error, 500 when it has none, with { error }, and the
|
||||||
|
* problems and message of an HttpError that has problems. An error of 500 or more that is not an HttpError is logged
|
||||||
|
* with its stack. A request that fails its schema is answered with "The request is not valid".
|
||||||
|
*/
|
||||||
|
export function errorHandler(log: Log) {
|
||||||
|
return (error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const status = error.statusCode ?? 500
|
||||||
|
if (status >= 500 && !(error instanceof HttpError)) {
|
||||||
|
log.error(`${request.method} ${request.url}: ${error.stack ?? error.message}`)
|
||||||
|
}
|
||||||
|
const message = error.validation ? `The request is not valid (${error.message}).` : error.message
|
||||||
|
if (error instanceof HttpError && error.problems !== undefined) {
|
||||||
|
return reply.code(status).send({ error: message, problems: error.problems, message: error.detail ?? '' })
|
||||||
|
}
|
||||||
|
return reply.code(status).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The not-found handler of setNotFoundHandler for an application of webDir, the built frontend served by
|
||||||
|
* @fastify/static: a path under /api is 404 with { error }; any other path answers index.html, so the frontend's
|
||||||
|
* routes work on a reload, or 503 with buildHint while the frontend is not built.
|
||||||
|
*/
|
||||||
|
export function notFoundHandler(webDir: string, buildHint: string) {
|
||||||
|
return (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (request.url === '/api' || request.url.startsWith('/api/')) {
|
||||||
|
return reply.code(404).send({ error: `'${request.method} ${request.url.split('?')[0]}' is not an endpoint of this tool.` })
|
||||||
|
}
|
||||||
|
if (!existsSync(join(webDir, 'index.html'))) {
|
||||||
|
return reply.code(503).type('text/plain; charset=utf-8').send(`${buildHint}\n`)
|
||||||
|
}
|
||||||
|
return reply.type('text/html; charset=utf-8').sendFile('index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
|
||||||
|
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
|
||||||
|
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** The log of a server: events to stdout, warnings and errors in colour to stderr, and requests when they are on. */
|
||||||
|
export type Log = {
|
||||||
|
event: (text: string) => void
|
||||||
|
warn: (text: string) => void
|
||||||
|
error: (text: string) => void
|
||||||
|
request: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A log that writes nothing, for tests. */
|
||||||
|
export const QUIET: Log = { event() {}, warn() {}, error() {}, request() {} }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The log of a server. With timestamps each line starts with the local time with milliseconds; requests logs each
|
||||||
|
* request to stderr, otherwise request writes nothing.
|
||||||
|
*/
|
||||||
|
export function createLog(options: { timestamps: boolean; requests: boolean }): Log {
|
||||||
|
const line = (text: string) => {
|
||||||
|
if (!options.timestamps) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (value: number, size = 2) => String(value).padStart(size, '0')
|
||||||
|
return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)} ${text}`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
event: (text) => console.log(line(text)),
|
||||||
|
warn: (text) => console.error(`\x1b[33m${line(`warning: ${text}`)}\x1b[0m`),
|
||||||
|
error: (text) => console.error(`\x1b[31m${line(`error: ${text}`)}\x1b[0m`),
|
||||||
|
request: options.requests ? (text) => console.error(line(text)) : () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether --timestamps auto|on|off asks for timestamps: auto only when stdout is a terminal. */
|
||||||
|
export function timestampsOn(choice: string) {
|
||||||
|
return choice === 'on' || (choice === 'auto' && process.stdout.isTTY === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the message in red to stderr as an error and ends the process with status 2. */
|
||||||
|
export function fail(message: string): never {
|
||||||
|
console.error(`\x1b[31merror: ${message}\x1b[0m`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The git description of the checkout of dir now, a commit or a tag changing it without a restart; outside a
|
||||||
|
* checkout, or without git, the variable named fallbackVariable, which a container image sets, or "dev".
|
||||||
|
*/
|
||||||
|
export function gitVersion(dir: string, fallbackVariable: string) {
|
||||||
|
const described = spawnSync('git', ['describe', '--tags', '--always', '--dirty'], { cwd: dir, encoding: 'utf8' })
|
||||||
|
return described.status === 0 ? described.stdout.trim() : process.env[fallbackVariable] || 'dev'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ctrl-C and SIGTERM, which watch mode sends on a restart, call close and end the process when it settles, or after 5
|
||||||
|
* seconds.
|
||||||
|
*/
|
||||||
|
export function exitOnSignals(close: () => Promise<unknown>) {
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref()
|
||||||
|
void close().then(() => process.exit(0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON object of the file at path, and its version, the SHA-256 of the bytes. With missingAsEmpty a missing file
|
||||||
|
* reads as {}, whose version is that of "{}". A file that cannot be read, invalid JSON or another value than an
|
||||||
|
* object throws with the reason.
|
||||||
|
*/
|
||||||
|
export function readJsonObject(path: string, options: { missingAsEmpty: boolean }): { data: Record<string, unknown>; version: string } {
|
||||||
|
let bytes: Buffer
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
bytes = options.missingAsEmpty && !existsSync(path) ? Buffer.from('{}') : readFileSync(path)
|
||||||
|
data = JSON.parse(bytes.toString('utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${path} cannot be read: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
|
throw new Error(`${path} must hold a JSON object`)
|
||||||
|
}
|
||||||
|
return { data: data as Record<string, unknown>, version: createHash('sha256').update(bytes).digest('hex') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["server", "web", "vite"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
/** The choices of VITE_FONT. */
|
||||||
|
export const FONTS = ['cdn', 'bundled', 'none'] as const
|
||||||
|
|
||||||
|
export type Font = (typeof FONTS)[number]
|
||||||
|
|
||||||
|
const FONT_MODULE = 'virtual:font'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the fonts of the toolkit's fonts.ts as VITE_FONT selects: cdn adds the Google Fonts links for Roboto and
|
||||||
|
* Roboto Mono to index.html, bundled imports @fontsource/roboto through the virtual:font module, none adds nothing; a
|
||||||
|
* font not provided falls back to the system fonts of its stack. The toolkit's renderApp imports virtual:font.
|
||||||
|
*/
|
||||||
|
export function font(choice: Font): Plugin {
|
||||||
|
const resolved = `\0${FONT_MODULE}`
|
||||||
|
return {
|
||||||
|
name: 'font',
|
||||||
|
resolveId(id) {
|
||||||
|
return id === FONT_MODULE ? resolved : undefined
|
||||||
|
},
|
||||||
|
load(id) {
|
||||||
|
if (id !== resolved) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return choice === 'bundled' ? ['300', '400', '500', '700'].map((weight) => `import '@fontsource/roboto/${weight}.css'`).join('\n') : ''
|
||||||
|
},
|
||||||
|
transformIndexHtml() {
|
||||||
|
if (choice !== 'cdn') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } },
|
||||||
|
{ tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: true } },
|
||||||
|
{ tag: 'link', attrs: { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@300;400;500;700&display=swap' } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VITE_FONT of env, the variables Vite's loadEnv answers, cdn when it is not set; another value throws. */
|
||||||
|
export function fontChoice(env: Record<string, string>): Font {
|
||||||
|
const choice = env.VITE_FONT ?? 'cdn'
|
||||||
|
if (!FONTS.includes(choice as Font)) {
|
||||||
|
throw new Error(`VITE_FONT=${choice} is not one of ${FONTS.join(', ')}.`)
|
||||||
|
}
|
||||||
|
return choice as Font
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolve options of an application that uses the toolkit's web code through a link: the toolkit's imports of
|
||||||
|
* React, MUI and Emotion are resolved from the application's own node_modules, so the page has one copy of each.
|
||||||
|
*/
|
||||||
|
export const TOOLKIT_RESOLVE = {
|
||||||
|
dedupe: ['react', 'react-dom', '@mui/material', '@mui/icons-material', '@mui/system', '@emotion/react', '@emotion/styled'],
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
/** A centered panel with a title and a message, for states before the application can render. */
|
||||||
|
export default function Card(props: { title: string; message: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', p: 3 }}>
|
||||||
|
<Paper sx={{ maxWidth: 480, p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
{props.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{props.message}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'
|
||||||
|
import { useState, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { ApiError, type Validation } from './api.ts'
|
||||||
|
import { useValidation } from './useValidation.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that edits one row. The server validates the value as it changes (validatePath); onSave writes it and
|
||||||
|
* the dialog closes when it succeeds. fields renders the inputs from the value, a setter and the problems by field;
|
||||||
|
* below shows extra information from the validation, such as subnet placements.
|
||||||
|
*/
|
||||||
|
export default function RowDialog<T, V extends Validation = Validation>(props: {
|
||||||
|
title: string
|
||||||
|
initial: T
|
||||||
|
validatePath: string
|
||||||
|
saveLabel: string
|
||||||
|
onSave: (value: T) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
fields: (value: T, set: (next: T) => void, problems: Record<string, string>) => ReactNode
|
||||||
|
below?: (result: V | null) => ReactNode
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState<T>(props.initial)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const validation = useValidation<V>(props.validatePath, value)
|
||||||
|
const problems = validation.result?.problems ?? {}
|
||||||
|
const message = validation.result?.message ?? ''
|
||||||
|
const blocked = validation.pending || Object.keys(problems).length > 0 || message !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(value)
|
||||||
|
} catch (caught) {
|
||||||
|
const failure = caught as ApiError
|
||||||
|
setError([failure.message, failure.detail].filter(Boolean).join('\n'))
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{props.title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{validation.error && <Alert severity="error">{validation.error}</Alert>}
|
||||||
|
{message && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{message}</Alert>}
|
||||||
|
{props.fields(value, setValue, problems)}
|
||||||
|
{props.below?.(validation.result)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>
|
||||||
|
{props.saveLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
|
||||||
|
import { Button, ListItemIcon, ListItemText, Menu, MenuItem, Typography } from '@mui/material'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { MONOSPACED } from './fonts.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item of a SwitcherMenu: its key, the label and icon of the button and the menu, and optionally a second line in
|
||||||
|
* the Monospaced font, a hint at the right end, such as the key that opens it, and whether it can be chosen.
|
||||||
|
*/
|
||||||
|
export type SwitcherItem<K> = {
|
||||||
|
key: K
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
secondary?: string
|
||||||
|
hint?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button naming the item chosen, current, with its icon, whose menu chooses another with onChoose. title is the
|
||||||
|
* button's tooltip.
|
||||||
|
*/
|
||||||
|
export default function SwitcherMenu<K>(props: { items: SwitcherItem<K>[]; current: SwitcherItem<K>; title?: string; onChoose: (key: K) => void }) {
|
||||||
|
const { items, current } = props
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={current.icon}
|
||||||
|
endIcon={<ArrowDropDown />}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={anchor !== null}
|
||||||
|
title={props.title}
|
||||||
|
onClick={(event) => setAnchor(event.currentTarget)}
|
||||||
|
>
|
||||||
|
{current.label}
|
||||||
|
</Button>
|
||||||
|
<Menu anchorEl={anchor} open={anchor !== null} onClose={() => setAnchor(null)}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={index}
|
||||||
|
selected={item.key === current.key}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setAnchor(null)
|
||||||
|
props.onChoose(item.key)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} secondary={item.secondary} slotProps={{ secondary: { sx: MONOSPACED } }} />
|
||||||
|
{item.hint !== undefined && <Typography variant="body2" color="text.secondary" sx={{ ml: 3 }}>{item.hint}</Typography>}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Clear from '@mui/icons-material/Clear'
|
||||||
|
import Visibility from '@mui/icons-material/Visibility'
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||||
|
import { IconButton, InputAdornment, TextField } from '@mui/material'
|
||||||
|
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||||
|
import { verbatim } from './fonts.ts'
|
||||||
|
|
||||||
|
/** Keeps the focus in the input when one of its buttons is pressed. */
|
||||||
|
function keepFocus(event: MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field with a clear button, a show/hide button for secrets, and several lines for 'lines'. The browser
|
||||||
|
* may not change what is typed (no spell check, capitalisation or completion). A small field shows no label; the
|
||||||
|
* label remains its accessible name.
|
||||||
|
*/
|
||||||
|
export default function TextInput(props: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
kind?: 'text' | 'secret' | 'lines'
|
||||||
|
problem?: string
|
||||||
|
help?: string
|
||||||
|
disabled?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
|
start?: ReactNode
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onBlur?: () => void
|
||||||
|
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void
|
||||||
|
}) {
|
||||||
|
const { label, value, kind = 'text', problem, help, disabled = false, onChange } = props
|
||||||
|
const [shown, setShown] = useState(false)
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label={props.size === 'small' ? undefined : label}
|
||||||
|
value={value}
|
||||||
|
size={props.size ?? (kind === 'lines' ? 'medium' : undefined)}
|
||||||
|
fullWidth
|
||||||
|
onBlur={props.onBlur}
|
||||||
|
onKeyDown={props.onKeyDown}
|
||||||
|
autoFocus={props.autoFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
error={problem !== undefined}
|
||||||
|
helperText={props.size === 'small' ? problem : problem || help || ' '}
|
||||||
|
multiline={kind === 'lines'}
|
||||||
|
minRows={kind === 'lines' ? 2 : undefined}
|
||||||
|
type={kind === 'secret' && !shown ? 'password' : 'text'}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { ...verbatim, 'aria-label': label },
|
||||||
|
input: {
|
||||||
|
startAdornment: props.start === undefined ? undefined : <InputAdornment position="start">{props.start}</InputAdornment>,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
{kind === 'secret' && (
|
||||||
|
<IconButton aria-label={shown ? `hide ${label}` : `show ${label}`} disabled={disabled} onClick={() => setShown(!shown)} onMouseDown={keepFocus} onMouseUp={keepFocus}>
|
||||||
|
{shown ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{value !== '' && (
|
||||||
|
<IconButton aria-label={`clear ${label}`} disabled={disabled} edge="end" onClick={() => onChange('')} onMouseDown={keepFocus} onMouseUp={keepFocus}>
|
||||||
|
<Clear />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import DarkMode from '@mui/icons-material/DarkMode'
|
||||||
|
import LightMode from '@mui/icons-material/LightMode'
|
||||||
|
import SettingsBrightness from '@mui/icons-material/SettingsBrightness'
|
||||||
|
import { IconButton, Tooltip, useColorScheme } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Mode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
const NEXT: Record<Mode, Mode> = { light: 'dark', dark: 'system', system: 'light' }
|
||||||
|
|
||||||
|
const ICONS: Record<Mode, ReactNode> = { light: <LightMode />, dark: <DarkMode />, system: <SettingsBrightness /> }
|
||||||
|
|
||||||
|
/** An icon button cycling light, dark and system theme. MUI stores the choice; mode is undefined on the first render. */
|
||||||
|
export default function ThemeMenu() {
|
||||||
|
const { mode, setMode } = useColorScheme()
|
||||||
|
if (!mode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title={`Theme: ${mode}. Switch to ${NEXT[mode]}.`}>
|
||||||
|
<IconButton aria-label={`theme ${mode}, switch to ${NEXT[mode]}`} onClick={() => setMode(NEXT[mode])}>
|
||||||
|
{ICONS[mode]}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* The answer of a validate endpoint: problems maps a field of the validated value to a message; message describes what
|
||||||
|
* the value would break elsewhere, or is empty.
|
||||||
|
*/
|
||||||
|
export type Validation = {
|
||||||
|
problems: Record<string, string>
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed call: the server's message and status, with the problems and message of a refused write (422). */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
problems: Record<string, string>
|
||||||
|
detail: string
|
||||||
|
|
||||||
|
constructor(message: string, status: number, problems: Record<string, string> = {}, detail = '') {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
this.problems = problems
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls the backend and returns the decoded JSON; a failed call throws ApiError with the backend's message. */
|
||||||
|
export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string; problems?: Record<string, string>; message?: string } | null
|
||||||
|
throw new ApiError(payload?.error ?? `${method} ${path} failed (${response.status}).`, response.status, payload?.problems, payload?.message)
|
||||||
|
}
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The switches a browser keeps for an application, each off until set: keys maps each setting to its localStorage key.
|
||||||
|
* browserSetting reads one, setBrowserSetting keeps it and tells every user of useBrowserSetting, which follows it as
|
||||||
|
* it changes in this tab or in another tab of the browser. event names the event a change dispatches on window.
|
||||||
|
*/
|
||||||
|
export function createBrowserSettings<K extends string>(keys: Record<K, string>, event: string) {
|
||||||
|
function browserSetting(setting: K) {
|
||||||
|
return window.localStorage.getItem(keys[setting]) === 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrowserSetting(setting: K, enabled: boolean) {
|
||||||
|
window.localStorage.setItem(keys[setting], enabled ? 'on' : 'off')
|
||||||
|
window.dispatchEvent(new Event(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(changed: () => void) {
|
||||||
|
window.addEventListener(event, changed)
|
||||||
|
window.addEventListener('storage', changed)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(event, changed)
|
||||||
|
window.removeEventListener('storage', changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBrowserSetting(setting: K) {
|
||||||
|
return useSyncExternalStore(subscribe, () => browserSetting(setting))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { browserSetting, setBrowserSetting, useBrowserSetting }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* The two font styles of the page: Regular, the theme's font of every element, and Monospaced, at 90% of the size of
|
||||||
|
* the surrounding text, for values read character by character, such as IDs, addresses, names of variables, code and
|
||||||
|
* the output of tools.
|
||||||
|
*/
|
||||||
|
export const REGULAR = '"Roboto", "Helvetica", "Arial", sans-serif'
|
||||||
|
export const MONOSPACED = {
|
||||||
|
fontFamily: '"Roboto Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: '90%',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input attributes for values that must not be changed by the browser, shown in the Monospaced font. */
|
||||||
|
export const verbatim = { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'none', autoComplete: 'off', style: MONOSPACED }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { ApiError, request, type Validation } from './api.ts'
|
||||||
|
export { createBrowserSettings } from './browserSettings.ts'
|
||||||
|
export { default as Card } from './Card.tsx'
|
||||||
|
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
|
||||||
|
export { appTheme, renderApp } from './renderApp.tsx'
|
||||||
|
export { default as RowDialog } from './RowDialog.tsx'
|
||||||
|
export { default as SwitcherMenu, type SwitcherItem } from './SwitcherMenu.tsx'
|
||||||
|
export { default as TextInput } from './TextInput.tsx'
|
||||||
|
export { default as ThemeMenu } from './ThemeMenu.tsx'
|
||||||
|
export { useValidation } from './useValidation.ts'
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/// <reference path="./virtual-font.d.ts" />
|
||||||
|
import 'virtual:font'
|
||||||
|
import { CssBaseline, ThemeProvider, createTheme, type ThemeOptions } from '@mui/material'
|
||||||
|
import { StrictMode, type ReactNode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import Card from './Card.tsx'
|
||||||
|
import { REGULAR } from './fonts.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme of the applications: light, dark and system modes, the choice stored in localStorage by MUI, the Regular
|
||||||
|
* font on every element and text fields of the small size. colorSchemes replaces the plain light and dark schemes,
|
||||||
|
* such as to add palette entries of a component.
|
||||||
|
*/
|
||||||
|
export function appTheme(colorSchemes: ThemeOptions['colorSchemes'] = { dark: true }) {
|
||||||
|
return createTheme({
|
||||||
|
colorSchemes,
|
||||||
|
cssVariables: { colorSchemeSelector: 'class' },
|
||||||
|
typography: { fontFamily: REGULAR },
|
||||||
|
components: { MuiTextField: { defaultProps: { size: 'small' } } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the application into #root with the theme and the fonts of the font plugin: app with what load answers,
|
||||||
|
* typically the backend's information. load is tried up to 20 times, 500 ms apart, showing Connecting, and then Not
|
||||||
|
* available with the last error.
|
||||||
|
*/
|
||||||
|
export async function renderApp<T>(options: { theme: ReturnType<typeof createTheme>; load: () => Promise<T>; app: (loaded: T) => ReactNode }) {
|
||||||
|
const root = createRoot(document.getElementById('root')!)
|
||||||
|
const render = (node: ReactNode) => {
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider theme={options.theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{node}
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let lastError = ''
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
try {
|
||||||
|
render(options.app(await options.load()))
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
lastError = (error as Error).message
|
||||||
|
render(<Card title="Connecting" message="Waiting for the backend to answer." />)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render(<Card title="Not available" message={lastError} />)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { request, type Validation } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the server to validate value at path (a .../validate endpoint) 250 ms after it last
|
||||||
|
* changed. pending is true until the answer for the current value arrives; until then the previous result stays.
|
||||||
|
*/
|
||||||
|
export function useValidation<T extends Validation>(path: string | null, value: unknown): { result: T | null; error: string; pending: boolean } {
|
||||||
|
const key = path === null ? '' : `${path}\n${JSON.stringify(value)}`
|
||||||
|
const [answer, setAnswer] = useState<{ key: string; result: T | null; error: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (key === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [target, body] = [key.slice(0, key.indexOf('\n')), JSON.parse(key.slice(key.indexOf('\n') + 1))]
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
request<T>('POST', target, { value: body })
|
||||||
|
.then((result) => setAnswer({ key, result, error: '' }))
|
||||||
|
.catch((caught: Error) => setAnswer({ key, result: null, error: caught.message }))
|
||||||
|
}, 250)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
return { result: answer?.result ?? null, error: answer?.error ?? '', pending: key !== '' && answer?.key !== key }
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// The module the font plugin of sk-react-toolkit/vite provides; it holds the font imports VITE_FONT selects.
|
||||||
|
declare module 'virtual:font' {}
|
||||||
Reference in new issue
Block a user