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