createI18n({ bundles, defaultLocale, defaultTone, initialLocale,
initialTone, onMiss }) → { t, locale, tone, setLocale, setTone,
subscribe, getSnapshot }
Resolution order:
<cur-locale>-<cur-tone>
→ <cur-locale>
→ <default-locale>-<default-tone>
→ <default-locale>
→ raw key (onMiss logs first occurrence)
Typed keys via keyof on the caller-supplied Bundle generic. Tone
bundles are Partial<B> so overrides only need the strings that differ
from the locale bundle — the defaultLocale bundle is the only one
expected to be complete.
React bindings isolated in src/i18n-react.ts so the core ships without
a React peer dep. SSR-safe: constructor takes initial locale/tone
rather than reading from window.
Typecheck clean under strict tsc (ES2020/DOM).
Closes fewo-webapp#416
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.8 KiB
Markdown
102 lines
3.8 KiB
Markdown
# @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
|
||
```
|
||
|
||
## `createCoreFetch` (v0.2)
|
||
|
||
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and
|
||
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns
|
||
such a function, with credentials/CSRF headers / 204 / content-type
|
||
handling already wired, and hooks for project-specific concerns:
|
||
|
||
```ts
|
||
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';
|
||
|
||
export const coreFetch = createCoreFetch({
|
||
baseUrl: import.meta.env.BASE_URL,
|
||
syncTables: new Set(['bookings', 'persons', 'contacts']),
|
||
onEnqueue: (req) => syncQueue.push(req), // returns true ⇒ skip network
|
||
on401: () => window.location.assign('/admin'),
|
||
on409: (_req, body) => ({ conflict: JSON.parse(body) }),
|
||
});
|
||
```
|
||
|
||
## i18n (v0.3)
|
||
|
||
Two axes: **locale** (`de`, `en`, …) × **tone** (`formal`, `informal`).
|
||
Bundles are keyed by `<locale>` (fallback) and `<locale>-<tone>` (primary);
|
||
the resolver walks `<cur-locale>-<cur-tone>` → `<cur-locale>` →
|
||
`<default-locale>-<default-tone>` → `<default-locale>` → the raw key
|
||
(missing keys log via an `onMiss` hook instead of breaking render).
|
||
|
||
```ts
|
||
// Define bundles — only strings that differ between tones need a
|
||
// tone-specific entry. Everything else lives in the locale bundle.
|
||
import { createI18n } from '@uschuster/webapp-scaffold/i18n';
|
||
import { useI18nStore } from '@uschuster/webapp-scaffold/i18n-react'; // React-only
|
||
|
||
const bundles = {
|
||
'de': { greeting: 'Willkommen', see_more: 'Mehr anzeigen' },
|
||
'de-informal': { greeting: 'Hi!' },
|
||
'en': { greeting: 'Welcome', see_more: 'See more' },
|
||
} as const;
|
||
|
||
export const i18n = createI18n<typeof bundles['de']>({
|
||
bundles,
|
||
defaultLocale: 'de',
|
||
defaultTone: 'formal',
|
||
onMiss: (key, loc, tone) =>
|
||
console.warn(`[i18n] missing ${String(key)} @ ${loc}-${tone}`),
|
||
});
|
||
|
||
// React — components re-render on setLocale / setTone:
|
||
export const useI18n = () => useI18nStore(i18n);
|
||
|
||
// In a component:
|
||
const { t, setTone } = useI18n();
|
||
return <button onClick={() => setTone('informal')}>{t('greeting')}</button>;
|
||
```
|
||
|
||
SSR-safe: pass `initialLocale` / `initialTone` from the server render.
|
||
Tree-shakeable: only the bundles you import ship in the bundle.
|