@zaaxch/tailframe 0.1.1 → 2.0.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/src/generate.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { loadExceptions, isExcepted } from "./exceptions.mjs";
4
+ import { serviceHttpFiles, serviceOperationName } from "./service-templates.mjs";
5
+ import { moduleApiSource } from "./ui-templates.mjs";
4
6
 
5
7
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
8
  const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
9
+ const PRIMARY = /^(?:[A-Z][A-Za-z0-9]*|[a-z][A-Za-z0-9]*)$/;
7
10
  const NOT_PLURAL = new Set(["status", "analysis"]);
8
11
 
9
12
  export class GenerateError extends Error {}
@@ -30,6 +33,14 @@ function requireModuleName(root, name) {
30
33
  return name;
31
34
  }
32
35
 
36
+ function requireUiModuleName(root, name) {
37
+ requireModuleName(root, name);
38
+ if (["auth", "theme"].includes(name)) {
39
+ fail(`UI module "${name}" is reserved for application-shell state under src/app/stores`);
40
+ }
41
+ return name;
42
+ }
43
+
33
44
  function requirePascal(value, label, forbiddenSuffixes = []) {
34
45
  if (!value || !PASCAL.test(value)) fail(`${label} must be PascalCase, received "${value ?? ""}"`);
35
46
  for (const suffix of forbiddenSuffixes) {
@@ -38,31 +49,129 @@ function requirePascal(value, label, forbiddenSuffixes = []) {
38
49
  return value;
39
50
  }
40
51
 
41
- function operationName(moduleName, verbNoun) {
42
- const moduleSuffix = pascal(moduleName);
43
- if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
44
- const prefix = verbNoun.slice(0, -moduleSuffix.length);
45
- return prefix[0].toLowerCase() + prefix.slice(1);
52
+ function requirePrimary(value, label) {
53
+ if (!value || !PRIMARY.test(value)) fail(`${label} must use PascalCase or camelCase primary-export naming, received "${value ?? ""}"`);
54
+ return value;
55
+ }
56
+
57
+ function edit(relative, update) {
58
+ return { relative, update };
59
+ }
60
+
61
+ function insertBefore(source, anchor, content, relative) {
62
+ const index = source.indexOf(anchor);
63
+ if (index < 0) fail(`Cannot update ${relative}: canonical insertion point was not found; normalize the file before generating`);
64
+ if (source.indexOf(anchor, index + anchor.length) >= 0) {
65
+ fail(`Cannot update ${relative}: canonical insertion point is ambiguous; normalize the file before generating`);
46
66
  }
47
- return verbNoun[0].toLowerCase() + verbNoun.slice(1);
67
+ return `${source.slice(0, index)}${content}${source.slice(index)}`;
48
68
  }
49
69
 
50
- function plan(files) {
70
+ function plan(files, edits = []) {
51
71
  return {
52
72
  files,
53
73
  write(root) {
54
74
  const existing = Object.keys(files).filter((relative) => fs.existsSync(path.join(root, relative)));
55
75
  if (existing.length) fail(`Refusing to overwrite existing file(s):\n${existing.map((file) => ` ${file}`).join("\n")}`);
76
+ const updates = edits.map(({ relative, update }) => {
77
+ const absolute = path.join(root, relative);
78
+ if (!fs.existsSync(absolute)) fail(`Cannot update missing generated composition file: ${relative}`);
79
+ return { relative, absolute, content: update(fs.readFileSync(absolute, "utf8")) };
80
+ });
56
81
  for (const [relative, content] of Object.entries(files)) {
57
82
  const absolute = path.join(root, relative);
58
83
  fs.mkdirSync(path.dirname(absolute), { recursive: true });
59
84
  fs.writeFileSync(absolute, content);
60
85
  }
61
- return Object.keys(files);
86
+ for (const update of updates) fs.writeFileSync(update.absolute, update.content);
87
+ return [...Object.keys(files), ...updates.map(({ relative }) => relative)];
62
88
  }
63
89
  };
64
90
  }
65
91
 
92
+ function registerUseCaseEdit(moduleName, verbNoun) {
93
+ const relative = "src/app/container.ts";
94
+ return edit(relative, (source) => {
95
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${relative} already references ${verbNoun}`);
96
+ let next = insertBefore(
97
+ source,
98
+ "\nconst register =",
99
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\n`,
100
+ relative
101
+ );
102
+ next = insertBefore(next, "\n\treturn container;", `\n\tregister(${verbNoun}, new ${verbNoun}());\n`, relative);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function mountModuleRoutesEdit(moduleName, verbNoun) {
108
+ const relative = "src/app/routes.ts";
109
+ const dependency = serviceOperationName(moduleName, verbNoun);
110
+ const factory = `${camel(moduleName)}Routes`;
111
+ return edit(relative, (source) => {
112
+ if (new RegExp(`\\b${factory}\\b`).test(source)) fail(`${relative} already mounts ${factory}`);
113
+ let next = insertBefore(
114
+ source,
115
+ "\nexport function applicationRoutes",
116
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\nimport { ${factory} } from "@/modules/${moduleName}/http/${moduleName}.routes";\n`,
117
+ relative
118
+ );
119
+ next = insertBefore(
120
+ next,
121
+ "\n\treturn router;",
122
+ `\n\trouter.use(\n\t\t${factory}({\n\t\t\t${dependency}: container.resolve(${verbNoun})\n\t\t})\n\t);\n`,
123
+ relative
124
+ );
125
+ return next;
126
+ });
127
+ }
128
+
129
+ function extendModuleRoutesEdits(moduleName, verbNoun) {
130
+ const operation = serviceOperationName(moduleName, verbNoun);
131
+ const dependency = operation;
132
+ const schemasRelative = `src/modules/${moduleName}/http/${moduleName}.schemas.ts`;
133
+ const routesRelative = `src/modules/${moduleName}/http/${moduleName}.routes.ts`;
134
+ const appRoutesRelative = "src/app/routes.ts";
135
+ return [
136
+ edit(schemasRelative, (source) => {
137
+ if (new RegExp(`\\b${verbNoun}Schema\\b`).test(source)) fail(`${schemasRelative} already defines ${verbNoun}Schema`);
138
+ return `${source.trimEnd()}\n\nexport const ${verbNoun}Schema = Joi.object({}).unknown(false);\n`;
139
+ }),
140
+ edit(routesRelative, (source) => {
141
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${routesRelative} already references ${verbNoun}`);
142
+ let next = insertBefore(
143
+ source,
144
+ `import { rpcHandler } from "@/platform/http/rpcHandler";`,
145
+ `import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\nimport { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";\n`,
146
+ routesRelative
147
+ );
148
+ next = insertBefore(next, "\n}\n\nexport function", `\n\t${dependency}: ${verbNoun};`, routesRelative);
149
+ next = insertBefore(
150
+ next,
151
+ "\n\treturn router;",
152
+ `\n\trouter.post("/${moduleName}.${operation}", rpcHandler(${verbNoun}Schema, useCases.${dependency}));`,
153
+ routesRelative
154
+ );
155
+ return next;
156
+ }),
157
+ edit(appRoutesRelative, (source) => {
158
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${appRoutesRelative} already references ${verbNoun}`);
159
+ let next = insertBefore(
160
+ source,
161
+ `import { ${camel(moduleName)}Routes } from "@/modules/${moduleName}/http/${moduleName}.routes";`,
162
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\n`,
163
+ appRoutesRelative
164
+ );
165
+ const start = next.indexOf(`\t\t${camel(moduleName)}Routes({`);
166
+ if (start < 0) fail(`Cannot update ${appRoutesRelative}: ${camel(moduleName)}Routes mount was not found`);
167
+ const end = next.indexOf("\n\t\t})", start);
168
+ if (end < 0) fail(`Cannot update ${appRoutesRelative}: ${camel(moduleName)}Routes mount is not canonical`);
169
+ const before = next.slice(0, end).replace(/\n\t\t\t([^\n]+)$/, "\n\t\t\t$1,");
170
+ return `${before}\n\t\t\t${dependency}: container.resolve(${verbNoun})${next.slice(end)}`;
171
+ })
172
+ ];
173
+ }
174
+
66
175
  function useCaseFiles(moduleName, verbNoun) {
67
176
  return {
68
177
  [`src/modules/${moduleName}/use-cases/${verbNoun}.ts`]:
@@ -84,61 +193,27 @@ export class ${verbNoun} implements UseCase<${verbNoun}Input, ${verbNoun}Output>
84
193
 
85
194
  describe("${verbNoun}", () => {
86
195
  it("is not implemented yet", async () => {
87
- await expect(new ${verbNoun}().execute({ requestId: "test", roles: [] }, {})).rejects.toThrow();
196
+ await expect(new ${verbNoun}().execute({ requestId: "test" }, {})).rejects.toThrow();
88
197
  });
89
198
  });
90
199
  `
91
200
  };
92
201
  }
93
202
 
94
- function httpFiles(moduleName, verbNoun) {
95
- const operation = operationName(moduleName, verbNoun);
96
- const dependency = verbNoun[0].toLowerCase() + verbNoun.slice(1);
97
- return {
98
- [`src/modules/${moduleName}/http/${moduleName}.schemas.ts`]:
99
- `import Joi from "joi";
100
-
101
- export const ${verbNoun}Schema = Joi.object({}).unknown(false);
102
- `,
103
- [`src/modules/${moduleName}/http/${moduleName}.routes.ts`]:
104
- `import { Router } from "express";
105
- import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";
106
- import { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";
107
- import { createRequestContext } from "@/platform/http/createRequestContext";
108
- import { rpcResult } from "@/platform/http/rpc";
109
-
110
- export function ${camel(moduleName)}Routes(${dependency}: ${verbNoun}) {
111
- const router = Router();
112
- router.post("/${moduleName}.${operation}", async (req, res, next) => {
113
- try {
114
- const input = await ${verbNoun}Schema.validateAsync(req.body ?? {});
115
- rpcResult(res, await ${dependency}.execute(await createRequestContext(req), input));
116
- } catch (error) {
117
- next(error);
118
- }
119
- });
120
- return router;
121
- }
122
- `
123
- };
124
- }
125
-
126
203
  const serviceSchematics = {
127
204
  module(root, [name, verbNoun], options) {
128
205
  requireModuleName(root, name);
129
206
  requirePascal(verbNoun, "First use case", ["UseCases"]);
130
- const files = { ...useCaseFiles(name, verbNoun), ...(options.has("--no-http") ? {} : httpFiles(name, verbNoun)) };
131
- return [plan(files), [
132
- `Register ${verbNoun} in src/app/container.ts`,
133
- ...(options.has("--no-http") ? [] : [`Mount ${camel(name)}Routes in src/app/routes.ts`]),
207
+ const files = { ...useCaseFiles(name, verbNoun), ...(options.has("--no-http") ? {} : serviceHttpFiles(name, verbNoun)) };
208
+ const edits = [registerUseCaseEdit(name, verbNoun), ...(options.has("--no-http") ? [] : [mountModuleRoutesEdit(name, verbNoun)])];
209
+ return [plan(files, edits), [
134
210
  `Implement ${verbNoun}.execute and replace the placeholder test`
135
211
  ]];
136
212
  },
137
213
  "use-case"(root, [moduleName, verbNoun]) {
138
214
  requireModuleName(root, moduleName);
139
215
  requirePascal(verbNoun, "Use case", ["UseCases"]);
140
- return [plan(useCaseFiles(moduleName, verbNoun)), [
141
- `Register ${verbNoun} in src/app/container.ts`,
216
+ return [plan(useCaseFiles(moduleName, verbNoun), [registerUseCaseEdit(moduleName, verbNoun)]), [
142
217
  `Expose it from an entry point (http route, worker, job, or cli) if the capability needs one`,
143
218
  `Implement ${verbNoun}.execute and replace the placeholder test`
144
219
  ]];
@@ -148,8 +223,15 @@ const serviceSchematics = {
148
223
  requirePascal(verbNoun, "Use case", ["UseCases"]);
149
224
  if (!fs.existsSync(path.join(root, `src/modules/${moduleName}/use-cases/${verbNoun}.ts`)))
150
225
  fail(`Use case src/modules/${moduleName}/use-cases/${verbNoun}.ts does not exist; generate it first`);
151
- return [plan(httpFiles(moduleName, verbNoun)), [
152
- `Mount ${camel(moduleName)}Routes in src/app/routes.ts`,
226
+ const schemas = path.join(root, `src/modules/${moduleName}/http/${moduleName}.schemas.ts`);
227
+ const routes = path.join(root, `src/modules/${moduleName}/http/${moduleName}.routes.ts`);
228
+ if (fs.existsSync(schemas) !== fs.existsSync(routes)) {
229
+ fail(`Module ${moduleName} has only one canonical HTTP file; restore both ${moduleName}.schemas.ts and ${moduleName}.routes.ts before generating`);
230
+ }
231
+ const firstHttp = !fs.existsSync(schemas);
232
+ return [plan(firstHttp ? serviceHttpFiles(moduleName, verbNoun) : {}, firstHttp
233
+ ? [mountModuleRoutesEdit(moduleName, verbNoun)]
234
+ : extendModuleRoutesEdits(moduleName, verbNoun)), [
153
235
  `Define the real request schema in ${moduleName}.schemas.ts`
154
236
  ]];
155
237
  },
@@ -233,14 +315,7 @@ ${body}
233
315
  function uiArtifacts(moduleName, kindOptions) {
234
316
  const files = {};
235
317
  if (kindOptions.api) {
236
- files[`src/modules/${moduleName}/api/${moduleName}.api.ts`] =
237
- `import { http } from "@/platform/http";
238
- import type { RpcResponse } from "@/core/rpc";
239
-
240
- export async function get${pascal(moduleName)}() {
241
- return (await http.post<RpcResponse<unknown>>("${moduleName}.get")).data.result;
242
- }
243
- `;
318
+ files[`src/modules/${moduleName}/api/${moduleName}.api.ts`] = moduleApiSource(moduleName, pascal(moduleName));
244
319
  }
245
320
  if (kindOptions.view) {
246
321
  const viewName = kindOptions.view.endsWith("View") ? kindOptions.view : `${kindOptions.view}View`;
@@ -265,9 +340,11 @@ export async function get${pascal(moduleName)}() {
265
340
  }
266
341
  if (kindOptions.store) {
267
342
  files[`src/modules/${moduleName}/stores/${kindOptions.store}.store.ts`] =
268
- `import { reactive } from "vue";
343
+ `import { defineStore } from "pinia";
269
344
 
270
- export const ${camel(kindOptions.store)}Store = reactive({});
345
+ export const use${pascal(kindOptions.store)}Store = defineStore("${moduleName}/${kindOptions.store}", () => {
346
+ return {};
347
+ });
271
348
  `;
272
349
  }
273
350
  if (kindOptions.composable) {
@@ -275,56 +352,221 @@ export const ${camel(kindOptions.store)}Store = reactive({});
275
352
  `export function use${pascal(kindOptions.composable)}() {
276
353
  return {};
277
354
  }
355
+ `;
356
+ }
357
+ if (kindOptions.public) {
358
+ const exportName = kindOptions.public;
359
+ files[`src/modules/${moduleName}/public/${exportName}.ts`] = exportName[0] === exportName[0].toUpperCase()
360
+ ? `export interface ${exportName} {}\n`
361
+ : `export function ${exportName}() {\n\treturn {};\n}\n`;
362
+ }
363
+ if (kindOptions.publicComponent) {
364
+ files[`src/modules/${moduleName}/public/${kindOptions.publicComponent}.vue`] =
365
+ `<script setup lang="ts"></script>
366
+
367
+ <template>
368
+ <div></div>
369
+ </template>
278
370
  `;
279
371
  }
280
372
  return files;
281
373
  }
282
374
 
375
+ export function themeAppStoreSource(storageKey) {
376
+ return `import { useLocalStorage } from "@vueuse/core";
377
+ import { defineStore } from "pinia";
378
+ import { computed } from "vue";
379
+
380
+ export type Theme = "light" | "dark";
381
+
382
+ const preferredTheme = (): Theme =>
383
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
384
+
385
+ export const useThemeStore = defineStore("theme", () => {
386
+ const theme = useLocalStorage<Theme>(${JSON.stringify(storageKey)}, preferredTheme());
387
+ const isDark = computed(() => theme.value === "dark");
388
+
389
+ function toggleTheme() {
390
+ theme.value = isDark.value ? "light" : "dark";
391
+ }
392
+
393
+ return { theme, isDark, toggleTheme };
394
+ });
395
+ `;
396
+ }
397
+
398
+ export function themeToggleSource() {
399
+ return `<template>
400
+ <button
401
+ type="button"
402
+ :aria-label="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
403
+ :title="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
404
+ @click="themeStore.toggleTheme"
405
+ >
406
+ <svg
407
+ v-if="themeStore.isDark"
408
+ aria-hidden="true"
409
+ class="size-5"
410
+ viewBox="0 0 24 24"
411
+ fill="none"
412
+ stroke="currentColor"
413
+ stroke-width="1.8"
414
+ >
415
+ <circle cx="12" cy="12" r="4" />
416
+ <path
417
+ d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
418
+ />
419
+ </svg>
420
+ <svg
421
+ v-else
422
+ aria-hidden="true"
423
+ class="size-5"
424
+ viewBox="0 0 24 24"
425
+ fill="none"
426
+ stroke="currentColor"
427
+ stroke-width="1.8"
428
+ >
429
+ <path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5a8.5 8.5 0 1 0 11.7 11.7Z" />
430
+ </svg>
431
+ </button>
432
+ </template>
433
+
434
+ <script setup lang="ts">
435
+ import { useThemeStore } from "@/app/stores/theme.store";
436
+
437
+ const themeStore = useThemeStore();
438
+ </script>
439
+ `;
440
+ }
441
+
442
+ export function authAppStoreSource() {
443
+ return `import { auth, googleProvider } from "@/platform/firebase";
444
+ import { onIdTokenChanged, signInWithPopup, signOut, type User } from "firebase/auth";
445
+ import { defineStore } from "pinia";
446
+ import { ref } from "vue";
447
+
448
+ export const useAuthStore = defineStore("auth", () => {
449
+ const firebaseUser = ref<User | null>(null);
450
+ const ready = ref(false);
451
+
452
+ onIdTokenChanged(auth, (user) => {
453
+ firebaseUser.value = user;
454
+ ready.value = true;
455
+ });
456
+
457
+ const signInWithGoogle = () => signInWithPopup(auth, googleProvider);
458
+ const logout = () => signOut(auth);
459
+ const getIdToken = () => firebaseUser.value?.getIdToken();
460
+
461
+ return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
462
+ });
463
+ `;
464
+ }
465
+
466
+ function appStoreFiles(name, options) {
467
+ if (name === "theme") {
468
+ const storageKey = options.get("--storage-key");
469
+ if (!storageKey) fail("Theme app-store generation requires --storage-key <key>");
470
+ return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
471
+ }
472
+ if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
473
+ fail(`App store must be one of auth or theme, received "${name ?? ""}"`);
474
+ }
475
+
476
+ function appPublicComponentFiles(name) {
477
+ if (name !== "ThemeToggle") {
478
+ fail(`Public shell component must be ThemeToggle, received "${name ?? ""}"; product UI belongs in its owning module`);
479
+ }
480
+ return {
481
+ "src/app/public/ThemeToggle.vue": themeToggleSource()
482
+ };
483
+ }
484
+
283
485
  const uiSchematics = {
284
486
  module(root, [name], options) {
285
- requireModuleName(root, name);
487
+ requireUiModuleName(root, name);
286
488
  const artifacts = {
287
489
  api: options.has("--api"),
288
490
  view: options.get("--view"),
289
491
  component: options.get("--component"),
290
492
  store: options.get("--store"),
291
- composable: options.get("--composable")
493
+ composable: options.get("--composable"),
494
+ public: options.get("--public"),
495
+ publicComponent: options.get("--public-component")
292
496
  };
293
497
  if (artifacts.view) requirePascal(artifacts.view, "View");
294
498
  if (artifacts.component) requirePascal(artifacts.component, "Component");
295
499
  if (artifacts.store && !KEBAB.test(artifacts.store)) fail(`Store name must be lowercase kebab-case, received "${artifacts.store}"`);
296
500
  if (artifacts.composable && !KEBAB.test(artifacts.composable)) fail(`Composable name must be lowercase kebab-case, received "${artifacts.composable}"`);
501
+ if (artifacts.public) requirePrimary(artifacts.public, "Public entry");
502
+ if (artifacts.publicComponent) requirePascal(artifacts.publicComponent, "Public component");
297
503
  const files = uiArtifacts(name, artifacts);
298
- if (!Object.keys(files).length) fail("A UI module needs at least one artifact: pass --api, --view <Name>, --component <Name>, --store <name>, or --composable <name> (empty layers are forbidden)");
504
+ if (!Object.keys(files).length) fail("A UI module needs at least one artifact: pass --api, --view <Name>, --component <Name>, --store <name>, --composable <name>, --public <name>, or --public-component <Name> (empty layers are forbidden)");
299
505
  return [plan(files), [
300
506
  ...(artifacts.view ? [`Register the view in a route and mount it from src/app/router.ts`] : []),
301
507
  ...(artifacts.api ? [`Point ${name}.api.ts at the real RPC operation`] : []),
302
- `Compose the module from src/app only; other modules must not import it`
508
+ ...(artifacts.public ? [`Implement ${artifacts.public}.ts as one stable public capability; keep internal module paths private`] : []),
509
+ ...(artifacts.publicComponent ? [`Implement ${artifacts.publicComponent}.vue as one stable public component backed only by this module's internals`] : []),
510
+ `Compose the module from src/app; sibling modules may use only named files directly under this module's public directory`
303
511
  ]];
304
512
  },
305
513
  api(root, [moduleName]) {
306
- requireModuleName(root, moduleName);
514
+ requireUiModuleName(root, moduleName);
307
515
  return [plan(uiArtifacts(moduleName, { api: true })), [`Point ${moduleName}.api.ts at the real RPC operation`]];
308
516
  },
309
517
  view(root, [moduleName, name]) {
310
- requireModuleName(root, moduleName);
518
+ requireUiModuleName(root, moduleName);
311
519
  requirePascal(name, "View");
312
520
  return [plan(uiArtifacts(moduleName, { view: name })), [`Register the view in a route and mount it from src/app/router.ts`]];
313
521
  },
314
522
  component(root, [moduleName, name]) {
315
- requireModuleName(root, moduleName);
523
+ requireUiModuleName(root, moduleName);
316
524
  requirePascal(name, "Component");
317
525
  return [plan(uiArtifacts(moduleName, { component: name })), [`Use the component from this module's views only`]];
318
526
  },
319
527
  store(root, [moduleName, name]) {
320
- requireModuleName(root, moduleName);
528
+ requireUiModuleName(root, moduleName);
321
529
  if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
322
- return [plan(uiArtifacts(moduleName, { store: name })), [`Keep the store module-private; cross-module state belongs in src/app`]];
530
+ return [plan(uiArtifacts(moduleName, { store: name })), [`Keep product state in this owning module; expose a deliberate facade with tailframe generate public ${moduleName} use${pascal(name)}Store only when another module needs it`]];
323
531
  },
324
532
  composable(root, [moduleName, name]) {
325
- requireModuleName(root, moduleName);
533
+ requireUiModuleName(root, moduleName);
326
534
  if (!name || !KEBAB.test(name)) fail(`Composable name must be lowercase kebab-case, received "${name ?? ""}"`);
327
- return [plan(uiArtifacts(moduleName, { composable: name })), [`Use the composable from this module only`]];
535
+ return [plan(uiArtifacts(moduleName, { composable: name })), [`Use the composable inside this module; expose a named public facade only when another module needs it`]];
536
+ },
537
+ public(root, [moduleName, name]) {
538
+ requireUiModuleName(root, moduleName);
539
+ requirePrimary(name, "Public entry");
540
+ return [plan(uiArtifacts(moduleName, { public: name })), [
541
+ `Expose one stable capability from ${name}.ts; it may adapt this module's internal stores, composables, types, or API`,
542
+ `Keep UI module dependencies acyclic and import no sibling internals`
543
+ ]];
544
+ },
545
+ "public-component"(root, [moduleName, name]) {
546
+ requireUiModuleName(root, moduleName);
547
+ requirePascal(name, "Public component");
548
+ return [plan(uiArtifacts(moduleName, { publicComponent: name })), [
549
+ `Implement ${name}.vue as one stable public component backed only by ${moduleName} internals`,
550
+ `Keep UI module dependencies acyclic and import no sibling internals`
551
+ ]];
552
+ },
553
+ "app-store"(root, [name], options) {
554
+ if (name === "auth" && !fs.existsSync(path.join(root, "src/platform/firebase.ts"))) {
555
+ fail("Auth app-store generation requires src/platform/firebase.ts exporting auth and googleProvider");
556
+ }
557
+ return [plan(appStoreFiles(name, options)), [
558
+ `Use the generated ${name}.store.ts as emitted; do not fork its implementation per project`,
559
+ `Modules may import the store directly; every other app store name is forbidden`
560
+ ]];
561
+ },
562
+ "app-component"(root, [name]) {
563
+ if (name === "ThemeToggle" && !fs.existsSync(path.join(root, "src/app/stores/theme.store.ts"))) {
564
+ fail("ThemeToggle generation requires src/app/stores/theme.store.ts; generate the theme app store first");
565
+ }
566
+ return [plan(appPublicComponentFiles(name)), [
567
+ `Use ThemeToggle as emitted; do not fork its implementation per project`,
568
+ `Keep every other reusable UI capability in its owning module`
569
+ ]];
328
570
  }
329
571
  };
330
572