@podosoft/podokit 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/dev.d.ts +26 -0
- package/dist/dev.js +297 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +82 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +7 -1
- package/dist/locale.d.ts +22 -0
- package/dist/locale.js +184 -0
- package/dist/lockfile.js +4 -0
- package/dist/templates/fullstack-nest-svelte/README.md +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -11
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
- package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -0
- package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
- package/dist/templates/todo/README.md +42 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/todo/apps/web/vite.config.ts +3 -9
- package/dist/templates/todo/compose.dev.yaml +6 -11
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/todo/dot-env.docker +2 -2
- package/dist/templates/todo/dot-gitignore +3 -0
- package/dist/templates/todo/dot-podokit/dev.json +4 -0
- package/dist/templates/todo/tests/README.md +10 -4
- package/dist/update.js +23 -5
- package/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
- package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
- package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
package/dist/locale.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeLocaleCode = normalizeLocaleCode;
|
|
4
|
+
exports.listLocales = listLocales;
|
|
5
|
+
exports.validateLocale = validateLocale;
|
|
6
|
+
exports.addLocale = addLocale;
|
|
7
|
+
exports.setLocaleEnabled = setLocaleEnabled;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const WEB_I18N = (0, node_path_1.join)("apps", "web", "src", "lib", "i18n");
|
|
11
|
+
function definitionsDir(projectRoot) {
|
|
12
|
+
return (0, node_path_1.join)(projectRoot, WEB_I18N, "locales");
|
|
13
|
+
}
|
|
14
|
+
function catalogsDir(projectRoot) {
|
|
15
|
+
return (0, node_path_1.join)(projectRoot, WEB_I18N, "catalogs");
|
|
16
|
+
}
|
|
17
|
+
function appCatalogDir(projectRoot) {
|
|
18
|
+
return (0, node_path_1.join)(catalogsDir(projectRoot), "app");
|
|
19
|
+
}
|
|
20
|
+
function normalizeLocaleCode(value) {
|
|
21
|
+
try {
|
|
22
|
+
const normalized = Intl.getCanonicalLocales(value)[0];
|
|
23
|
+
if (!normalized)
|
|
24
|
+
throw new Error("missing locale");
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`Invalid locale code "${value}". Use a BCP 47 language tag such as en, ko, or pt-BR.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readJson(path) {
|
|
32
|
+
const value = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
34
|
+
throw new Error(`Expected a JSON object: ${path}`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function readDefinition(path) {
|
|
39
|
+
const value = readJson(path);
|
|
40
|
+
const code = typeof value.code === "string" ? normalizeLocaleCode(value.code) : "";
|
|
41
|
+
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
42
|
+
const direction = value.direction;
|
|
43
|
+
const enabled = value.enabled;
|
|
44
|
+
if (!code || !name || (direction !== "ltr" && direction !== "rtl") || typeof enabled !== "boolean") {
|
|
45
|
+
throw new Error(`Invalid locale definition: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
if (`${code}.json` !== (0, node_path_1.basename)(path)) {
|
|
48
|
+
throw new Error(`Locale definition filename must match its canonical code: ${code}.json`);
|
|
49
|
+
}
|
|
50
|
+
return { code, name, direction, enabled };
|
|
51
|
+
}
|
|
52
|
+
function listLocales(projectRoot) {
|
|
53
|
+
const dir = definitionsDir(projectRoot);
|
|
54
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
55
|
+
throw new Error("This project does not contain PodoKit JSON locale definitions.");
|
|
56
|
+
return (0, node_fs_1.readdirSync)(dir)
|
|
57
|
+
.filter((name) => name.endsWith(".json"))
|
|
58
|
+
.map((name) => readDefinition((0, node_path_1.join)(dir, name)))
|
|
59
|
+
.sort((left, right) => left.code.localeCompare(right.code));
|
|
60
|
+
}
|
|
61
|
+
function merge(target, source) {
|
|
62
|
+
const result = { ...target };
|
|
63
|
+
for (const [key, value] of Object.entries(source)) {
|
|
64
|
+
const previous = result[key];
|
|
65
|
+
if (previous && typeof previous === "object" && !Array.isArray(previous) &&
|
|
66
|
+
value && typeof value === "object" && !Array.isArray(value)) {
|
|
67
|
+
result[key] = merge(previous, value);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
result[key] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
function composedCatalog(projectRoot, code) {
|
|
76
|
+
const root = catalogsDir(projectRoot);
|
|
77
|
+
if (!(0, node_fs_1.existsSync)(root))
|
|
78
|
+
return {};
|
|
79
|
+
const sources = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
|
|
80
|
+
.filter((entry) => entry.isDirectory())
|
|
81
|
+
.map((entry) => entry.name)
|
|
82
|
+
.sort((left, right) => {
|
|
83
|
+
const leftApp = left === "app" ? 1 : 0;
|
|
84
|
+
const rightApp = right === "app" ? 1 : 0;
|
|
85
|
+
return leftApp - rightApp || left.localeCompare(right);
|
|
86
|
+
});
|
|
87
|
+
let catalog = {};
|
|
88
|
+
for (const source of sources) {
|
|
89
|
+
const path = (0, node_path_1.join)(root, source, `${code}.json`);
|
|
90
|
+
if ((0, node_fs_1.existsSync)(path))
|
|
91
|
+
catalog = merge(catalog, readJson(path));
|
|
92
|
+
}
|
|
93
|
+
return catalog;
|
|
94
|
+
}
|
|
95
|
+
function flatten(value, prefix = "", out = new Map()) {
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
if (value.length === 0)
|
|
98
|
+
out.set(prefix, value);
|
|
99
|
+
for (const [index, child] of value.entries()) {
|
|
100
|
+
flatten(child, `${prefix}[${index}]`, out);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (value && typeof value === "object") {
|
|
104
|
+
for (const [key, child] of Object.entries(value)) {
|
|
105
|
+
flatten(child, prefix ? `${prefix}.${key}` : key, out);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
out.set(prefix, value);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function placeholders(value) {
|
|
114
|
+
if (typeof value !== "string")
|
|
115
|
+
return [];
|
|
116
|
+
return [...value.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? "").sort();
|
|
117
|
+
}
|
|
118
|
+
function validateLocale(projectRoot, rawCode) {
|
|
119
|
+
const code = normalizeLocaleCode(rawCode);
|
|
120
|
+
const definitionPath = (0, node_path_1.join)(definitionsDir(projectRoot), `${code}.json`);
|
|
121
|
+
if (!(0, node_fs_1.existsSync)(definitionPath))
|
|
122
|
+
throw new Error(`Locale ${code} has not been added.`);
|
|
123
|
+
const definition = readDefinition(definitionPath);
|
|
124
|
+
const base = new Map([...flatten(composedCatalog(projectRoot, "en"))].filter(([, value]) => value !== ""));
|
|
125
|
+
const selected = flatten(composedCatalog(projectRoot, code));
|
|
126
|
+
const missing = [];
|
|
127
|
+
for (const [key, baseValue] of base) {
|
|
128
|
+
if (!selected.has(key) || selected.get(key) === "") {
|
|
129
|
+
missing.push(key);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const selectedValue = selected.get(key);
|
|
133
|
+
const baseKind = Array.isArray(baseValue) ? "array" : typeof baseValue;
|
|
134
|
+
const selectedKind = Array.isArray(selectedValue) ? "array" : typeof selectedValue;
|
|
135
|
+
if (baseKind !== selectedKind)
|
|
136
|
+
throw new Error(`${code}:${key} must be ${baseKind}, received ${selectedKind}`);
|
|
137
|
+
if (placeholders(baseValue).join(",") !== placeholders(selectedValue).join(",")) {
|
|
138
|
+
throw new Error(`${code}:${key} must preserve placeholders ${placeholders(baseValue).join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const total = base.size;
|
|
142
|
+
const translated = total - missing.length;
|
|
143
|
+
return {
|
|
144
|
+
definition,
|
|
145
|
+
translated,
|
|
146
|
+
total,
|
|
147
|
+
percent: total === 0 ? 100 : Math.round((translated / total) * 100),
|
|
148
|
+
missing,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function defaultLocaleName(code) {
|
|
152
|
+
try {
|
|
153
|
+
return new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return code;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function addLocale(projectRoot, rawCode, options = {}) {
|
|
160
|
+
const code = normalizeLocaleCode(rawCode);
|
|
161
|
+
const definitionPath = (0, node_path_1.join)(definitionsDir(projectRoot), `${code}.json`);
|
|
162
|
+
const catalogPath = (0, node_path_1.join)(appCatalogDir(projectRoot), `${code}.json`);
|
|
163
|
+
if ((0, node_fs_1.existsSync)(definitionPath) || (0, node_fs_1.existsSync)(catalogPath))
|
|
164
|
+
throw new Error(`Locale ${code} already exists.`);
|
|
165
|
+
const definition = {
|
|
166
|
+
code,
|
|
167
|
+
name: options.name?.trim() || defaultLocaleName(code),
|
|
168
|
+
direction: options.direction ?? "ltr",
|
|
169
|
+
enabled: false,
|
|
170
|
+
};
|
|
171
|
+
(0, node_fs_1.mkdirSync)(definitionsDir(projectRoot), { recursive: true });
|
|
172
|
+
(0, node_fs_1.mkdirSync)(appCatalogDir(projectRoot), { recursive: true });
|
|
173
|
+
(0, node_fs_1.writeFileSync)(definitionPath, `${JSON.stringify(definition, null, 2)}\n`);
|
|
174
|
+
(0, node_fs_1.writeFileSync)(catalogPath, "{}\n");
|
|
175
|
+
return definition;
|
|
176
|
+
}
|
|
177
|
+
function setLocaleEnabled(projectRoot, rawCode, enabled) {
|
|
178
|
+
const coverage = validateLocale(projectRoot, rawCode);
|
|
179
|
+
if (!enabled && coverage.definition.code === "en")
|
|
180
|
+
throw new Error("The PodoKit English fallback cannot be deactivated.");
|
|
181
|
+
const definition = { ...coverage.definition, enabled };
|
|
182
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(definitionsDir(projectRoot), `${definition.code}.json`), `${JSON.stringify(definition, null, 2)}\n`);
|
|
183
|
+
return { ...coverage, definition };
|
|
184
|
+
}
|
package/dist/lockfile.js
CHANGED
|
@@ -45,6 +45,9 @@ const WALK_IGNORE = new Set([
|
|
|
45
45
|
exports.DEFAULT_OWNED_GLOBS = [
|
|
46
46
|
"apps/web/src/routes/**",
|
|
47
47
|
"apps/web/src/lib/components/ui/**",
|
|
48
|
+
// Locale definitions and app messages are application-owned extension points.
|
|
49
|
+
"apps/web/src/lib/i18n/locales/**",
|
|
50
|
+
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
48
51
|
// Owned Nest DI extension slot — override providers here; update never touches it.
|
|
49
52
|
"apps/api/src/app.extensions.ts",
|
|
50
53
|
// AI agent guidance — yours to customize; update never touches it.
|
|
@@ -62,6 +65,7 @@ exports.DEFAULT_OWNED_GLOBS = [
|
|
|
62
65
|
".devcontainer/**",
|
|
63
66
|
".env.docker",
|
|
64
67
|
"infra/traefik/**",
|
|
68
|
+
".podokit/dev.json",
|
|
65
69
|
];
|
|
66
70
|
const TEXT_EXT = /\.(json|md|ts|tsx|js|mjs|cjs|css|html|yml|yaml|txt|env|svelte|gitignore|example)$/i;
|
|
67
71
|
function podokitDir(projectRoot) {
|
|
@@ -8,10 +8,49 @@ Full-stack TypeScript starter generated with [PodoKit](https://github.com/podoso
|
|
|
8
8
|
|
|
9
9
|
## Getting started
|
|
10
10
|
|
|
11
|
+
### Recommended: containerized development
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
14
|
{{packageManager}} install
|
|
13
15
|
cp .env.example .env
|
|
16
|
+
npx @podosoft/podokit dev watch
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Open the URL printed by the command; a new project defaults to
|
|
20
|
+
**http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
|
|
21
|
+
source of truth if you change the hostname. The first running PodoKit project starts
|
|
22
|
+
one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
|
|
23
|
+
route by that hostname. Even a single app uses
|
|
24
|
+
the same topology, with one route and no project-specific host port.
|
|
25
|
+
|
|
26
|
+
Run lifecycle and container commands from a second terminal:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @podosoft/podokit dev url
|
|
30
|
+
npx @podosoft/podokit dev ps
|
|
31
|
+
npx @podosoft/podokit dev logs
|
|
32
|
+
npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
|
|
33
|
+
npx @podosoft/podokit dev down
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If installed modules require extra services, use their profiles on the initial
|
|
37
|
+
watch command instead:
|
|
14
38
|
|
|
39
|
+
```bash
|
|
40
|
+
npx @podosoft/podokit dev watch \
|
|
41
|
+
--profile cache --profile storage --profile queue
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pass the same profile flags to other lifecycle commands when applicable. `dev down`
|
|
45
|
+
removes this project's stack and route. If it is the final registered
|
|
46
|
+
project, it also removes the shared gateway and network. Source changes are synced
|
|
47
|
+
through Compose Watch, including Vite HMR on the same portless browser origin.
|
|
48
|
+
|
|
49
|
+
### Alternative: host processes
|
|
50
|
+
|
|
51
|
+
Use this loop when you want only the web and API processes on the host:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
15
54
|
# start local PostgreSQL + Redis
|
|
16
55
|
docker compose -f infra/docker/docker-compose.yml up -d
|
|
17
56
|
|
|
@@ -35,6 +74,9 @@ generate and run a migration:
|
|
|
35
74
|
Want a worked example? Generate the `todo` template instead:
|
|
36
75
|
`npx @podosoft/podokit create my-app --template todo`.
|
|
37
76
|
|
|
77
|
+
For multi-project routing, container profiles, migrations, and OAuth over a
|
|
78
|
+
stable HTTPS tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
|
|
79
|
+
|
|
38
80
|
## Deploy
|
|
39
81
|
|
|
40
82
|
Docker Compose in `infra/docker`; example k3s manifests in `infra/k3s`
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Localization
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Locale definitions live in `locales/<code>.json`. Product copy and intentional
|
|
4
|
+
module overrides live in the app-owned `catalogs/app/<code>.json` files.
|
|
4
5
|
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
The admin-dashboard module installs the composed JSON message runtime and its
|
|
7
|
+
complete English and Korean catalogs. Use `podo locale add`, `validate`, and
|
|
8
|
+
`activate` to extend it. See the generated `$podokit-add-locale` skill for the
|
|
9
|
+
complete workflow.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -2,15 +2,10 @@ import { sveltekit } from "@sveltejs/kit/vite";
|
|
|
2
2
|
import tailwindcss from "@tailwindcss/vite";
|
|
3
3
|
import { defineConfig } from "vite";
|
|
4
4
|
|
|
5
|
-
// Inside the dev container
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// HMR client must target that same port — otherwise the ws connects to :80,
|
|
9
|
-
// fails, and Vite silently falls back to a full page reload on every change
|
|
10
|
-
// (which wipes in-progress form input). compose.dev.yaml injects
|
|
11
|
-
// VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
|
|
5
|
+
// Inside the dev container, bind to all interfaces and accept local gateway or
|
|
6
|
+
// HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
|
|
7
|
+
// browser origin so the same configuration works for both entry points.
|
|
12
8
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
13
|
-
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
|
|
14
9
|
|
|
15
10
|
export default defineConfig({
|
|
16
11
|
plugins: [tailwindcss(), sveltekit()],
|
|
@@ -19,7 +14,6 @@ export default defineConfig({
|
|
|
19
14
|
host: true,
|
|
20
15
|
port: 5001,
|
|
21
16
|
allowedHosts: true,
|
|
22
|
-
hmr: { clientPort: hmrClientPort },
|
|
23
17
|
}
|
|
24
18
|
: undefined,
|
|
25
19
|
});
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# Containerized development environment.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# --profile
|
|
3
|
+
# podo dev watch # core stack
|
|
4
|
+
# podo dev watch --profile cache --profile storage \
|
|
5
|
+
# --profile queue # with modules
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# containerized stacks can run at once without colliding — the web container reads
|
|
10
|
-
# the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
|
|
7
|
+
# A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
|
|
8
|
+
# to this stack. The legacy per-project Traefik service is opt-in only.
|
|
11
9
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
12
10
|
# on the internal network, so this stack never collides with other projects.
|
|
13
11
|
#
|
|
@@ -24,6 +22,7 @@ name: {{projectName}}-dev
|
|
|
24
22
|
|
|
25
23
|
services:
|
|
26
24
|
traefik:
|
|
25
|
+
profiles: [podokit-legacy-proxy]
|
|
27
26
|
image: traefik:v3.5
|
|
28
27
|
command:
|
|
29
28
|
- --providers.file.filename=/etc/traefik/dynamic.yml
|
|
@@ -52,10 +51,6 @@ services:
|
|
|
52
51
|
# getClientAddress() tied to the visitor instead of the proxy container.
|
|
53
52
|
- ADDRESS_HEADER=x-forwarded-for
|
|
54
53
|
- XFF_DEPTH=1
|
|
55
|
-
# Match Traefik's published port so HMR's WebSocket targets the right port
|
|
56
|
-
# (default 80). Without this, HMR connects to :80 and a non-80 stack falls
|
|
57
|
-
# back to a full page reload on every edit.
|
|
58
|
-
- VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
|
|
59
54
|
depends_on: [api]
|
|
60
55
|
networks: [podokit]
|
|
61
56
|
develop:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-add-locale
|
|
3
|
+
description: Add, translate, validate, activate, or troubleshoot a JSON locale in a PodoKit-generated SvelteKit app. Use when a user asks to add a language, split or edit locale JSON, check translation coverage or placeholders, configure fallback behavior, or verify locale changes through Vite HMR.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PodoKit Add Locale
|
|
7
|
+
|
|
8
|
+
Add languages through the PodoKit locale commands and the app-owned JSON catalog. Keep module catalogs managed so `podo update` can continue to update them.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Inspect the existing locale state.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
podo locale list
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Add a canonical BCP 47 language tag. Set `--direction rtl` only for right-to-left languages.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
podo locale add pt-BR --name "Português (Brasil)"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
3. Read [references/catalog-format.md](references/catalog-format.md), then translate the new app-owned file at `apps/web/src/lib/i18n/catalogs/app/<locale>.json`.
|
|
25
|
+
|
|
26
|
+
4. Validate key coverage, JSON value types, and interpolation placeholders.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
podo locale validate pt-BR
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
5. Activate the locale. Partial catalogs are permitted because runtime fallback fills missing keys, but report the coverage percentage to the user.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
podo locale activate pt-BR
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
6. Run the app with its documented container-based development command. Verify the language switch, one complete key, one fallback key, and locale persistence. Editing an existing JSON file must update through Vite HMR without restarting the container. Adding a new locale file may trigger an automatic full-page reload.
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
|
|
42
|
+
- Treat `en` as the stable final fallback and never deactivate it.
|
|
43
|
+
- Preserve placeholders exactly. For example, translate `Hello {name}` without renaming or removing `{name}`.
|
|
44
|
+
- Put product-specific copy only in `catalogs/app/`. Do not edit `catalogs/admin-dashboard/` or other module catalogs directly.
|
|
45
|
+
- Keep locale definition filenames identical to their canonical codes, including case such as `pt-BR.json`.
|
|
46
|
+
- Do not activate a locale definition before its JSON parses and `podo locale validate` succeeds.
|
|
47
|
+
- Do not use the UI message catalogs for user-authored records. Dynamic content needs locale-aware records and a separate publication/fallback policy.
|
|
48
|
+
- When deactivating a locale, preserve its files so it can be reactivated without losing translations.
|
|
49
|
+
|
|
50
|
+
## Fallback And Site Default
|
|
51
|
+
|
|
52
|
+
The message runtime resolves missing keys in this order: selected locale, configured site default, then English. The site default is an administrator choice, not a build-time constant. Changing it must not delete or rewrite catalogs.
|
|
53
|
+
|
|
54
|
+
If a requested language is unsupported or inactive, resolve it to the configured site default. Keep localized URL and SEO behavior in the consuming app; the catalogs provide translated strings but do not define routing policy.
|
|
55
|
+
|
|
56
|
+
## Completion Checklist
|
|
57
|
+
|
|
58
|
+
- `podo locale validate` succeeds for every active locale.
|
|
59
|
+
- The new locale definition is active and has the intended display name and direction.
|
|
60
|
+
- App-owned JSON contains the new product copy; managed module catalogs remain untouched.
|
|
61
|
+
- Development edits update without a container restart.
|
|
62
|
+
- Automated tests cover selection, fallback, placeholders, and at least one incomplete catalog.
|
|
63
|
+
- User-facing locale setup or routing documentation is updated when behavior changes.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# JSON Catalog Format
|
|
2
|
+
|
|
3
|
+
## Directory Layout
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
apps/web/src/lib/i18n/
|
|
7
|
+
├── locales/
|
|
8
|
+
│ ├── en.json
|
|
9
|
+
│ ├── ko.json
|
|
10
|
+
│ └── pt-BR.json
|
|
11
|
+
└── catalogs/
|
|
12
|
+
├── admin-dashboard/ # PodoKit-managed module messages
|
|
13
|
+
├── blog/ # module-managed messages when installed
|
|
14
|
+
└── app/ # app-owned product messages and overrides
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Each locale definition has this shape:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"code": "pt-BR",
|
|
22
|
+
"name": "Português (Brasil)",
|
|
23
|
+
"direction": "ltr",
|
|
24
|
+
"enabled": false
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`code` must be a canonical BCP 47 tag and must match the filename. `direction` is `ltr` or `rtl`. Newly added locales stay inactive until explicitly activated.
|
|
29
|
+
|
|
30
|
+
## Catalog Composition
|
|
31
|
+
|
|
32
|
+
Catalogs are nested JSON objects with strings at their leaves:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"common": {
|
|
37
|
+
"save": "Save",
|
|
38
|
+
"welcome": "Welcome, {name}"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The runtime lazily loads every `catalogs/*/<locale>.json` file. Module catalogs are merged first in stable name order; the `app` catalog is merged last, so the consuming app may intentionally override module copy without modifying managed files.
|
|
44
|
+
|
|
45
|
+
English defines the complete key and value-type contract. Other locales may omit keys, but translated values must retain the English type and all `{placeholder}` names. JSON objects and arrays are supported; executable expressions are not.
|
|
46
|
+
|
|
47
|
+
## Development Reloading
|
|
48
|
+
|
|
49
|
+
Catalog imports use Vite's `import.meta.glob`. Changes to an existing JSON dependency participate in Vite HMR and must appear without restarting the development container. A new file changes the glob membership and may cause Vite to reload the page automatically. If neither occurs, confirm that the app source directory, including `apps/web/src`, is mounted or synced by the development compose configuration.
|
|
@@ -6,11 +6,11 @@ POSTGRES_USER=podokit
|
|
|
6
6
|
POSTGRES_PASSWORD=podokit
|
|
7
7
|
POSTGRES_DB=podokit
|
|
8
8
|
BACKEND_INTERNAL_URL=http://api:5002
|
|
9
|
-
CORS_ORIGIN=http://
|
|
9
|
+
CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
|
|
10
10
|
REDIS_HOST=redis
|
|
11
11
|
REDIS_PORT=6379
|
|
12
12
|
BETTER_AUTH_SECRET=change-me-in-container-min-32-characters
|
|
13
|
-
BETTER_AUTH_URL=http://
|
|
13
|
+
BETTER_AUTH_URL=http://{{projectName}}.localhost
|
|
14
14
|
ADMIN_EMAILS=admin@example.com
|
|
15
15
|
AUDIT_LOG_ENABLED=true
|
|
16
16
|
RATE_LIMIT_TTL=60
|
|
@@ -9,7 +9,13 @@ npm run test:e2e:ui # pages (chromium)
|
|
|
9
9
|
npm run test:e2e:report # open the HTML report
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
The suite runs against a live stack (web on `E2E_BASE_URL`,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
The suite runs against a live stack (web on `E2E_BASE_URL`, proxying `/api/*` to
|
|
13
|
+
the API). With the recommended containerized loop, start `podo dev watch` and run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
E2E_BASE_URL=http://{{projectName}}.localhost npm run test:e2e
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The default `http://localhost:5001` is for the alternative `npm run dev`
|
|
20
|
+
host-process loop. Files ending in `*.api.spec.ts` run in the request-only `api`
|
|
21
|
+
project; `*.ui.spec.ts` run in the chromium `ui` project.
|
|
@@ -31,6 +31,7 @@ const ICON_TYPES = new Set(["image/svg+xml", "image/png", "image/x-icon", "image
|
|
|
31
31
|
// preset a slug, and overrides a JSON map of hex colors under known token keys.
|
|
32
32
|
const HEX = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
33
33
|
const PRESET = /^[a-z0-9-]{1,32}$/;
|
|
34
|
+
const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
34
35
|
const THEME_TOKEN_KEYS = new Set([
|
|
35
36
|
"background",
|
|
36
37
|
"card",
|
|
@@ -46,6 +47,14 @@ const THEME_TOKEN_KEYS = new Set([
|
|
|
46
47
|
/** Validate/normalize a single theme setting value. Empty string clears it. */
|
|
47
48
|
function validateThemeValue(key: string, value: string): string {
|
|
48
49
|
if (value === "") return "";
|
|
50
|
+
if (key === "locale") {
|
|
51
|
+
if (!LOCALE.test(value)) throw new BadRequestException("locale must be a valid BCP 47 language tag");
|
|
52
|
+
try {
|
|
53
|
+
return Intl.getCanonicalLocales(value)[0] ?? value;
|
|
54
|
+
} catch {
|
|
55
|
+
throw new BadRequestException("locale must be a valid BCP 47 language tag");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
49
58
|
if (key === "brandColor") {
|
|
50
59
|
if (!HEX.test(value)) throw new BadRequestException("brandColor must be a hex color");
|
|
51
60
|
return value;
|
|
@@ -35,7 +35,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
35
35
|
event.locals.session = null;
|
|
36
36
|
}
|
|
37
37
|
const response = await resolve(event, {
|
|
38
|
-
transformPageChunk: ({ html }) =>
|
|
38
|
+
transformPageChunk: ({ html }) =>
|
|
39
|
+
html.replace(
|
|
40
|
+
/(<html\b[^>]*?\slang=)(["'])[^"']*\2/i,
|
|
41
|
+
`$1"${event.locals.locale}"`,
|
|
42
|
+
),
|
|
39
43
|
});
|
|
40
44
|
return applySearchIndexingHeaders(response, event.url.pathname);
|
|
41
45
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password"]);
|
|
2
|
+
|
|
3
|
+
/** Accept only same-site absolute paths and keep authentication redirects from
|
|
4
|
+
* becoming open redirects or loops back into an auth screen. */
|
|
5
|
+
export function safeAuthRedirect(value: string | null | undefined, fallback = "/"): string {
|
|
6
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) {
|
|
7
|
+
return fallback;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const parsed = new URL(value, "http://podokit.local");
|
|
11
|
+
if (parsed.origin !== "http://podokit.local" || AUTH_PATHS.has(parsed.pathname)) return fallback;
|
|
12
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Current same-site path, suitable for a login return target. */
|
|
19
|
+
export function currentPath(url: URL): string {
|
|
20
|
+
return safeAuthRedirect(`${url.pathname}${url.search}${url.hash}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Add a validated return target to an authentication-page link. */
|
|
24
|
+
export function withAuthRedirect(authPath: "/login" | "/signup", target: string): string {
|
|
25
|
+
const params = new URLSearchParams({ redirect: safeAuthRedirect(target) });
|
|
26
|
+
return `${authPath}?${params.toString()}`;
|
|
27
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from "$app/navigation";
|
|
3
|
+
import { page } from "$app/state";
|
|
4
|
+
import { currentPath, withAuthRedirect } from "$lib/auth-redirect";
|
|
3
5
|
import { Button } from "$lib/components/ui/button";
|
|
4
6
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
|
5
7
|
import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
|
|
@@ -12,16 +14,19 @@
|
|
|
12
14
|
|
|
13
15
|
let { user }: { user: SessionUser | null } = $props();
|
|
14
16
|
const i18n = getI18n();
|
|
17
|
+
const loginHref = $derived(withAuthRedirect("/login", currentPath(page.url)));
|
|
15
18
|
|
|
16
19
|
async function signOut(): Promise<void> {
|
|
20
|
+
const returnTo = currentPath(page.url);
|
|
17
21
|
await api.auth.signOut();
|
|
18
|
-
await goto(
|
|
22
|
+
await goto(returnTo, { invalidateAll: true });
|
|
19
23
|
}
|
|
20
24
|
</script>
|
|
21
25
|
|
|
22
26
|
{#if user}
|
|
23
27
|
<DropdownMenu.Root>
|
|
24
28
|
<DropdownMenu.Trigger
|
|
29
|
+
data-testid="account-menu"
|
|
25
30
|
class="hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-full outline-none focus-visible:ring-3"
|
|
26
31
|
aria-label={i18n.t.nav.account}
|
|
27
32
|
>
|
|
@@ -38,9 +43,9 @@
|
|
|
38
43
|
<DropdownMenu.Item onSelect={() => goto("/admin")}><LayoutDashboardIcon class="mr-2 size-4" /> {i18n.t.common.appName}</DropdownMenu.Item>
|
|
39
44
|
{/if}
|
|
40
45
|
<DropdownMenu.Separator />
|
|
41
|
-
<DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
|
|
46
|
+
<DropdownMenu.Item data-testid="sign-out" onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
|
|
42
47
|
</DropdownMenu.Content>
|
|
43
48
|
</DropdownMenu.Root>
|
|
44
49
|
{:else}
|
|
45
|
-
<Button
|
|
50
|
+
<Button data-testid="sign-in-link" href={loginHref} variant="ghost">{i18n.t.auth.signIn}</Button>
|
|
46
51
|
{/if}
|