v0.1.0: fetch/postprocess/inject scripts + vite config factories

Bootstraps the shared frontend build glue for webapp-template-derived
projects:

  bin/fetch-openapi.sh         — pull Swagger JSON from a running backend
  bin/postprocess-openapi.py   — fix oatpp 1.3 rough edges before orval
  bin/inject-hashed-filenames.py — rewrite HTML tags, config-driven
  src/vite-config.ts           — defineAdminConfig / defineGuestConfig
  templates/orval.config.template.ts — starting point for derived repos

Package name @uschuster/webapp-scaffold. Consumed as a devDependency
through the internal Forgejo npm registry; binaries exposed for use in
package.json scripts. createCoreFetch + i18n deferred to v0.2 / v0.3.

Closes fewo-webapp#414

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 22:06:58 +02:00
commit 55968fbd73
8 changed files with 332 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
*.tgz
.DS_Store

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# @uschuster/webapp-scaffold
Shared frontend build glue for webapp-template-derived projects.
## What's in v0.1
| Path | Purpose |
|------|---------|
| `bin/fetch-openapi.sh` | Pull the oatpp Swagger spec from a running backend. Configurable via `OPENAPI_URL` / `APP_URL` / `APP_TEST_PORT`, optional Bearer via `APP_API_KEY`. JSON-validated. |
| `bin/postprocess-openapi.py` | Clean up oatpp 1.3's rough edges (missing `operationId`s, missing tags) before handing the spec to Orval. |
| `bin/inject-hashed-filenames.py` | Rewrite HTML script tags to point at Vite's manifest-declared hashed bundle. Config-driven so projects with multiple entry points (admin + guest) use a single invocation. |
| `src/vite-config.ts` | `defineAdminConfig({root, vendorChunks?, outDir?})` and `defineGuestConfig({root, entry?, vendorChunks?, outDir?})` helpers that bake in the `VITE_BASE`-driven prefix convention, manifest output, and the `static/{dist,guest/dist}` output layout the `StaticController` expects. |
| `templates/orval.config.template.ts` | Starting `orval.config.ts` for derived projects to copy-and-tweak. |
## Install
```json
{
"devDependencies": {
"@uschuster/webapp-scaffold": "^0.1.0"
}
}
```
Register the internal Forgejo npm registry (see your `~/.npmrc`):
```
@uschuster:registry=http://127.0.0.1:3000/api/packages/uwe.admin/npm/
```
## Consumer wiring
```ts
// frontend/vite.config.ts
import { defineAdminConfig } from '@uschuster/webapp-scaffold';
export default defineAdminConfig({ root: __dirname });
```
```sh
# frontend/package.json > scripts > codegen
webapp-scaffold-fetch-openapi && \
webapp-scaffold-postprocess-openapi && \
orval
```
## Roadmap
- **v0.2**`createCoreFetch` factory (the Orval-mutator `coreFetch` wrapper,
lifted from webapp-template, extended with `syncTables` + conflict hooks).
- **v0.3** — i18n system (de/en × formal/informal with fallback chain).

