create-oke 0.10.2 → 0.11.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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/ai-setup/apply.ts +123 -31
  4. package/src/ai-setup/catalog.ts +3 -3
  5. package/src/ai-setup/from-pref.ts +1 -1
  6. package/src/ai-setup/prompts.ts +1 -1
  7. package/src/cli.test.ts +183 -142
  8. package/src/cli.ts +190 -32
  9. package/src/create-defaults.test.ts +21 -15
  10. package/src/create-defaults.ts +33 -13
  11. package/src/customize-flow.test.ts +30 -35
  12. package/src/customize-flow.ts +68 -229
  13. package/src/drivers-catalog.ts +42 -48
  14. package/src/local-okengine.test.ts +50 -41
  15. package/src/local-okengine.ts +28 -14
  16. package/src/locales.test.ts +128 -0
  17. package/src/locales.ts +321 -0
  18. package/src/scaffold.ts +277 -33
  19. package/src/templates.ts +2 -8
  20. package/src/transform.test.ts +102 -38
  21. package/src/transform.ts +378 -132
  22. package/templates/advanced/.env.example +23 -3
  23. package/templates/advanced/README.md +1 -1
  24. package/templates/advanced/drizzle.config.ts +10 -13
  25. package/templates/advanced/oke.config.ts +21 -62
  26. package/templates/advanced/package.json +3 -1
  27. package/templates/advanced/src/app.ts +3 -10
  28. package/templates/advanced/src/core.ts +58 -0
  29. package/templates/advanced/src/db/schema.decl.ts +1 -1
  30. package/templates/advanced/src/db/seed/index.ts +2 -2
  31. package/templates/advanced/src/flows/notes/index.ts +3 -15
  32. package/templates/advanced/src/locales/index.ts +6 -0
  33. package/templates/advanced/tests/advanced.test.ts +1 -1
  34. package/templates/advanced/tsconfig.json +3 -0
  35. package/templates/standard/.env.example +23 -3
  36. package/templates/standard/README.md +1 -1
  37. package/templates/standard/drizzle.config.ts +10 -13
  38. package/templates/standard/oke.config.ts +18 -60
  39. package/templates/standard/package.json +3 -1
  40. package/templates/standard/src/app.ts +3 -10
  41. package/templates/standard/src/core.ts +55 -0
  42. package/templates/standard/src/db/schema.decl.ts +1 -1
  43. package/templates/standard/src/db/seed/index.ts +2 -2
  44. package/templates/standard/src/flows/notes/index.ts +3 -12
  45. package/templates/standard/src/locales/index.ts +6 -0
  46. package/templates/standard/tests/standard.test.ts +1 -1
  47. package/templates/standard/tsconfig.json +3 -0
  48. package/templates/advanced/src/core/channels.ts +0 -13
  49. package/templates/advanced/src/core/gates.ts +0 -12
  50. package/templates/advanced/src/core/index.ts +0 -12
  51. package/templates/advanced/src/core/store.ts +0 -8
  52. package/templates/advanced/src/core/vault.ts +0 -7
  53. package/templates/advanced/src/locales/ar.ts +0 -18
  54. package/templates/standard/src/core/channels.ts +0 -13
  55. package/templates/standard/src/core/gates.ts +0 -12
  56. package/templates/standard/src/core/index.ts +0 -12
  57. package/templates/standard/src/core/store.ts +0 -5
  58. package/templates/standard/src/core/vault.ts +0 -7
  59. package/templates/standard/src/locales/ar.ts +0 -18
