From 55968fbd73b80f69536ab3b613cc8d36c26f4319 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Tue, 21 Apr 2026 22:06:58 +0200 Subject: [PATCH] v0.1.0: fetch/postprocess/inject scripts + vite config factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 ++ README.md | 50 ++++++++++++++++++++ bin/fetch-openapi.sh | 37 +++++++++++++++ bin/inject-hashed-filenames.py | 76 ++++++++++++++++++++++++++++++ bin/postprocess-openapi.py | 47 ++++++++++++++++++ package.json | 27 +++++++++++ src/vite-config.ts | 64 +++++++++++++++++++++++++ templates/orval.config.template.ts | 28 +++++++++++ 8 files changed, 332 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/fetch-openapi.sh create mode 100755 bin/inject-hashed-filenames.py create mode 100755 bin/postprocess-openapi.py create mode 100644 package.json create mode 100644 src/vite-config.ts create mode 100644 templates/orval.config.template.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1127ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.tgz +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..1786e40 --- /dev/null +++ b/README.md @@ -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). diff --git a/bin/fetch-openapi.sh b/bin/fetch-openapi.sh new file mode 100755 index 0000000..22f2f73 --- /dev/null +++ b/bin/fetch-openapi.sh @@ -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', {}))}\") +" diff --git a/bin/inject-hashed-filenames.py b/bin/inject-hashed-filenames.py new file mode 100755 index 0000000..0afdddc --- /dev/null +++ b/bin/inject-hashed-filenames.py @@ -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()) diff --git a/bin/postprocess-openapi.py b/bin/postprocess-openapi.py new file mode 100755 index 0000000..552cfcb --- /dev/null +++ b/bin/postprocess-openapi.py @@ -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") diff --git a/package.json b/package.json new file mode 100644 index 0000000..623832d --- /dev/null +++ b/package.json @@ -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 +} diff --git a/src/vite-config.ts b/src/vite-config.ts new file mode 100644 index 0000000..f7a3a38 --- /dev/null +++ b/src/vite-config.ts @@ -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; + /** Override the default output dir (default: `/../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, + }, + }, + }, + }; +} diff --git a/templates/orval.config.template.ts b/templates/orval.config.template.ts new file mode 100644 index 0000000..4aa7a82 --- /dev/null +++ b/templates/orval.config.template.ts @@ -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', + }, + }, + }, + }, +});