37
bin/fetch-openapi.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Fetch openapi.json from a backend for codegen.
#
# Usage:
# APP_API_KEY=... bash scripts/fetch-openapi.sh
#
# Resolution order for the source URL:
# 1. $OPENAPI_URL if set (explicit override)
# 2. $APP_URL + /api-docs/oas-3.0.0.json (oatpp swagger default)
# 3. http://127.0.0.1:${APP_TEST_PORT:-8101}/api-docs/oas-3.0.0.json
#
# The backend's openapi endpoint is auth-gated; pass $APP_API_KEY if the
# target requires it. For local codegen against a throwaway backend started
# with --allow-plaintext and an empty users table (+ SETUP_MODE), the
# anonymous pseudo-admin bypass means the API key is not required.
set -euo pipefail
URL=${OPENAPI_URL:-${APP_URL:-http://127.0.0.1:${APP_TEST_PORT:-8101}}/api-docs/oas-3.0.0.json}
AUTH_HEADER=()
if [[ -n ${APP_API_KEY:-} ]]; then
AUTH_HEADER=(-H "Authorization: Bearer $APP_API_KEY")
fi
OUT=${OPENAPI_OUT:-openapi.json}
echo "Fetching $URL$OUT"
curl -fsS "${AUTH_HEADER[@]}" "$URL" -o "$OUT"
# Verify it's actually JSON with an `openapi` key — oatpp sometimes returns
# HTML on 401 which would silently land here otherwise.
python3 -c "
import json, sys
d = json.load(open('$OUT'))
assert 'openapi' in d, 'not an OpenAPI spec (missing \"openapi\" key)'
print(f\" openapi={d['openapi']}, paths={len(d.get('paths', {}))}\")
"

76
bin/inject-hashed-filenames.py Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Rewrite HTML script tags to point at Vite's hashed production bundle.
Usage:
inject-hashed-filenames.py CONFIG_JSON [BUILD_DIR]
CONFIG_JSON is a path to a JSON file listing the entries to rewrite. The
schema mirrors the two hard-coded entries fewo-webapp had baked into an
earlier version of this script:
[
{
"manifest": "static/dist/.vite/manifest.json",
"html": "static/index.html",
"old_src": "/static/dist/app.js"
},
{
"manifest": "static/guest/dist/.vite/manifest.json",
"html": "static/guest/index.html",
"old_src": "/guest/dist/guest-app.js"
}
]
All paths are resolved relative to BUILD_DIR (defaults to the repo root
containing the config file's parent chain → `$PWD`).
"""
import json
import os
import sys
def inject(manifest_path: str, html_path: str, old_src: str) -> None:
if not os.path.exists(manifest_path):
print(f"skip: no manifest at {manifest_path}")
return
if not os.path.exists(html_path):
print(f"skip: no html at {html_path}")
return
with open(manifest_path) as f:
manifest = json.load(f)
for entry in manifest.values():
if not entry.get("isEntry"):
continue
hashed = entry["file"]
new_src = f"{os.path.dirname(old_src)}/{hashed}"
with open(html_path) as f:
html = f.read()
if old_src not in html:
print(f"skip: {old_src!r} not in {html_path}")
return
with open(html_path, "w") as f:
f.write(html.replace(old_src, new_src))
print(f"{old_src} -> {new_src}")
return
print(f"skip: no isEntry row in {manifest_path}")
def main() -> int:
if len(sys.argv) < 2:
print("usage: inject-hashed-filenames.py CONFIG_JSON [BUILD_DIR]", file=sys.stderr)
return 2
cfg_path = sys.argv[1]
build_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
with open(cfg_path) as f:
entries = json.load(f)
for e in entries:
inject(
os.path.join(build_dir, e["manifest"]),
os.path.join(build_dir, e["html"]),
e["old_src"],
)
return 0
if __name__ == "__main__":
sys.exit(main())

47
bin/postprocess-openapi.py Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Post-process openapi.json before handing it to Orval.
oatpp 1.3's Swagger generator produces a valid spec but with rough edges
that make the generated client less ergonomic:
- Missing operationIds on some endpoints orval falls back to path-based
names that collide. We synthesise an operationId from method + path.
- summary/description strings occasionally contain stray control characters.
- Some endpoints have no tags, which throws off orval's file-splitting.
Run between `fetch-openapi.sh` and `orval` (see package.json `codegen`).
"""
import json
import re
import sys
from pathlib import Path
SRC = Path("openapi.json")
if not SRC.exists():
sys.exit(f"{SRC} not found — run fetch-openapi.sh first")
spec = json.loads(SRC.read_text())
def op_id(method: str, path: str) -> str:
"""`getApiAnnouncementsEntityIdHistory` from `GET /api/announcements/{entity_id}/history`."""
parts = [method.lower()]
for seg in path.strip("/").split("/"):
if seg.startswith("{") and seg.endswith("}"):
parts.append(seg[1:-1])
else:
parts.append(seg)
return re.sub(r"[^A-Za-z0-9]+(\w)", lambda m: m.group(1).upper(),
"_".join(parts))
paths = spec.get("paths", {})
for path, methods in paths.items():
for method, op in methods.items():
if not isinstance(op, dict):
continue
op.setdefault("operationId", op_id(method, path))
op.setdefault("tags", [path.strip("/").split("/")[1] if "/" in path.strip("/") else "default"])
SRC.write_text(json.dumps(spec, indent=2))
print(f" postprocessed {SRC}{len(paths)} paths")

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@uschuster/webapp-scaffold",
"version": "0.1.0",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module",
"bin": {
"webapp-scaffold-fetch-openapi": "bin/fetch-openapi.sh",
"webapp-scaffold-postprocess-openapi": "bin/postprocess-openapi.py",
"webapp-scaffold-inject-hashes": "bin/inject-hashed-filenames.py"
},
"main": "src/vite-config.ts",
"exports": {
".": "./src/vite-config.ts",
"./orval-template": "./templates/orval.config.template.ts"
},
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],
"peerDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0"
},
"repository": {
"type": "git",
"url": "http://127.0.0.1:3000/uwe.admin/webapp-scaffold.git"
},
"license": "UNLICENSED",
"private": true
}

64
src/vite-config.ts Normal file
View file

@ -0,0 +1,64 @@
// Vite config factories shared across webapp-template-derived projects.
//
// Derived projects typically have two bundles:
// - the admin / owner SPA (`defineAdminConfig`)
// - the public guest site (`defineGuestConfig`)
// Both honour the same Apache-proxy-prefix baseUrl convention and keep
// their outputs under a conventional path so the `StaticController` and
// Apache vhost can serve them without per-project tweaks.
import type { UserConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export interface AdminConfigOptions {
/** Directory the vite.config.ts lives in — pass `__dirname` or `import.meta.dirname`. */
root: string;
/** Optional manual chunks for large vendor libs. */
vendorChunks?: Record<string, string[]>;
/** Override the default output dir (default: `<root>/../static/dist`). */
outDir?: string;
}
export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
const base = process.env.VITE_BASE || '/';
return {
base,
plugins: [react()],
build: {
outDir: opts.outDir ?? path.resolve(opts.root, '../static/dist'),
emptyOutDir: true,
manifest: true,
rollupOptions: opts.vendorChunks
? { output: { manualChunks: opts.vendorChunks } }
: undefined,
},
};
}
export interface GuestConfigOptions extends AdminConfigOptions {
/** Entry point relative to root (default: `src/guest/main.tsx`). */
entry?: string;
}
export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
const base = process.env.VITE_BASE || '/';
return {
base,
plugins: [react()],
build: {
outDir: opts.outDir ?? path.resolve(opts.root, '../static/guest/dist'),
emptyOutDir: true,
manifest: true,
rollupOptions: {
input: path.resolve(opts.root, opts.entry ?? 'src/guest/main.tsx'),
output: {
entryFileNames: 'guest-app.[hash].js',
chunkFileNames: 'guest-chunk-[hash].js',
assetFileNames: 'guest-asset-[hash][extname]',
manualChunks: opts.vendorChunks,
},
},
},
};
}

View file

@ -0,0 +1,28 @@
import { defineConfig } from 'orval';
// Generates a typed fetch-based client under src/generated/ from openapi.json.
// Consumers import the generated functions/types directly — never hand-roll
// fetches for documented endpoints (see CLAUDE.md: DRY-via-Orval).
//
// Regenerate with `npm run codegen` (fetches openapi.json from a running
// test backend first).
export default defineConfig({
app: {
input: { target: './openapi.json' },
output: {
mode: 'split',
target: './src/generated/api.ts',
schemas: './src/generated/models',
client: 'fetch',
baseUrl: { getBaseUrlFromSpecification: false },
httpClient: 'fetch',
override: {
mutator: {
path: './src/core-fetch.ts',
name: 'coreFetch',
},
},
},
},
});