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:
commit
55968fbd73
8 changed files with 332 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
50
README.md
Normal file
50
README.md
Normal 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
37
bin/fetch-openapi.sh
Executable 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
76
bin/inject-hashed-filenames.py
Executable 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
47
bin/postprocess-openapi.py
Executable 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
27
package.json
Normal 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
64
src/vite-config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
28
templates/orval.config.template.ts
Normal file
28
templates/orval.config.template.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue