@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/bin/tailframe.mjs +3 -2
- package/package.json +1 -1
- package/src/architecture.mjs +72 -10
- package/src/conventions.mjs +179 -0
- package/src/generate.mjs +312 -70
- package/src/new.mjs +188 -130
- package/src/service-templates.mjs +483 -0
- package/src/ui-templates.mjs +141 -0
- package/src/validate.mjs +3 -1
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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") ? {} :
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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 {
|
|
343
|
+
`import { defineStore } from "pinia";
|
|
269
344
|
|
|
270
|
-
export const ${
|
|
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
|
-
|
|
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 --
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|