@zaaxch/tailframe 0.1.1 → 1.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 +68 -10
- package/src/conventions.mjs +95 -0
- package/src/generate.mjs +194 -13
- package/src/new.mjs +22 -16
- package/src/validate.mjs +3 -1
package/bin/tailframe.mjs
CHANGED
|
@@ -14,8 +14,9 @@ function usage() {
|
|
|
14
14
|
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
15
15
|
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
16
16
|
console.error(" identifiers <module> <NameId...>, integration <provider>");
|
|
17
|
-
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>,");
|
|
18
|
-
console.error(" api <module>, view <module> <Name>, component <module> <Name>, store <module> <name>, composable <module> <name
|
|
17
|
+
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>|--public <name>|--public-component <Name>,");
|
|
18
|
+
console.error(" api <module>, view <module> <Name>, component <module> <Name>, store <module> <name>, composable <module> <name>,");
|
|
19
|
+
console.error(" public <module> <name>, public-component <module> <Name>, app-store auth, app-store theme --storage-key <key>, app-component ThemeToggle");
|
|
19
20
|
process.exit(2);
|
|
20
21
|
}
|
|
21
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/architecture.mjs
CHANGED
|
@@ -4,6 +4,22 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".vue"]);
|
|
7
|
+
const APP_STORE_FILES = ["auth.store.ts", "theme.store.ts"];
|
|
8
|
+
const APP_PUBLIC_FILES = ["ThemeToggle.vue"];
|
|
9
|
+
const APP_STORE_TARGETS = new Set(["src/app/stores/auth.store", "src/app/stores/theme.store"]);
|
|
10
|
+
const APP_PUBLIC_TARGETS = new Set(["src/app/public/ThemeToggle"]);
|
|
11
|
+
|
|
12
|
+
function withoutSourceExtension(target) {
|
|
13
|
+
return target?.replace(/\.(?:ts|tsx|vue)$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPublicAppStore(target) {
|
|
17
|
+
return APP_STORE_TARGETS.has(withoutSourceExtension(target));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPublicAppUi(target) {
|
|
21
|
+
return APP_PUBLIC_TARGETS.has(withoutSourceExtension(target));
|
|
22
|
+
}
|
|
7
23
|
|
|
8
24
|
export function toPosix(value) {
|
|
9
25
|
return value.split(path.sep).join("/");
|
|
@@ -47,6 +63,10 @@ function resolveInternal(relativeFile, specifier) {
|
|
|
47
63
|
return path.posix.normalize(path.posix.join(path.posix.dirname(relativeFile), specifier));
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
function looksLikeUnsupportedInternalAlias(specifier) {
|
|
67
|
+
return /^(?:(?:~|#|\$)\/(?:src\/)?|@(?:app|core|platform|modules)\/|(?:src\/)?(?:app|core|platform|modules)\/)/.test(specifier);
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
export function isTestFile(relative) {
|
|
51
71
|
return relative.includes("/__tests__/") || /\.(?:test|spec)\.[^.]+$/.test(relative);
|
|
52
72
|
}
|
|
@@ -86,6 +106,10 @@ function validateStructure(root, kind, fail) {
|
|
|
86
106
|
if (!allowed.includes(name)) fail(`Unexpected architecture ${entryKind}: ${relative}/${name}`);
|
|
87
107
|
}
|
|
88
108
|
};
|
|
109
|
+
const checkOptionalEntries = (relative, entryKind, allowed) => {
|
|
110
|
+
const target = path.join(root, relative);
|
|
111
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) checkEntries(relative, entryKind, allowed);
|
|
112
|
+
};
|
|
89
113
|
|
|
90
114
|
if (kind === "service") {
|
|
91
115
|
checkEntries("src", "directory", ["__tests__", "app", "core", "modules", "platform"]);
|
|
@@ -95,8 +119,12 @@ function validateStructure(root, kind, fail) {
|
|
|
95
119
|
} else {
|
|
96
120
|
checkEntries("src", "directory", ["app", "core", "modules", "platform"]);
|
|
97
121
|
checkEntries("src", "file", ["main.ts"]);
|
|
98
|
-
checkEntries("src/app", "directory", ["__tests__", "components", "stores", "views"]);
|
|
122
|
+
checkEntries("src/app", "directory", ["__tests__", "components", "public", "stores", "views"]);
|
|
99
123
|
checkEntries("src/core", "directory", []);
|
|
124
|
+
checkOptionalEntries("src/app/public", "directory", []);
|
|
125
|
+
checkOptionalEntries("src/app/public", "file", APP_PUBLIC_FILES);
|
|
126
|
+
checkOptionalEntries("src/app/stores", "directory", []);
|
|
127
|
+
checkOptionalEntries("src/app/stores", "file", APP_STORE_FILES);
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
const platform = path.join(root, "src/platform");
|
|
@@ -108,11 +136,14 @@ function validateStructure(root, kind, fail) {
|
|
|
108
136
|
}
|
|
109
137
|
const allowedModuleDirectories = kind === "service"
|
|
110
138
|
? ["__tests__", "domain", "http", "persistence", "use-cases"]
|
|
111
|
-
: ["__tests__", "api", "components", "composables", "routes", "stores", "types", "views"];
|
|
139
|
+
: ["__tests__", "api", "components", "composables", "public", "routes", "stores", "types", "views"];
|
|
112
140
|
for (const moduleName of listEntries(modules, "directory")) {
|
|
113
|
-
if (kind === "ui" &&
|
|
141
|
+
if (kind === "ui" && ["auth", "theme"].includes(moduleName)) {
|
|
142
|
+
fail(`Application ${moduleName === "auth" ? "authentication" : "theme"} must live under src/app, not src/modules/${moduleName}`);
|
|
143
|
+
}
|
|
114
144
|
checkEntries(`src/modules/${moduleName}`, "directory", allowedModuleDirectories);
|
|
115
145
|
checkEntries(`src/modules/${moduleName}`, "file", []);
|
|
146
|
+
if (kind === "ui") checkOptionalEntries(`src/modules/${moduleName}/public`, "directory", []);
|
|
116
147
|
}
|
|
117
148
|
}
|
|
118
149
|
|
|
@@ -188,7 +219,7 @@ function validateServiceImport(source, target, external, fail, graph) {
|
|
|
188
219
|
}
|
|
189
220
|
}
|
|
190
221
|
|
|
191
|
-
function validateUiImport(source, target, external, fail) {
|
|
222
|
+
function validateUiImport(source, target, external, fail, graph) {
|
|
192
223
|
if (source === "src/main.ts") {
|
|
193
224
|
if (external || !target.startsWith("src/app/")) fail(`src/main.ts may import only app bootstrap code, not ${external ?? target}`);
|
|
194
225
|
return;
|
|
@@ -197,6 +228,22 @@ function validateUiImport(source, target, external, fail) {
|
|
|
197
228
|
if (external || !target.startsWith("src/core/")) fail(`${source} violates core purity by importing ${external ?? target}`);
|
|
198
229
|
return;
|
|
199
230
|
}
|
|
231
|
+
if (source.startsWith("src/app/stores/")) {
|
|
232
|
+
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
233
|
+
fail(`${source} is a canonical shell store and may import only framework packages, core, or platform, not ${target}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (source.startsWith("src/app/public/")) {
|
|
237
|
+
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target)) return;
|
|
238
|
+
fail(`${source} is canonical public shell UI and may import only framework packages, core, platform, or a canonical app store, not ${target}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (/^src\/app\/(?:components|views)\//.test(source)) {
|
|
242
|
+
const targetModule = moduleParts(target ?? "");
|
|
243
|
+
if (!targetModule || targetModule.layer === "public") return;
|
|
244
|
+
fail(`${source} may compose a product module only through a named module public entry, not ${target}; route module views directly from src/app/router.ts`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
200
247
|
if (source.startsWith("src/app/")) return;
|
|
201
248
|
if (source.startsWith("src/platform/")) {
|
|
202
249
|
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
@@ -205,20 +252,27 @@ function validateUiImport(source, target, external, fail) {
|
|
|
205
252
|
}
|
|
206
253
|
const sourceModule = moduleParts(source);
|
|
207
254
|
if (!sourceModule || external) return;
|
|
208
|
-
if (target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
255
|
+
if (target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target) || isPublicAppUi(target)) return;
|
|
209
256
|
const targetModule = moduleParts(target);
|
|
210
257
|
if (targetModule?.module === sourceModule.module) return;
|
|
211
|
-
|
|
258
|
+
if (targetModule?.layer === "public" && targetModule.remainder && !targetModule.remainder.includes("/")) {
|
|
259
|
+
if (!isTestFile(source) && sourceModule.layer !== "__tests__") {
|
|
260
|
+
if (!graph.has(sourceModule.module)) graph.set(sourceModule.module, new Set());
|
|
261
|
+
graph.get(sourceModule.module).add(targetModule.module);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
fail(`${source} may import only its own UI module, core, platform, the canonical auth/theme app stores, ThemeToggle, or a named file directly under another module's public directory, not ${target}`);
|
|
212
266
|
}
|
|
213
267
|
|
|
214
|
-
function detectCycles(graph, fail) {
|
|
268
|
+
function detectCycles(graph, fail, label) {
|
|
215
269
|
const visited = new Set();
|
|
216
270
|
const active = [];
|
|
217
271
|
const activeSet = new Set();
|
|
218
272
|
const visit = (moduleName) => {
|
|
219
273
|
if (activeSet.has(moduleName)) {
|
|
220
274
|
const start = active.indexOf(moduleName);
|
|
221
|
-
fail(
|
|
275
|
+
fail(`${label}: ${[...active.slice(start), moduleName].join(" -> ")}`);
|
|
222
276
|
return;
|
|
223
277
|
}
|
|
224
278
|
if (visited.has(moduleName)) return;
|
|
@@ -246,14 +300,18 @@ export function validateArchitecture(rootArgument, kind) {
|
|
|
246
300
|
const relative = toPosix(path.relative(root, absolute));
|
|
247
301
|
for (const specifier of extractImports(fs.readFileSync(absolute, "utf8"), relative)) {
|
|
248
302
|
const target = resolveInternal(relative, specifier);
|
|
303
|
+
if (!target && looksLikeUnsupportedInternalAlias(specifier)) {
|
|
304
|
+
fail(`${relative} uses unsupported internal import alias ${specifier}; use @/ or a relative import so boundaries remain enforceable`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
249
307
|
if (target && !target.startsWith("src/")) {
|
|
250
308
|
fail(`${relative} has a relative import outside src: ${specifier}`);
|
|
251
309
|
continue;
|
|
252
310
|
}
|
|
253
311
|
if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
254
|
-
else validateUiImport(relative, target, target ? undefined : specifier, fail);
|
|
312
|
+
else validateUiImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
255
313
|
}
|
|
256
314
|
}
|
|
257
|
-
|
|
315
|
+
detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
|
|
258
316
|
return errors;
|
|
259
317
|
}
|
package/src/conventions.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { toPosix, walk } from "./architecture.mjs";
|
|
4
|
+
import { authAppStoreSource, themeAppStoreSource, themeToggleSource } from "./generate.mjs";
|
|
4
5
|
|
|
5
6
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
6
7
|
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
|
|
@@ -8,6 +9,11 @@ const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
8
9
|
const ROLES = new Set(["routes", "schemas", "api", "store", "types", "documents"]);
|
|
9
10
|
const NOT_PLURAL = new Set(["status", "analysis"]);
|
|
10
11
|
const BRAND_PATTERN = /\bBrand\s*<|unique symbol/;
|
|
12
|
+
const ROUTE_NAMES_FILE = "src/core/RouteNames.ts";
|
|
13
|
+
const ROUTE_NAMES_DECLARATION = /\b(?:enum|const|type)\s+RouteNames\b/;
|
|
14
|
+
const ROUTE_NAMES_CANONICAL_DECLARATION = /\bexport\s+enum\s+RouteNames\b/;
|
|
15
|
+
const ROUTE_NAMES_VALUE = /^\s*[A-Z][A-Z0-9_]*\s*=\s*["']([^"']+)["']/gm;
|
|
16
|
+
const ROUTE_NAME_REFERENCE = /\bname:\s*["']([^"']+)["']/g;
|
|
11
17
|
|
|
12
18
|
function parseName(fileName) {
|
|
13
19
|
if (fileName.endsWith(".d.ts")) return undefined;
|
|
@@ -59,6 +65,9 @@ export function validateConventions(rootArgument, kind) {
|
|
|
59
65
|
if ((relative.startsWith("src/core/") && name.extension === ".ts") && BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
|
|
60
66
|
flag("S5", relative, `${relative} defines identifier branding in core; branded IDs and the Brand helper belong in the owning module's domain/identifiers.ts`);
|
|
61
67
|
}
|
|
68
|
+
if (kind === "ui" && relative.startsWith("src/app/public/") && relative.slice("src/app/public/".length).includes("/")) {
|
|
69
|
+
flag("U6", relative, `${relative} is nested under app/public; public shell entries are named files directly under src/app/public`);
|
|
70
|
+
}
|
|
62
71
|
continue;
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -95,9 +104,83 @@ export function validateConventions(rootArgument, kind) {
|
|
|
95
104
|
flag("S5", relative, `${relative} declares identifier branding outside domain/identifiers.ts; each module keeps its brands, Brand helper, and boundary helpers in one domain/identifiers.ts`);
|
|
96
105
|
}
|
|
97
106
|
}
|
|
107
|
+
if (kind === "ui") {
|
|
108
|
+
validateRouteNames(root, src, flag);
|
|
109
|
+
validateCanonicalShell(root, flag);
|
|
110
|
+
}
|
|
98
111
|
return violations;
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
function normalizeGeneratedSource(source) {
|
|
115
|
+
return source.replace(/\r\n/g, "\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateCanonicalShell(root, flag) {
|
|
119
|
+
const authRelative = "src/app/stores/auth.store.ts";
|
|
120
|
+
const authFile = path.join(root, authRelative);
|
|
121
|
+
if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== authAppStoreSource()) {
|
|
122
|
+
flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const themeRelative = "src/app/stores/theme.store.ts";
|
|
126
|
+
const themeFile = path.join(root, themeRelative);
|
|
127
|
+
if (fs.existsSync(themeFile)) {
|
|
128
|
+
const source = normalizeGeneratedSource(fs.readFileSync(themeFile, "utf8"));
|
|
129
|
+
const storageKeyMatch = source.match(/useLocalStorage<Theme>\(("(?:\\.|[^"\\])*"), preferredTheme\(\)\)/);
|
|
130
|
+
let expected;
|
|
131
|
+
if (storageKeyMatch) {
|
|
132
|
+
try { expected = themeAppStoreSource(JSON.parse(storageKeyMatch[1])); } catch { /* reported below */ }
|
|
133
|
+
}
|
|
134
|
+
if (!expected || source !== expected) {
|
|
135
|
+
flag("U7", themeRelative, `${themeRelative} differs from the canonical Tailframe theme store; only its generated storage-key value may vary`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const toggleRelative = "src/app/public/ThemeToggle.vue";
|
|
140
|
+
const toggleFile = path.join(root, toggleRelative);
|
|
141
|
+
if (fs.existsSync(toggleFile)) {
|
|
142
|
+
if (!fs.existsSync(themeFile)) flag("U7", toggleRelative, `${toggleRelative} requires ${themeRelative}`);
|
|
143
|
+
if (normalizeGeneratedSource(fs.readFileSync(toggleFile, "utf8")) !== themeToggleSource()) {
|
|
144
|
+
flag("U7", toggleRelative, `${toggleRelative} differs from the canonical Tailframe theme toggle; regenerate it instead of maintaining a project-local variant`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Route names are the one piece of shell vocabulary a module legitimately needs, so they live in a
|
|
151
|
+
* core enum both sides may import. A module that cannot reach the enum hardcodes the string instead,
|
|
152
|
+
* and a later rename breaks navigation silently.
|
|
153
|
+
*/
|
|
154
|
+
function validateRouteNames(root, src, flag) {
|
|
155
|
+
const declarationFile = path.join(root, ROUTE_NAMES_FILE);
|
|
156
|
+
const declared = new Set();
|
|
157
|
+
if (!fs.existsSync(declarationFile)) {
|
|
158
|
+
flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} is required; route names are one exported enum shared by the router and modules`);
|
|
159
|
+
} else {
|
|
160
|
+
const text = fs.readFileSync(declarationFile, "utf8");
|
|
161
|
+
if (!ROUTE_NAMES_CANONICAL_DECLARATION.test(text)) {
|
|
162
|
+
flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare export enum RouteNames`);
|
|
163
|
+
}
|
|
164
|
+
for (const match of text.matchAll(ROUTE_NAMES_VALUE)) declared.add(match[1]);
|
|
165
|
+
if (declared.size === 0) flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare at least one string-valued route name`);
|
|
166
|
+
}
|
|
167
|
+
for (const absolute of walk(src)) {
|
|
168
|
+
const relative = toPosix(path.relative(root, absolute));
|
|
169
|
+
if (![".ts", ".vue"].includes(path.extname(relative))) continue;
|
|
170
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
171
|
+
if (relative !== ROUTE_NAMES_FILE && ROUTE_NAMES_DECLARATION.test(text)) {
|
|
172
|
+
flag("U5", relative, `${relative} declares RouteNames outside ${ROUTE_NAMES_FILE}; route names are one core enum imported by the router and by modules`);
|
|
173
|
+
}
|
|
174
|
+
if (!relative.startsWith("src/modules/") || declared.size === 0) continue;
|
|
175
|
+
for (const match of text.matchAll(ROUTE_NAME_REFERENCE)) {
|
|
176
|
+
if (declared.has(match[1])) {
|
|
177
|
+
flag("U5", relative, `${relative} hardcodes the route name "${match[1]}"; import RouteNames from ${ROUTE_NAMES_FILE}`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
101
184
|
function validateShared(relative, name, flag) {
|
|
102
185
|
if (name.stem === "index") return;
|
|
103
186
|
const directory = path.posix.basename(path.posix.dirname(relative));
|
|
@@ -111,6 +194,10 @@ function validateShared(relative, name, flag) {
|
|
|
111
194
|
else if (directory === "views" && !name.stem.endsWith("View")) flag("U2", relative, `${relative} views are named <Name>View.vue`);
|
|
112
195
|
return;
|
|
113
196
|
}
|
|
197
|
+
if (relative.startsWith("src/app/stores/") && name.extension === ".ts") {
|
|
198
|
+
flag("U3", relative, `${relative} is not a canonical app store; app/stores is closed to auth.store.ts and theme.store.ts`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
114
201
|
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
|
115
202
|
flag("G1", relative, `${relative} must be named after its primary export in that export's casing (PascalCase or camelCase)`);
|
|
116
203
|
}
|
|
@@ -178,6 +265,14 @@ function validateUiFile(relative, moduleName, layer, name, flag) {
|
|
|
178
265
|
if (name.role !== "store") flag("U3", relative, `${relative} stores are named <name>.store.ts`);
|
|
179
266
|
return;
|
|
180
267
|
}
|
|
268
|
+
if (layer === "public") {
|
|
269
|
+
const remainder = relative.slice(`src/modules/${moduleName}/public/`.length);
|
|
270
|
+
if (remainder.includes("/")) flag("U6", relative, `${relative} is nested; module public entries are named files directly under public/`);
|
|
271
|
+
else if (name.role !== undefined || (!PASCAL.test(name.stem) && !CAMEL.test(name.stem))) {
|
|
272
|
+
flag("U6", relative, `${relative} must be a named public entry in primary-export casing, not a role-suffix file or barrel`);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
181
276
|
if (layer === "types") {
|
|
182
277
|
if (name.role !== undefined) {
|
|
183
278
|
if (name.role !== "types" || name.subject !== moduleName) flag("U3", relative, `${relative} the module types role file is ${moduleName}.types.ts`);
|
package/src/generate.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { loadExceptions, isExcepted } from "./exceptions.mjs";
|
|
|
4
4
|
|
|
5
5
|
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
6
6
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
7
|
+
const PRIMARY = /^(?:[A-Z][A-Za-z0-9]*|[a-z][A-Za-z0-9]*)$/;
|
|
7
8
|
const NOT_PLURAL = new Set(["status", "analysis"]);
|
|
8
9
|
|
|
9
10
|
export class GenerateError extends Error {}
|
|
@@ -30,6 +31,14 @@ function requireModuleName(root, name) {
|
|
|
30
31
|
return name;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function requireUiModuleName(root, name) {
|
|
35
|
+
requireModuleName(root, name);
|
|
36
|
+
if (["auth", "theme"].includes(name)) {
|
|
37
|
+
fail(`UI module "${name}" is reserved for application-shell state under src/app/stores`);
|
|
38
|
+
}
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
function requirePascal(value, label, forbiddenSuffixes = []) {
|
|
34
43
|
if (!value || !PASCAL.test(value)) fail(`${label} must be PascalCase, received "${value ?? ""}"`);
|
|
35
44
|
for (const suffix of forbiddenSuffixes) {
|
|
@@ -38,6 +47,11 @@ function requirePascal(value, label, forbiddenSuffixes = []) {
|
|
|
38
47
|
return value;
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
function requirePrimary(value, label) {
|
|
51
|
+
if (!value || !PRIMARY.test(value)) fail(`${label} must use PascalCase or camelCase primary-export naming, received "${value ?? ""}"`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
function operationName(moduleName, verbNoun) {
|
|
42
56
|
const moduleSuffix = pascal(moduleName);
|
|
43
57
|
if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
|
|
@@ -265,9 +279,11 @@ export async function get${pascal(moduleName)}() {
|
|
|
265
279
|
}
|
|
266
280
|
if (kindOptions.store) {
|
|
267
281
|
files[`src/modules/${moduleName}/stores/${kindOptions.store}.store.ts`] =
|
|
268
|
-
`import {
|
|
282
|
+
`import { defineStore } from "pinia";
|
|
269
283
|
|
|
270
|
-
export const ${
|
|
284
|
+
export const use${pascal(kindOptions.store)}Store = defineStore("${moduleName}/${kindOptions.store}", () => {
|
|
285
|
+
return {};
|
|
286
|
+
});
|
|
271
287
|
`;
|
|
272
288
|
}
|
|
273
289
|
if (kindOptions.composable) {
|
|
@@ -275,56 +291,221 @@ export const ${camel(kindOptions.store)}Store = reactive({});
|
|
|
275
291
|
`export function use${pascal(kindOptions.composable)}() {
|
|
276
292
|
return {};
|
|
277
293
|
}
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
if (kindOptions.public) {
|
|
297
|
+
const exportName = kindOptions.public;
|
|
298
|
+
files[`src/modules/${moduleName}/public/${exportName}.ts`] = exportName[0] === exportName[0].toUpperCase()
|
|
299
|
+
? `export interface ${exportName} {}\n`
|
|
300
|
+
: `export function ${exportName}() {\n\treturn {};\n}\n`;
|
|
301
|
+
}
|
|
302
|
+
if (kindOptions.publicComponent) {
|
|
303
|
+
files[`src/modules/${moduleName}/public/${kindOptions.publicComponent}.vue`] =
|
|
304
|
+
`<script setup lang="ts"></script>
|
|
305
|
+
|
|
306
|
+
<template>
|
|
307
|
+
<div></div>
|
|
308
|
+
</template>
|
|
278
309
|
`;
|
|
279
310
|
}
|
|
280
311
|
return files;
|
|
281
312
|
}
|
|
282
313
|
|
|
314
|
+
export function themeAppStoreSource(storageKey) {
|
|
315
|
+
return `import { useLocalStorage } from "@vueuse/core";
|
|
316
|
+
import { defineStore } from "pinia";
|
|
317
|
+
import { computed } from "vue";
|
|
318
|
+
|
|
319
|
+
export type Theme = "light" | "dark";
|
|
320
|
+
|
|
321
|
+
const preferredTheme = (): Theme =>
|
|
322
|
+
typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
323
|
+
|
|
324
|
+
export const useThemeStore = defineStore("theme", () => {
|
|
325
|
+
const theme = useLocalStorage<Theme>(${JSON.stringify(storageKey)}, preferredTheme());
|
|
326
|
+
const isDark = computed(() => theme.value === "dark");
|
|
327
|
+
|
|
328
|
+
function toggleTheme() {
|
|
329
|
+
theme.value = isDark.value ? "light" : "dark";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { theme, isDark, toggleTheme };
|
|
333
|
+
});
|
|
334
|
+
`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function themeToggleSource() {
|
|
338
|
+
return `<template>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
:aria-label="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
|
|
342
|
+
:title="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
|
|
343
|
+
@click="themeStore.toggleTheme"
|
|
344
|
+
>
|
|
345
|
+
<svg
|
|
346
|
+
v-if="themeStore.isDark"
|
|
347
|
+
aria-hidden="true"
|
|
348
|
+
class="size-5"
|
|
349
|
+
viewBox="0 0 24 24"
|
|
350
|
+
fill="none"
|
|
351
|
+
stroke="currentColor"
|
|
352
|
+
stroke-width="1.8"
|
|
353
|
+
>
|
|
354
|
+
<circle cx="12" cy="12" r="4" />
|
|
355
|
+
<path
|
|
356
|
+
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"
|
|
357
|
+
/>
|
|
358
|
+
</svg>
|
|
359
|
+
<svg
|
|
360
|
+
v-else
|
|
361
|
+
aria-hidden="true"
|
|
362
|
+
class="size-5"
|
|
363
|
+
viewBox="0 0 24 24"
|
|
364
|
+
fill="none"
|
|
365
|
+
stroke="currentColor"
|
|
366
|
+
stroke-width="1.8"
|
|
367
|
+
>
|
|
368
|
+
<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" />
|
|
369
|
+
</svg>
|
|
370
|
+
</button>
|
|
371
|
+
</template>
|
|
372
|
+
|
|
373
|
+
<script setup lang="ts">
|
|
374
|
+
import { useThemeStore } from "@/app/stores/theme.store";
|
|
375
|
+
|
|
376
|
+
const themeStore = useThemeStore();
|
|
377
|
+
</script>
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function authAppStoreSource() {
|
|
382
|
+
return `import { auth, googleProvider } from "@/platform/firebase";
|
|
383
|
+
import { onIdTokenChanged, signInWithPopup, signOut, type User } from "firebase/auth";
|
|
384
|
+
import { defineStore } from "pinia";
|
|
385
|
+
import { ref } from "vue";
|
|
386
|
+
|
|
387
|
+
export const useAuthStore = defineStore("auth", () => {
|
|
388
|
+
const firebaseUser = ref<User | null>(null);
|
|
389
|
+
const ready = ref(false);
|
|
390
|
+
|
|
391
|
+
onIdTokenChanged(auth, (user) => {
|
|
392
|
+
firebaseUser.value = user;
|
|
393
|
+
ready.value = true;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const signInWithGoogle = () => signInWithPopup(auth, googleProvider);
|
|
397
|
+
const logout = () => signOut(auth);
|
|
398
|
+
const getIdToken = () => firebaseUser.value?.getIdToken();
|
|
399
|
+
|
|
400
|
+
return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
|
|
401
|
+
});
|
|
402
|
+
`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function appStoreFiles(name, options) {
|
|
406
|
+
if (name === "theme") {
|
|
407
|
+
const storageKey = options.get("--storage-key");
|
|
408
|
+
if (!storageKey) fail("Theme app-store generation requires --storage-key <key>");
|
|
409
|
+
return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
|
|
410
|
+
}
|
|
411
|
+
if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
|
|
412
|
+
fail(`App store must be one of auth or theme, received "${name ?? ""}"`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function appPublicComponentFiles(name) {
|
|
416
|
+
if (name !== "ThemeToggle") {
|
|
417
|
+
fail(`Public shell component must be ThemeToggle, received "${name ?? ""}"; product UI belongs in its owning module`);
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
"src/app/public/ThemeToggle.vue": themeToggleSource()
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
283
424
|
const uiSchematics = {
|
|
284
425
|
module(root, [name], options) {
|
|
285
|
-
|
|
426
|
+
requireUiModuleName(root, name);
|
|
286
427
|
const artifacts = {
|
|
287
428
|
api: options.has("--api"),
|
|
288
429
|
view: options.get("--view"),
|
|
289
430
|
component: options.get("--component"),
|
|
290
431
|
store: options.get("--store"),
|
|
291
|
-
composable: options.get("--composable")
|
|
432
|
+
composable: options.get("--composable"),
|
|
433
|
+
public: options.get("--public"),
|
|
434
|
+
publicComponent: options.get("--public-component")
|
|
292
435
|
};
|
|
293
436
|
if (artifacts.view) requirePascal(artifacts.view, "View");
|
|
294
437
|
if (artifacts.component) requirePascal(artifacts.component, "Component");
|
|
295
438
|
if (artifacts.store && !KEBAB.test(artifacts.store)) fail(`Store name must be lowercase kebab-case, received "${artifacts.store}"`);
|
|
296
439
|
if (artifacts.composable && !KEBAB.test(artifacts.composable)) fail(`Composable name must be lowercase kebab-case, received "${artifacts.composable}"`);
|
|
440
|
+
if (artifacts.public) requirePrimary(artifacts.public, "Public entry");
|
|
441
|
+
if (artifacts.publicComponent) requirePascal(artifacts.publicComponent, "Public component");
|
|
297
442
|
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 --
|
|
443
|
+
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
444
|
return [plan(files), [
|
|
300
445
|
...(artifacts.view ? [`Register the view in a route and mount it from src/app/router.ts`] : []),
|
|
301
446
|
...(artifacts.api ? [`Point ${name}.api.ts at the real RPC operation`] : []),
|
|
302
|
-
`
|
|
447
|
+
...(artifacts.public ? [`Implement ${artifacts.public}.ts as one stable public capability; keep internal module paths private`] : []),
|
|
448
|
+
...(artifacts.publicComponent ? [`Implement ${artifacts.publicComponent}.vue as one stable public component backed only by this module's internals`] : []),
|
|
449
|
+
`Compose the module from src/app; sibling modules may use only named files directly under this module's public directory`
|
|
303
450
|
]];
|
|
304
451
|
},
|
|
305
452
|
api(root, [moduleName]) {
|
|
306
|
-
|
|
453
|
+
requireUiModuleName(root, moduleName);
|
|
307
454
|
return [plan(uiArtifacts(moduleName, { api: true })), [`Point ${moduleName}.api.ts at the real RPC operation`]];
|
|
308
455
|
},
|
|
309
456
|
view(root, [moduleName, name]) {
|
|
310
|
-
|
|
457
|
+
requireUiModuleName(root, moduleName);
|
|
311
458
|
requirePascal(name, "View");
|
|
312
459
|
return [plan(uiArtifacts(moduleName, { view: name })), [`Register the view in a route and mount it from src/app/router.ts`]];
|
|
313
460
|
},
|
|
314
461
|
component(root, [moduleName, name]) {
|
|
315
|
-
|
|
462
|
+
requireUiModuleName(root, moduleName);
|
|
316
463
|
requirePascal(name, "Component");
|
|
317
464
|
return [plan(uiArtifacts(moduleName, { component: name })), [`Use the component from this module's views only`]];
|
|
318
465
|
},
|
|
319
466
|
store(root, [moduleName, name]) {
|
|
320
|
-
|
|
467
|
+
requireUiModuleName(root, moduleName);
|
|
321
468
|
if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
322
|
-
return [plan(uiArtifacts(moduleName, { store: name })), [`Keep
|
|
469
|
+
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
470
|
},
|
|
324
471
|
composable(root, [moduleName, name]) {
|
|
325
|
-
|
|
472
|
+
requireUiModuleName(root, moduleName);
|
|
326
473
|
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
|
|
474
|
+
return [plan(uiArtifacts(moduleName, { composable: name })), [`Use the composable inside this module; expose a named public facade only when another module needs it`]];
|
|
475
|
+
},
|
|
476
|
+
public(root, [moduleName, name]) {
|
|
477
|
+
requireUiModuleName(root, moduleName);
|
|
478
|
+
requirePrimary(name, "Public entry");
|
|
479
|
+
return [plan(uiArtifacts(moduleName, { public: name })), [
|
|
480
|
+
`Expose one stable capability from ${name}.ts; it may adapt this module's internal stores, composables, types, or API`,
|
|
481
|
+
`Keep UI module dependencies acyclic and import no sibling internals`
|
|
482
|
+
]];
|
|
483
|
+
},
|
|
484
|
+
"public-component"(root, [moduleName, name]) {
|
|
485
|
+
requireUiModuleName(root, moduleName);
|
|
486
|
+
requirePascal(name, "Public component");
|
|
487
|
+
return [plan(uiArtifacts(moduleName, { publicComponent: name })), [
|
|
488
|
+
`Implement ${name}.vue as one stable public component backed only by ${moduleName} internals`,
|
|
489
|
+
`Keep UI module dependencies acyclic and import no sibling internals`
|
|
490
|
+
]];
|
|
491
|
+
},
|
|
492
|
+
"app-store"(root, [name], options) {
|
|
493
|
+
if (name === "auth" && !fs.existsSync(path.join(root, "src/platform/firebase.ts"))) {
|
|
494
|
+
fail("Auth app-store generation requires src/platform/firebase.ts exporting auth and googleProvider");
|
|
495
|
+
}
|
|
496
|
+
return [plan(appStoreFiles(name, options)), [
|
|
497
|
+
`Use the generated ${name}.store.ts as emitted; do not fork its implementation per project`,
|
|
498
|
+
`Modules may import the store directly; every other app store name is forbidden`
|
|
499
|
+
]];
|
|
500
|
+
},
|
|
501
|
+
"app-component"(root, [name]) {
|
|
502
|
+
if (name === "ThemeToggle" && !fs.existsSync(path.join(root, "src/app/stores/theme.store.ts"))) {
|
|
503
|
+
fail("ThemeToggle generation requires src/app/stores/theme.store.ts; generate the theme app store first");
|
|
504
|
+
}
|
|
505
|
+
return [plan(appPublicComponentFiles(name)), [
|
|
506
|
+
`Use ThemeToggle as emitted; do not fork its implementation per project`,
|
|
507
|
+
`Keep every other reusable UI capability in its owning module`
|
|
508
|
+
]];
|
|
328
509
|
}
|
|
329
510
|
};
|
|
330
511
|
|
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { GenerateError } from "./generate.mjs";
|
|
3
|
+
import { authAppStoreSource, GenerateError } from "./generate.mjs";
|
|
4
4
|
|
|
5
5
|
function fail(message) {
|
|
6
6
|
throw new GenerateError(message);
|
|
@@ -132,15 +132,15 @@ Use this workflow for any new domain capability or operation. Add only the layer
|
|
|
132
132
|
1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
|
|
133
133
|
2. Read the root and every applicable child \`AGENTS.md\`.
|
|
134
134
|
3. Inspect the nearest working module and tests.
|
|
135
|
-
4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers,
|
|
136
|
-
5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
|
|
135
|
+
4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; complete the printed wiring checklist after each generation.
|
|
136
|
+
5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
|
|
137
137
|
6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
|
|
138
138
|
7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
|
|
139
139
|
8. Define repository contracts in the owning module and implement them in persistence adapters.
|
|
140
140
|
9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
|
|
141
141
|
10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
|
|
142
|
-
11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory
|
|
143
|
-
12. Add required container, route, client, navigation, worker, or process registration and focused tests.
|
|
142
|
+
11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/theme app stores and \`ThemeToggle\` from app; every other app path is private.
|
|
143
|
+
12. Add required container, route, client, navigation, worker, or process registration and focused tests. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
|
|
144
144
|
13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
|
|
145
145
|
|
|
146
146
|
Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
|
|
@@ -398,7 +398,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
398
398
|
| Cross-entry-point technology-neutral contract | Flat \`src/core\` |
|
|
399
399
|
| Root executable | Bootstrap of matching \`src/app\` assembly only |
|
|
400
400
|
|
|
401
|
-
Product vocabulary stays in the module that owns its meaning; never move it into \`core\`. A module may import another only through a named file directly under the provider's \`use-cases/\`; its domain, HTTP, persistence, ports, and tests remain private, and cross-module use-case dependencies must be acyclic.
|
|
401
|
+
Product vocabulary stays in the module that owns its meaning; never move it into \`core\`. A module may import another only through a named file directly under the provider's \`use-cases/\`; its domain, HTTP, persistence, ports, and tests remain private, and cross-module use-case dependencies must be acyclic. Use \`@/\` or relative internal imports; do not add another source alias.
|
|
402
402
|
|
|
403
403
|
## Public API contract
|
|
404
404
|
- Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
|
|
@@ -469,12 +469,13 @@ add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.l
|
|
|
469
469
|
if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
|
|
470
470
|
add(`${ui}/src/core/rpc.ts`, `export interface RpcResponse<T> { result: T; }\nexport interface RpcError { error: { message: string; code?: string }; }`);
|
|
471
471
|
if (options.auth === "firebase") {
|
|
472
|
-
add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\
|
|
473
|
-
add(`${ui}/src/app/stores/auth.ts`,
|
|
472
|
+
add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nimport { getAuth, GoogleAuthProvider } from "firebase/auth";\n\nconst firebaseApp = initializeApp({\n\tapiKey: import.meta.env.VITE_FIREBASE_API_KEY,\n\tauthDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,\n\tprojectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,\n\tappId: import.meta.env.VITE_FIREBASE_APP_ID\n});\n\nexport const auth = getAuth(firebaseApp);\nexport const googleProvider = new GoogleAuthProvider();`);
|
|
473
|
+
add(`${ui}/src/app/stores/auth.store.ts`, authAppStoreSource());
|
|
474
474
|
}
|
|
475
|
-
add(`${ui}/src/
|
|
475
|
+
add(`${ui}/src/core/RouteNames.ts`, `/** Route vocabulary shared by the router and by modules. Modules never import the router itself. */\nexport enum RouteNames {\n\tHOME = "Home"\n}\n`);
|
|
476
|
+
add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";\nimport { RouteNames } from "@/core/RouteNames";\nexport default createRouter({ history: createWebHistory(), routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/app/views/HomeView.vue") }] });`);
|
|
476
477
|
add(`${ui}/src/platform/http.ts`, `import axios from "axios";\nexport const http = axios.create({ baseURL: \`\${window.location.origin}/api/v1\`, headers: { "Content-Type": "application/json" } });`);
|
|
477
|
-
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router
|
|
478
|
+
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router from "@/app/router";\nimport { RouteNames } from "@/core/RouteNames";\nimport { useAuthStore } from "@/app/stores/auth.store";\nimport { http } from "@/platform/http";\nexport function configureHttp(pinia: Pinia) {\n\tconst auth = useAuthStore(pinia);\n\thttp.interceptors.request.use(async config => { const token = await auth.getIdToken(); if (token) config.headers.Authorization = \`Bearer \${token}\`; return config; });\n\thttp.interceptors.response.use(response => response, async error => { if (error?.response?.status === 401) { await auth.logout(); await router.push({ name: RouteNames.HOME }); } return Promise.reject(error); });\n}\n`);
|
|
478
479
|
add(`${ui}/src/modules/health/api/health.api.ts`, `import { http } from "@/platform/http";\nimport type { RpcResponse } from "@/core/rpc";\nexport async function getHealth() { return (await http.post<RpcResponse<{ status: string }>>("health.get")).data.result; }`);
|
|
479
480
|
add(`${ui}/src/app/views/HomeView.vue`, `<template><main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16"><p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Project foundation</p><h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1><p>The product domain is intentionally undefined.</p></main></template>`);
|
|
480
481
|
add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
|
|
@@ -488,26 +489,30 @@ add(`${ui}/AGENTS.md`, `# ${title} UI guidance
|
|
|
488
489
|
These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
|
|
489
490
|
|
|
490
491
|
## Architecture vocabulary
|
|
491
|
-
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and
|
|
492
|
+
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
|
|
492
493
|
- Use \`src/core\` only for small technology-neutral client contracts.
|
|
493
494
|
- Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
|
|
494
495
|
- Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
|
|
495
|
-
- These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it
|
|
496
|
-
-
|
|
496
|
+
- These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
|
|
497
|
+
- Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
|
|
497
498
|
|
|
498
499
|
## Change placement
|
|
499
500
|
| Change | Canonical owner |
|
|
500
501
|
| --- | --- |
|
|
501
502
|
| Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
|
|
503
|
+
| Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
|
|
502
504
|
| Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
|
|
503
|
-
|
|
|
505
|
+
| Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
|
|
506
|
+
| Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
|
|
507
|
+
| Any other state or reusable presentation | Owning \`src/modules/<module>\` |
|
|
508
|
+
| Shell, router, global styles, or cross-capability composition | \`src/app\` |
|
|
504
509
|
| Technology-neutral client contract | Flat \`src/core\` |
|
|
505
510
|
| Root executable | Bootstrap of \`src/app\` only |
|
|
506
511
|
|
|
507
|
-
UI modules
|
|
512
|
+
UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
|
|
508
513
|
|
|
509
514
|
## Module shape
|
|
510
|
-
Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Do not invent alternative layer names or generate speculative CRUD. Create module files only with \`tailframe generate\`.
|
|
515
|
+
Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`public/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Public entries are named direct files, never nested directories or barrels. Do not invent alternative layer names or generate speculative CRUD. Create module and public shell files only with \`tailframe generate\`.
|
|
511
516
|
|
|
512
517
|
## API contract
|
|
513
518
|
- Use the shared Axios client and singular \`POST <module>.<operation>\` calls.
|
|
@@ -519,6 +524,7 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
|
|
|
519
524
|
|
|
520
525
|
## State management
|
|
521
526
|
Keep local state local. Use Pinia only for state shared across routes or unrelated components.
|
|
527
|
+
Keep all product state in the owning module. The auth/theme stores and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
|
|
522
528
|
|
|
523
529
|
## UI behavior
|
|
524
530
|
Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
|
package/src/validate.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { validateArchitecture } from "./architecture.mjs";
|
|
|
2
2
|
import { validateConventions } from "./conventions.mjs";
|
|
3
3
|
import { isExcepted, loadExceptions } from "./exceptions.mjs";
|
|
4
4
|
|
|
5
|
+
const NON_EXCEPTABLE_RULES = new Set(["U5", "U6", "U7"]);
|
|
6
|
+
|
|
5
7
|
export function runValidate(root, kind) {
|
|
6
8
|
const structural = validateArchitecture(root, kind);
|
|
7
9
|
if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
|
|
@@ -9,7 +11,7 @@ export function runValidate(root, kind) {
|
|
|
9
11
|
}
|
|
10
12
|
const exceptions = loadExceptions(root);
|
|
11
13
|
const conventions = validateConventions(root, kind)
|
|
12
|
-
.filter((violation) => !isExcepted(violation.path, exceptions))
|
|
14
|
+
.filter((violation) => NON_EXCEPTABLE_RULES.has(violation.rule) || !isExcepted(violation.path, exceptions))
|
|
13
15
|
.map((violation) => `${violation.rule}: ${violation.message}`);
|
|
14
16
|
return [...structural, ...conventions];
|
|
15
17
|
}
|