package/src/locales.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Extra locales for create-oke — English is always the default.
3
+ *
4
+ * Templates ship English-only. Optional tags (ar, fr, …) are applied after
5
+ * copy: config, channel templates, `src/locales/index.ts`, and locale modules.
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ /** Default locale — always present, never removed. */
19
+ export const DEFAULT_LOCALE = "en";
20
+
21
+ /** Known RTL tags written into `i18n.dir`. */
22
+ export const RTL_LOCALES: ReadonlySet<string> = new Set(["ar", "he", "fa", "ur"]);
23
+
24
+ /**
25
+ * Normalize a raw locale tag (trim, lowercase language, keep region case).
26
+ *
27
+ * @param raw - User input (`AR`, `fr-FR`, …)
28
+ */
29
+ export function normalizeLocaleTag(raw: string): string {
30
+ const trimmed = raw.trim().replaceAll("_", "-");
31
+ if (!trimmed) return "";
32
+ const [lang, ...rest] = trimmed.split("-");
33
+ if (!lang) return "";
34
+ const base = lang.toLowerCase();
35
+ if (rest.length === 0) return base;
36
+ return [base, ...rest.map((p, i) => (i === 0 ? p.toUpperCase() : p))].join("-");
37
+ }
38
+
39
+ /**
40
+ * Parse a comma/space-separated locale list. Drops `en` (always implied) and
41
+ * duplicates. Returns `null` when the input is empty or invalid.
42
+ *
43
+ * @param raw - e.g. `ar` or `ar,fr`
44
+ */
45
+ export function parseExtraLocales(raw: string): readonly string[] | null {
46
+ const parts = raw
47
+ .split(/[,;\s]+/)
48
+ .map((p) => normalizeLocaleTag(p))
49
+ .filter((p) => p.length > 0);
50
+ if (parts.length === 0) return null;
51
+ const out: string[] = [];
52
+ const seen = new Set<string>();
53
+ for (const tag of parts) {
54
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]+)*$/.test(tag)) return null;
55
+ if (tag === DEFAULT_LOCALE || seen.has(tag)) continue;
56
+ seen.add(tag);
57
+ out.push(tag);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Full locale list for config / channels (`en` first, then extras).
64
+ *
65
+ * @param extra - Extra tags beyond English
66
+ */
67
+ export function localesWithDefault(extra: readonly string[] = []): readonly string[] {
68
+ const extras = parseExtraLocales(extra.join(",")) ?? [];
69
+ return [DEFAULT_LOCALE, ...extras];
70
+ }
71
+
72
+ /**
73
+ * `i18n.dir` map for RTL extras (omitted when empty).
74
+ *
75
+ * @param locales - Full locale list including `en`
76
+ */
77
+ export function dirMapForLocales(locales: readonly string[]): Readonly<Record<string, "rtl">> {
78
+ const dir: Record<string, "rtl"> = {};
79
+ for (const tag of locales) {
80
+ const base = tag.split("-")[0] ?? tag;
81
+ if (RTL_LOCALES.has(base)) dir[tag] = "rtl";
82
+ }
83
+ return dir;
84
+ }
85
+
86
+ /**
87
+ * Format the `i18n: { … }` block for `oke.config.ts`.
88
+ *
89
+ * @param locales - Full locale list
90
+ */
91
+ export function formatI18nConfig(locales: readonly string[]): string {
92
+ const list = locales.map((l) => JSON.stringify(l)).join(", ");
93
+ const dir = dirMapForLocales(locales);
94
+ const dirKeys = Object.keys(dir);
95
+ if (dirKeys.length === 0) {
96
+ return `i18n: { locales: [${list}], default: "en" }`;
97
+ }
98
+ const dirBody = dirKeys.map((k) => `${JSON.stringify(k)}: "rtl"`).join(", ");
99
+ return `i18n: { locales: [${list}], default: "en", dir: { ${dirBody} } }`;
100
+ }
101
+
102
+ /** Bundled Arabic catalog matching template `src/locales/en.ts` keys. */
103
+ export const AR_LOCALE_SOURCE = `import { defineLocale } from "okengine";
104
+ import type { MessagesFor } from "okengine";
105
+ import type { en } from "./en";
106
+
107
+ const ar = {
108
+ errors: {
109
+ notFound: "غير موجود",
110
+ unauthorized: "غير مصرح",
111
+ },
112
+ notes: {
113
+ created: "تم إنشاء الملاحظة «{title}».",
114
+ archived: "تم أرشفة الملاحظة.",
115
+ empty: "لا توجد ملاحظات نشطة بعد.",
116
+ count: "{count, plural, zero {لا ملاحظات} one {ملاحظة واحدة} two {ملاحظتان} few {# ملاحظات} many {# ملاحظة} other {# ملاحظة}}",
117
+ },
118
+ } satisfies MessagesFor<typeof en>;
119
+
120
+ defineLocale("ar", ar);
121
+ `;
122
+
123
+ /**
124
+ * Stub locale module — English strings as a translation starting point.
125
+ *
126
+ * @param tag - Locale tag (`fr`, `de`, …)
127
+ */
128
+ export function stubLocaleSource(tag: string): string {
129
+ const id = tag.replaceAll("-", "_");
130
+ return `import { defineLocale } from "okengine";
131
+ import type { MessagesFor } from "okengine";
132
+ import type { en } from "./en";
133
+
134
+ /** TODO: translate — seeded from English so keys stay aligned. */
135
+ const ${id} = {
136
+ errors: {
137
+ notFound: "Not found",
138
+ unauthorized: "Unauthorized",
139
+ },
140
+ notes: {
141
+ created: 'Note "{title}" was created.',
142
+ archived: "Note archived.",
143
+ empty: "No active notes yet.",
144
+ count: "{count, plural, =0 {no notes} one {# note} other {# notes}}",
145
+ },
146
+ } satisfies MessagesFor<typeof en>;
147
+
148
+ defineLocale(${JSON.stringify(tag)}, ${id});
149
+ `;
150
+ }
151
+
152
+ /**
153
+ * Resolve source for a locale tag (bundled seed or English stub).
154
+ *
155
+ * @param tag - Normalized locale tag
156
+ */
157
+ export function localeFileSource(tag: string): string {
158
+ if (tag === "ar") return AR_LOCALE_SOURCE;
159
+ return stubLocaleSource(tag);
160
+ }
161
+
162
+ /**
163
+ * Apply extra locales onto a scaffolded project (English-only templates).
164
+ *
165
+ * When `extra` is empty: ensure English-only config / channels / locale index
166
+ * and remove leftover `src/locales/<tag>.ts` files other than `en.ts` /
167
+ * `index.ts`.
168
+ *
169
+ * @param targetDir - Project root
170
+ * @param extra - Extra locale tags (not including `en`)
171
+ * @returns Relative paths written or removed (POSIX)
172
+ */
173
+ export function applyLocalesToProject(
174
+ targetDir: string,
175
+ extra: readonly string[] = [],
176
+ ): readonly string[] {
177
+ const extras = parseExtraLocales(extra.join(",")) ?? [];
178
+ const locales = localesWithDefault(extras);
179
+ const touched: string[] = [];
180
+
181
+ const configPath = join(targetDir, "oke.config.ts");
182
+ if (existsSync(configPath)) {
183
+ const source = readFileSync(configPath, "utf8");
184
+ const next = replaceI18nConfig(source, locales);
185
+ if (next !== source) {
186
+ writeFileSync(configPath, next, "utf8");
187
+ touched.push("oke.config.ts");
188
+ }
189
+ }
190
+
191
+ const list = locales.map((l) => JSON.stringify(l)).join(", ");
192
+ const channelCandidates = ["src/core.ts", "src/core/channels.ts"] as const;
193
+ for (const rel of channelCandidates) {
194
+ const channelsPath = join(targetDir, rel);
195
+ if (!existsSync(channelsPath)) continue;
196
+ const source = readFileSync(channelsPath, "utf8");
197
+ const next = source.replace(/locales:\s*\[[^\]]*\]/, `locales: [${list}]`);
198
+ if (next !== source) {
199
+ writeFileSync(channelsPath, next, "utf8");
200
+ touched.push(rel);
201
+ }
202
+ break;
203
+ }
204
+
205
+ const localesDir = join(targetDir, "src/locales");
206
+ mkdirSync(localesDir, { recursive: true });
207
+
208
+ // Remove non-English locale modules that are no longer selected.
209
+ try {
210
+ for (const entry of readdirSync(localesDir)) {
211
+ if (!entry.endsWith(".ts") || entry === "en.ts" || entry === "index.ts") continue;
212
+ const tag = entry.slice(0, -".ts".length);
213
+ if (!extras.includes(tag)) {
214
+ unlinkSync(join(localesDir, entry));
215
+ touched.push(`src/locales/${entry}`);
216
+ }
217
+ }
218
+ } catch {
219
+ // locales dir may be absent in stubs
220
+ }
221
+
222
+ for (const tag of extras) {
223
+ const rel = `src/locales/${tag}.ts`;
224
+ writeFileSync(join(targetDir, rel), localeFileSource(tag), "utf8");
225
+ if (!touched.includes(rel)) touched.push(rel);
226
+ }
227
+
228
+ const indexPath = join(localesDir, "index.ts");
229
+ const indexSource = formatLocalesIndex(locales);
230
+ const prevIndex = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : "";
231
+ if (prevIndex !== indexSource) {
232
+ writeFileSync(indexPath, indexSource, "utf8");
233
+ if (!touched.includes("src/locales/index.ts")) touched.push("src/locales/index.ts");
234
+ }
235
+
236
+ // Strip legacy per-locale imports from app.ts (locales load via core → index).
237
+ const appPath = join(targetDir, "src/app.ts");
238
+ if (existsSync(appPath)) {
239
+ const source = readFileSync(appPath, "utf8");
240
+ const next = stripLegacyAppLocaleImports(source);
241
+ if (next !== source) {
242
+ writeFileSync(appPath, next, "utf8");
243
+ touched.push("src/app.ts");
244
+ }
245
+ }
246
+
247
+ return touched;
248
+ }
249
+
250
+ /**
251
+ * Replace or insert the `i18n:` config line.
252
+ *
253
+ * @param source - `oke.config.ts` source
254
+ * @param locales - Full locale list
255
+ */
256
+ export function replaceI18nConfig(source: string, locales: readonly string[]): string {
257
+ const block = formatI18nConfig(locales);
258
+ const start = source.search(/i18n:\s*\{/);
259
+ if (start < 0) {
260
+ // Insert before the closing `});` of defineConfig.
261
+ return source.replace(/\n\}\);\s*$/, `,\n ${block},\n});\n`);
262
+ }
263
+ const braceStart = source.indexOf("{", start);
264
+ let depth = 0;
265
+ let end = braceStart;
266
+ for (let i = braceStart; i < source.length; i++) {
267
+ const ch = source[i];
268
+ if (ch === "{") depth++;
269
+ else if (ch === "}") {
270
+ depth--;
271
+ if (depth === 0) {
272
+ end = i + 1;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ return `${source.slice(0, start)}${block}${source.slice(end)}`;
278
+ }
279
+
280
+ /**
281
+ * Build `src/locales/index.ts` that side-effect-imports every catalog.
282
+ *
283
+ * @param locales - Full locale list (`en` first)
284
+ */
285
+ export function formatLocalesIndex(locales: readonly string[]): string {
286
+ const imports = locales.map((l) => `import "./${l}";`).join("\n");
287
+ return `/**
288
+ * Message catalogs — imported once from \`src/core.ts\`.
289
+ * Add a sibling import when you add a locale file.
290
+ */
291
+
292
+ ${imports}
293
+ `;
294
+ }
295
+
296
+ /**
297
+ * Remove legacy `@/locales/<tag>` / `./locales/<tag>` imports from `app.ts`.
298
+ *
299
+ * @param source - `src/app.ts` source
300
+ */
301
+ export function stripLegacyAppLocaleImports(source: string): string {
302
+ return source.replace(/^import\s+["'](?:@\/|\.\/)locales\/[^"']+["'];\s*\n/gm, "");
303
+ }
304
+
305
+ /**
306
+ * @deprecated Prefer {@link formatLocalesIndex} — kept for older tests/callers.
307
+ * Rewrite `@/locales/<tag>` imports on `app.ts` (legacy side-effect style).
308
+ *
309
+ * @param source - `src/app.ts` source
310
+ * @param locales - Full locale list
311
+ */
312
+ export function rewriteAppLocaleImports(source: string, locales: readonly string[]): string {
313
+ const withoutLocaleImports = stripLegacyAppLocaleImports(source);
314
+ const imports = locales.map((l) => `import "@/locales/${l}";`).join("\n");
315
+ const m = /^(import\s+.+;\n)/m.exec(withoutLocaleImports);
316
+ if (!m) {
317
+ return `${imports}\n${withoutLocaleImports}`;
318
+ }
319
+ const idx = m.index + m[0].length;
320
+ return withoutLocaleImports.slice(0, idx) + `${imports}\n` + withoutLocaleImports.slice(idx);
321
+ }