@clearlydev/nx-archweave 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +265 -2
- package/dist/bin/review-verdict.js +330 -0
- package/dist/eslint/fsd-boundaries.d.ts +119 -0
- package/dist/eslint/fsd-boundaries.js +144 -0
- package/dist/eslint/index.d.ts +78 -0
- package/dist/eslint/index.js +256 -0
- package/dist/eslint/tsconfig-aliases.d.ts +18 -0
- package/dist/eslint/tsconfig-aliases.js +114 -0
- package/dist/generators/add-agent-rules/generator.js +550 -0
- package/dist/generators/add-agent-rules/render.js +46 -0
- package/dist/generators/add-backend/generator.js +784 -0
- package/dist/generators/add-backend/render.js +102 -0
- package/dist/generators/add-fsd-lite/generator.js +553 -0
- package/dist/generators/add-fsd-lite/render.js +72 -0
- package/dist/generators/add-github-templates/files/en/codeowners/.github/CODEOWNERS.template +4 -0
- package/dist/generators/add-github-templates/files/en/issue/.github/ISSUE_TEMPLATE/bug-report.yml.template +28 -0
- package/dist/generators/add-github-templates/files/en/issue/.github/ISSUE_TEMPLATE/feature-request.yml.template +17 -0
- package/dist/generators/add-github-templates/files/en/pr/.github/PULL_REQUEST_TEMPLATE.md.template +12 -0
- package/dist/generators/add-github-templates/files/ko/codeowners/.github/CODEOWNERS.template +4 -0
- package/dist/generators/add-github-templates/files/ko/issue/.github/ISSUE_TEMPLATE/bug-report.yml.template +28 -0
- package/dist/generators/add-github-templates/files/ko/issue/.github/ISSUE_TEMPLATE/feature-request.yml.template +17 -0
- package/dist/generators/add-github-templates/files/ko/pr/.github/PULL_REQUEST_TEMPLATE.md.template +12 -0
- package/dist/generators/add-github-templates/generator.js +125 -0
- package/dist/generators/add-github-templates/render.js +26 -0
- package/dist/generators/add-integration/generator.js +849 -0
- package/dist/generators/add-integration/render.js +34 -0
- package/dist/generators/add-review/generator.js +921 -0
- package/dist/generators/add-review/render.js +96 -0
- package/dist/generators/allow-builds/generator.js +436 -0
- package/dist/generators/allow-builds/render.js +82 -0
- package/dist/generators/doctor/generator.js +2462 -0
- package/dist/generators/doctor/integration-report.js +70 -0
- package/dist/generators/doctor/render.js +10 -0
- package/dist/generators/entity/generator.js +395 -0
- package/dist/generators/feature/generator.js +395 -0
- package/dist/generators/init/generator.js +3211 -0
- package/dist/generators/init/render.js +125 -0
- package/dist/generators/init/validation.js +74 -0
- package/dist/generators/install-integration/generator.js +1265 -0
- package/dist/generators/install-integration/render.js +128 -0
- package/dist/generators/page/generator.js +395 -0
- package/dist/generators/record-integration/generator.js +883 -0
- package/dist/generators/record-integration/render.js +31 -0
- package/dist/generators/shared/dry-run.js +12 -0
- package/dist/generators/shared/frontend-app.js +57 -0
- package/dist/generators/shared/generator.js +395 -0
- package/dist/generators/shared/project-type.js +39 -0
- package/dist/generators/shared/record-pipeline.js +34 -0
- package/dist/generators/shared/run-nx.js +24 -0
- package/dist/generators/shared/template-files.js +38 -0
- package/dist/generators/shared/toolkit-config-file.js +110 -0
- package/dist/generators/slice/core.js +185 -0
- package/dist/generators/slice/files/index/index.ts.template +14 -0
- package/dist/generators/slice/files/module/__name__.spec.ts.template +11 -0
- package/dist/generators/slice/files/module/__name__.ts.template +11 -0
- package/dist/generators/slice/files/segment/index.ts.template +20 -0
- package/dist/generators/slice/render.js +70 -0
- package/dist/generators/widget/generator.js +395 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +711 -0
- package/dist/lib/nx-plugin.d.ts +2 -0
- package/dist/lib/nx-plugin.js +7 -0
- package/dist/runtime/integrations-composition-root.js +20 -0
- package/dist/runtime/node-command-runner.js +81 -0
- package/generators.json +85 -0
- package/package.json +96 -4
- package/src/generators/add-agent-rules/schema.json +43 -0
- package/src/generators/add-backend/schema.json +39 -0
- package/src/generators/add-fsd-lite/schema.json +43 -0
- package/src/generators/add-github-templates/schema.json +35 -0
- package/src/generators/add-integration/schema.json +35 -0
- package/src/generators/add-review/schema.json +72 -0
- package/src/generators/allow-builds/schema.json +20 -0
- package/src/generators/doctor/schema.json +20 -0
- package/src/generators/entity/schema.json +37 -0
- package/src/generators/feature/schema.json +37 -0
- package/src/generators/init/schema.json +98 -0
- package/src/generators/install-integration/schema.json +35 -0
- package/src/generators/page/schema.json +53 -0
- package/src/generators/record-integration/schema.json +26 -0
- package/src/generators/shared/schema.json +32 -0
- package/src/generators/widget/schema.json +45 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
function describeState(state) {
|
|
2
|
+
switch (state.kind) {
|
|
3
|
+
case 'absent':
|
|
4
|
+
return 'not installed';
|
|
5
|
+
case 'already-installed':
|
|
6
|
+
return `already installed and enabled at ${state.scope} scope${state.version ? ` (version ${state.version})` : ''}`;
|
|
7
|
+
case 'installed-other-scope':
|
|
8
|
+
return `installed and enabled, but at ${state.installedScope} scope`;
|
|
9
|
+
case 'disabled':
|
|
10
|
+
return 'installed but disabled';
|
|
11
|
+
case 'ambiguous':
|
|
12
|
+
return 'more than one installed plugin matches exactly';
|
|
13
|
+
case 'cli-not-found':
|
|
14
|
+
return 'unknown — the claude CLI was not found';
|
|
15
|
+
case 'cli-error':
|
|
16
|
+
return `unknown — the claude CLI could not list plugins (${state.reason})`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function describeInstall(detail) {
|
|
20
|
+
switch (detail.kind) {
|
|
21
|
+
case 'executed':
|
|
22
|
+
return 'the official command exited successfully';
|
|
23
|
+
case 'manual-only':
|
|
24
|
+
return 'no scriptable command exists for this target — nothing was executed';
|
|
25
|
+
case 'cli-not-found':
|
|
26
|
+
return 'not executed — the claude CLI was not found';
|
|
27
|
+
case 'failed-to-start':
|
|
28
|
+
return `not executed — the claude CLI could not be started (${detail.code})`;
|
|
29
|
+
case 'cli-unsupported':
|
|
30
|
+
return 'not executed — this claude CLI does not support scriptable plugin install';
|
|
31
|
+
case 'confirmation-required':
|
|
32
|
+
return 'stopped — the marketplace declares a command that a person must confirm';
|
|
33
|
+
case 'failed':
|
|
34
|
+
return `failed${detail.failureCode ? ` (${detail.failureCode})` : ''}`;
|
|
35
|
+
case 'timeout':
|
|
36
|
+
return 'stopped — the command timed out';
|
|
37
|
+
case 'output-too-large':
|
|
38
|
+
return 'stopped — the command produced too much output';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function describeDeclaration(declaration, tool, preview) {
|
|
42
|
+
switch (declaration.action) {
|
|
43
|
+
case 'updated':
|
|
44
|
+
return `toolkit.config.json: "${tool}" ${preview ? 'would be added' : 'added'} to integrations.enabled`;
|
|
45
|
+
case 'unchanged':
|
|
46
|
+
return `toolkit.config.json: "${tool}" is already declared in integrations.enabled (unchanged)`;
|
|
47
|
+
case 'no-config':
|
|
48
|
+
return (`toolkit.config.json: not declared — this workspace has no toolkit.config.json (it was not set up with init). ` +
|
|
49
|
+
`Add "${tool}" to integrations.enabled there if you want doctor to track the declaration.`);
|
|
50
|
+
case 'unrecognized':
|
|
51
|
+
return ('toolkit.config.json: not updated — the file is not in a shape nx-archweave recognizes, so it was left alone. ' +
|
|
52
|
+
`Add "${tool}" to integrations.enabled by hand.`);
|
|
53
|
+
case 'write-failed':
|
|
54
|
+
return `toolkit.config.json: not updated — writing it failed (${declaration.message}). Add "${tool}" to integrations.enabled by hand.`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Every value here is a tool id, a URL, a command, a classification, a manifest
|
|
58
|
+
// entry (installPath excluded by construction) or a
|
|
59
|
+
// CLI message that was home-path-redacted where it was produced (install.ts).
|
|
60
|
+
export function renderInstallIntegrationView(view) {
|
|
61
|
+
const lines = [
|
|
62
|
+
`Integration: ${view.tool} (a separate project — nx-archweave only runs its official installer)`,
|
|
63
|
+
`Source repository: ${view.sourceRepository}`,
|
|
64
|
+
`Official homepage: ${view.officialHomepage}`,
|
|
65
|
+
`License: ${view.license}`,
|
|
66
|
+
view.requestedVersion === null
|
|
67
|
+
? 'Version: latest (decided by the marketplace) — the version actually installed is the one recorded'
|
|
68
|
+
: `Requested version: ${view.requestedVersion} (not enforced — the marketplace decides; the version actually installed is the one recorded)`,
|
|
69
|
+
`Target: ${view.target}`,
|
|
70
|
+
`Scope: ${view.scope}`,
|
|
71
|
+
`Official command: ${view.command}`,
|
|
72
|
+
...(view.teamMarketplace
|
|
73
|
+
? [`For teammates (project scope), the marketplace is declared in .claude/settings.json first: ${view.teamMarketplace.command}`]
|
|
74
|
+
: []),
|
|
75
|
+
...(view.workspaceFiles.length > 0 ? [`Claude Code will update in this workspace: ${view.workspaceFiles.join(', ')}`] : []),
|
|
76
|
+
`Current state: ${describeState(view.currentState)}`,
|
|
77
|
+
];
|
|
78
|
+
if (view.applyIgnoredForNxDryRun) {
|
|
79
|
+
lines.push("Note: --apply was ignored because Nx's --dry-run is set.");
|
|
80
|
+
}
|
|
81
|
+
if (view.preview && view.currentState.kind === 'absent') {
|
|
82
|
+
lines.push('', ...(view.teamMarketplace ? [`Would run: ${view.teamMarketplace.command}`] : []), `Would run: ${view.command}`, `To run it: ${view.rerunCommand}`);
|
|
83
|
+
}
|
|
84
|
+
else if (view.preview && view.teamMarketplace) {
|
|
85
|
+
lines.push('', `Would run: ${view.teamMarketplace.command}`, `To run it: ${view.rerunCommand}`);
|
|
86
|
+
}
|
|
87
|
+
if (view.teamMarketplace?.detail) {
|
|
88
|
+
const { detail } = view.teamMarketplace;
|
|
89
|
+
lines.push('', detail.kind === 'executed'
|
|
90
|
+
? 'Marketplace: declared in .claude/settings.json (extraKnownMarketplaces) — teammates get its source from the committed file'
|
|
91
|
+
: `Marketplace declaration: ${describeInstall(detail)}${detail.kind === 'failed' ? ` — ${detail.message}` : ''}`);
|
|
92
|
+
}
|
|
93
|
+
if (view.install) {
|
|
94
|
+
lines.push('', `Install: ${describeInstall(view.install.detail)}`);
|
|
95
|
+
}
|
|
96
|
+
if (view.record) {
|
|
97
|
+
const { record } = view;
|
|
98
|
+
lines.push('', `Verification: ${record.verification.verified ? 'passed' : 'not verified'}`, ...record.verification.problems.map((problem) => ` ! ${problem}`));
|
|
99
|
+
if (record.hasConflict && record.existingEntry) {
|
|
100
|
+
lines.push('Conflict: an existing manifest entry differs from what was verified now — the fresh result replaces it.', ` Existing: ${JSON.stringify(record.existingEntry)}`, ` Verified: ${JSON.stringify(record.proposedEntry)}`);
|
|
101
|
+
}
|
|
102
|
+
const installKind = view.install?.detail.kind;
|
|
103
|
+
if (record.proposedEntry) {
|
|
104
|
+
if (installKind !== undefined && installKind !== 'executed') {
|
|
105
|
+
lines.push(`The install command reported ${installKind}, but a fresh check shows the plugin installed and enabled.`);
|
|
106
|
+
}
|
|
107
|
+
lines.push(record.wasWritten
|
|
108
|
+
? 'Recorded — the following manifest entry was written to archweave-integrations.json:'
|
|
109
|
+
: 'archweave-integrations.json would get this entry:', ` ${JSON.stringify(record.proposedEntry)}`);
|
|
110
|
+
}
|
|
111
|
+
else if (installKind === 'executed') {
|
|
112
|
+
lines.push('The install command exited 0, but the fresh verification did not confirm the plugin — nothing was written.');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
lines.push('Nothing was written — verification did not succeed via the authoritative claude-cli signal.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (view.declaration) {
|
|
119
|
+
lines.push(describeDeclaration(view.declaration, view.tool, view.preview));
|
|
120
|
+
}
|
|
121
|
+
if (view.guidance.length > 0) {
|
|
122
|
+
lines.push('', 'Next steps:', ...view.guidance.map((step) => ` - ${step}`));
|
|
123
|
+
}
|
|
124
|
+
lines.push('', view.preview
|
|
125
|
+
? '(preview — only read-only checks ran; nothing was installed and no file was written)'
|
|
126
|
+
: '(applied — the lines above state exactly what was executed and written)');
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// src/generators/slice/core.ts
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// ../template-engine/dist/fsd/slice-rules.js
|
|
5
|
+
function toCamelCase(kebabName) {
|
|
6
|
+
return kebabName.split("-").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
7
|
+
}
|
|
8
|
+
function resolveSlicePublicApiName(name, publicApiName) {
|
|
9
|
+
return publicApiName ?? toCamelCase(name);
|
|
10
|
+
}
|
|
11
|
+
var IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
12
|
+
var RESERVED_WORDS = /* @__PURE__ */ new Set([
|
|
13
|
+
"await",
|
|
14
|
+
"break",
|
|
15
|
+
"case",
|
|
16
|
+
"catch",
|
|
17
|
+
"class",
|
|
18
|
+
"const",
|
|
19
|
+
"continue",
|
|
20
|
+
"debugger",
|
|
21
|
+
"default",
|
|
22
|
+
"delete",
|
|
23
|
+
"do",
|
|
24
|
+
"else",
|
|
25
|
+
"enum",
|
|
26
|
+
"export",
|
|
27
|
+
"extends",
|
|
28
|
+
"false",
|
|
29
|
+
"finally",
|
|
30
|
+
"for",
|
|
31
|
+
"function",
|
|
32
|
+
"if",
|
|
33
|
+
"implements",
|
|
34
|
+
"import",
|
|
35
|
+
"in",
|
|
36
|
+
"instanceof",
|
|
37
|
+
"interface",
|
|
38
|
+
"let",
|
|
39
|
+
"new",
|
|
40
|
+
"null",
|
|
41
|
+
"package",
|
|
42
|
+
"private",
|
|
43
|
+
"protected",
|
|
44
|
+
"public",
|
|
45
|
+
"return",
|
|
46
|
+
"static",
|
|
47
|
+
"super",
|
|
48
|
+
"switch",
|
|
49
|
+
"this",
|
|
50
|
+
"throw",
|
|
51
|
+
"true",
|
|
52
|
+
"try",
|
|
53
|
+
"typeof",
|
|
54
|
+
"var",
|
|
55
|
+
"void",
|
|
56
|
+
"while",
|
|
57
|
+
"with",
|
|
58
|
+
"yield"
|
|
59
|
+
]);
|
|
60
|
+
function isUsableSliceApiName(candidate) {
|
|
61
|
+
return IDENTIFIER_PATTERN.test(candidate) && !RESERVED_WORDS.has(candidate);
|
|
62
|
+
}
|
|
63
|
+
var ESSENCE_SEGMENT_NAMES = [
|
|
64
|
+
"components",
|
|
65
|
+
"hooks",
|
|
66
|
+
"helpers",
|
|
67
|
+
"utils",
|
|
68
|
+
"types",
|
|
69
|
+
"constants",
|
|
70
|
+
"consts",
|
|
71
|
+
"interfaces",
|
|
72
|
+
"enums",
|
|
73
|
+
"modals",
|
|
74
|
+
"stores",
|
|
75
|
+
"services"
|
|
76
|
+
];
|
|
77
|
+
function isEssenceSegmentName(name) {
|
|
78
|
+
return ESSENCE_SEGMENT_NAMES.includes(name);
|
|
79
|
+
}
|
|
80
|
+
var SEGMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
81
|
+
|
|
82
|
+
// src/generators/shared/toolkit-config-file.ts
|
|
83
|
+
var TOOLKIT_CONFIG_PATH = "toolkit.config.json";
|
|
84
|
+
function readToolkitConfigFile(tree) {
|
|
85
|
+
const raw = tree.read(TOOLKIT_CONFIG_PATH, "utf-8");
|
|
86
|
+
if (raw === null) return { raw: null, parsed: null };
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
return {
|
|
90
|
+
raw,
|
|
91
|
+
parsed: typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return { raw, parsed: null };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function readToolkitConfig(tree) {
|
|
98
|
+
return readToolkitConfigFile(tree).parsed;
|
|
99
|
+
}
|
|
100
|
+
function resolveLocaleOption(option, config) {
|
|
101
|
+
if (option) return option;
|
|
102
|
+
const locale = config?.["locale"];
|
|
103
|
+
return locale === "ko" || locale === "en" ? locale : "en";
|
|
104
|
+
}
|
|
105
|
+
var RECORDED_FRONTENDS = ["react-vite", "next", "none"];
|
|
106
|
+
|
|
107
|
+
// src/generators/shared/frontend-app.ts
|
|
108
|
+
import { getProjects } from "@nx/devkit";
|
|
109
|
+
|
|
110
|
+
// src/generators/shared/project-type.ts
|
|
111
|
+
import { parseJson } from "@nx/devkit";
|
|
112
|
+
function resolveProjectType(tree, root, declared) {
|
|
113
|
+
if (declared) return declared === "library" ? "library" : "application";
|
|
114
|
+
const prefix = root === "" || root === "." ? "" : `${root}/`;
|
|
115
|
+
if (tree.exists(`${prefix}tsconfig.lib.json`)) return "library";
|
|
116
|
+
if (tree.exists(`${prefix}tsconfig.app.json`)) return "application";
|
|
117
|
+
const raw = tree.read(`${prefix}package.json`, "utf-8");
|
|
118
|
+
if (raw === null) return "library";
|
|
119
|
+
try {
|
|
120
|
+
const manifest = parseJson(raw);
|
|
121
|
+
if (manifest && !["exports", "main", "module", "bin"].some((key) => Boolean(manifest[key]))) return "application";
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
return "library";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/generators/shared/frontend-app.ts
|
|
128
|
+
var baseNameOf = (root) => root.split("/").pop() ?? root;
|
|
129
|
+
function isEndToEnd(root) {
|
|
130
|
+
const dir = baseNameOf(root);
|
|
131
|
+
return dir === "e2e" || dir.endsWith("-e2e");
|
|
132
|
+
}
|
|
133
|
+
function isBackend(root, config) {
|
|
134
|
+
return baseNameOf(root) === "api" || (config.tags ?? []).includes("scope:backend");
|
|
135
|
+
}
|
|
136
|
+
function sourceRootOf(config) {
|
|
137
|
+
if (config.sourceRoot) return config.sourceRoot;
|
|
138
|
+
return config.root === "" || config.root === "." ? "src" : `${config.root}/src`;
|
|
139
|
+
}
|
|
140
|
+
function resolveFrontendApp(tree, explicitProject) {
|
|
141
|
+
let projects;
|
|
142
|
+
try {
|
|
143
|
+
projects = getProjects(tree);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
const pathMatch = /Cannot parse ([^:]+):/.exec(message);
|
|
147
|
+
return { kind: "invalid-project-json", detail: pathMatch ? pathMatch[1] : message };
|
|
148
|
+
}
|
|
149
|
+
const found = (project2, config2) => ({
|
|
150
|
+
kind: "found",
|
|
151
|
+
project: project2,
|
|
152
|
+
root: config2.root,
|
|
153
|
+
sourceRoot: sourceRootOf(config2)
|
|
154
|
+
});
|
|
155
|
+
if (explicitProject !== void 0) {
|
|
156
|
+
const config2 = projects.get(explicitProject);
|
|
157
|
+
return config2 ? found(explicitProject, config2) : { kind: "not-found", project: explicitProject };
|
|
158
|
+
}
|
|
159
|
+
const eligible = [...projects.entries()].filter(([, config2]) => !isEndToEnd(config2.root) && !isBackend(config2.root, config2));
|
|
160
|
+
const declared = eligible.filter(([, config2]) => config2.projectType === "application");
|
|
161
|
+
const inferred = eligible.filter(
|
|
162
|
+
([, config2]) => config2.projectType === void 0 && config2.root !== "" && config2.root !== "." && resolveProjectType(tree, config2.root, void 0) === "application"
|
|
163
|
+
);
|
|
164
|
+
const candidates = (declared.length > 0 ? declared : inferred).sort(([a], [b]) => a.localeCompare(b));
|
|
165
|
+
if (candidates.length === 0) return { kind: "none" };
|
|
166
|
+
if (candidates.length > 1) return { kind: "ambiguous", candidates: candidates.map(([name]) => name) };
|
|
167
|
+
const [project, config] = candidates[0];
|
|
168
|
+
return found(project, config);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/generators/shared/template-files.ts
|
|
172
|
+
import { existsSync } from "node:fs";
|
|
173
|
+
import { fileURLToPath } from "node:url";
|
|
174
|
+
import { generateFiles } from "@nx/devkit";
|
|
175
|
+
import { createTree } from "@nx/devkit/testing";
|
|
176
|
+
function generatorFilesDir(generator, fromUrl) {
|
|
177
|
+
for (const relative of [`../${generator}/files`, `./generators/${generator}/files`]) {
|
|
178
|
+
const dir = fileURLToPath(new URL(relative, fromUrl));
|
|
179
|
+
if (existsSync(dir)) return dir;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`The "${generator}" generator's template files are missing next to ${fileURLToPath(fromUrl)}`);
|
|
182
|
+
}
|
|
183
|
+
function renderTemplateFiles(srcFolder, target, substitutions) {
|
|
184
|
+
const scratch = createTree();
|
|
185
|
+
const seeded = new Set(scratch.listChanges().map((change) => change.path));
|
|
186
|
+
generateFiles(scratch, srcFolder, target, substitutions);
|
|
187
|
+
return scratch.listChanges().flatMap(
|
|
188
|
+
(change) => change.type === "CREATE" && change.content !== null && !seeded.has(change.path) ? [{ filePath: change.path, content: change.content.toString("utf-8") }] : []
|
|
189
|
+
).sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/generators/slice/core.ts
|
|
193
|
+
function composeSlice(input) {
|
|
194
|
+
const files = generatorFilesDir("slice", import.meta.url);
|
|
195
|
+
const directory = `${input.sourceRoot}/${input.layerDirName}/${input.name}`;
|
|
196
|
+
const shared = input.layer === "shared";
|
|
197
|
+
const values = {
|
|
198
|
+
name: input.name,
|
|
199
|
+
publicApiName: input.publicApiName,
|
|
200
|
+
layer: input.layer,
|
|
201
|
+
locale: input.locale,
|
|
202
|
+
modulePath: shared ? `./${input.name}` : `./model/${input.name}`
|
|
203
|
+
};
|
|
204
|
+
const module = renderTemplateFiles(join(files, "module"), shared ? directory : `${directory}/model`, values).sort((a, b) => Number(a.filePath.endsWith(".spec.ts")) - Number(b.filePath.endsWith(".spec.ts")));
|
|
205
|
+
const segments = shared ? [] : Array.from(new Set(input.segments)).filter((segment) => segment !== "model").flatMap((segment) => renderTemplateFiles(join(files, "segment"), `${directory}/${segment}`, { ...values, segment }));
|
|
206
|
+
return {
|
|
207
|
+
directory,
|
|
208
|
+
publicApiName: input.publicApiName,
|
|
209
|
+
files: [...renderTemplateFiles(join(files, "index"), directory, values), ...module, ...segments]
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
var KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
213
|
+
function layerDirNameFor(layer, frontend) {
|
|
214
|
+
return layer === "pages" && frontend === "next" ? "_pages" : layer;
|
|
215
|
+
}
|
|
216
|
+
function layerDirFor(layer, sourceRoot, frontend = "react-vite") {
|
|
217
|
+
return `${sourceRoot}/${layerDirNameFor(layer, frontend)}`;
|
|
218
|
+
}
|
|
219
|
+
var NEXT_CONFIG_FILES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
|
|
220
|
+
function resolveFrontendForApp(option, config, appRoot, tree) {
|
|
221
|
+
if (option === "react-vite" || option === "next") return option;
|
|
222
|
+
const recorded = config?.["frontend"];
|
|
223
|
+
if (typeof recorded === "string" && RECORDED_FRONTENDS.includes(recorded) && recorded !== "none") {
|
|
224
|
+
return recorded;
|
|
225
|
+
}
|
|
226
|
+
const prefix = appRoot === "" || appRoot === "." ? "" : `${appRoot}/`;
|
|
227
|
+
return NEXT_CONFIG_FILES.some((file) => tree.exists(`${prefix}${file}`)) ? "next" : "react-vite";
|
|
228
|
+
}
|
|
229
|
+
function listFilesUnder(tree, dir, prefix = "") {
|
|
230
|
+
return tree.children(dir).flatMap((child) => {
|
|
231
|
+
const path = `${dir}/${child}`;
|
|
232
|
+
return tree.isFile(path) ? [`${prefix}${child}`] : listFilesUnder(tree, path, `${prefix}${child}/`);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function computeSliceAction(tree, plan) {
|
|
236
|
+
if (!tree.exists(plan.directory)) return "created";
|
|
237
|
+
const expectedNames = plan.files.map((f) => f.filePath.slice(plan.directory.length + 1)).sort();
|
|
238
|
+
const actualNames = listFilesUnder(tree, plan.directory).sort();
|
|
239
|
+
if (actualNames.length !== expectedNames.length || !actualNames.every((n, i) => n === expectedNames[i])) {
|
|
240
|
+
return "skipped";
|
|
241
|
+
}
|
|
242
|
+
const allMatch = plan.files.every((f) => tree.read(f.filePath, "utf-8") === f.content);
|
|
243
|
+
return allMatch ? "unchanged" : "skipped";
|
|
244
|
+
}
|
|
245
|
+
function refusal(reason, dryRun, layer, name, app, extra = {}) {
|
|
246
|
+
const layerDir = layerDirFor(layer, app.sourceRoot);
|
|
247
|
+
return {
|
|
248
|
+
dryRun,
|
|
249
|
+
layer,
|
|
250
|
+
name,
|
|
251
|
+
project: app.project,
|
|
252
|
+
layerDir,
|
|
253
|
+
directory: `${layerDir}/${name}`,
|
|
254
|
+
publicApiName: null,
|
|
255
|
+
refused: true,
|
|
256
|
+
refusalReason: reason,
|
|
257
|
+
action: null,
|
|
258
|
+
files: [],
|
|
259
|
+
...extra
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function runFsdSlice(tree, layer, options) {
|
|
263
|
+
const dryRun = options.dryRun ?? false;
|
|
264
|
+
const unresolved = { project: null, sourceRoot: "<app>/src" };
|
|
265
|
+
if (!KEBAB_CASE.test(options.name)) {
|
|
266
|
+
return refusal("invalid-name", dryRun, layer, options.name, unresolved);
|
|
267
|
+
}
|
|
268
|
+
if (!isUsableSliceApiName(resolveSlicePublicApiName(options.name, options.publicApiName))) {
|
|
269
|
+
return refusal("invalid-public-api-name", dryRun, layer, options.name, unresolved);
|
|
270
|
+
}
|
|
271
|
+
if (layer === "shared" && isEssenceSegmentName(options.name)) {
|
|
272
|
+
return refusal("segment-by-essence", dryRun, layer, options.name, unresolved);
|
|
273
|
+
}
|
|
274
|
+
const segments = Array.from(new Set(options.segments ?? []));
|
|
275
|
+
if (segments.length > 0 && layer === "shared") {
|
|
276
|
+
return refusal("segments-on-shared", dryRun, layer, options.name, unresolved);
|
|
277
|
+
}
|
|
278
|
+
const badSegment = segments.find((segment) => !SEGMENT_NAME_PATTERN.test(segment));
|
|
279
|
+
if (badSegment !== void 0) return refusal("invalid-segment", dryRun, layer, options.name, unresolved, { detail: badSegment });
|
|
280
|
+
const essenceSegment = segments.find((segment) => isEssenceSegmentName(segment));
|
|
281
|
+
if (essenceSegment !== void 0) return refusal("segment-by-essence", dryRun, layer, options.name, unresolved, { detail: essenceSegment });
|
|
282
|
+
const app = resolveFrontendApp(tree, options.project);
|
|
283
|
+
if (app.kind === "not-found") return refusal("project-not-found", dryRun, layer, options.name, { project: app.project, sourceRoot: unresolved.sourceRoot });
|
|
284
|
+
if (app.kind === "ambiguous") return refusal("ambiguous-frontend", dryRun, layer, options.name, unresolved, { candidates: app.candidates });
|
|
285
|
+
if (app.kind === "invalid-project-json") return refusal("invalid-project-json", dryRun, layer, options.name, unresolved, { detail: app.detail });
|
|
286
|
+
if (app.kind === "none") return refusal("no-frontend-app", dryRun, layer, options.name, unresolved);
|
|
287
|
+
const config = readToolkitConfig(tree);
|
|
288
|
+
const frontend = resolveFrontendForApp(options.frontend, config, app.root, tree);
|
|
289
|
+
const layerDir = layerDirFor(layer, app.sourceRoot, frontend);
|
|
290
|
+
const locale = resolveLocaleOption(options.locale, config);
|
|
291
|
+
const plan = composeSlice({
|
|
292
|
+
layer,
|
|
293
|
+
name: options.name,
|
|
294
|
+
publicApiName: resolveSlicePublicApiName(options.name, options.publicApiName),
|
|
295
|
+
locale,
|
|
296
|
+
sourceRoot: app.sourceRoot,
|
|
297
|
+
layerDirName: layerDirNameFor(layer, frontend),
|
|
298
|
+
segments
|
|
299
|
+
});
|
|
300
|
+
const action = computeSliceAction(tree, plan);
|
|
301
|
+
if (action === "created" && !dryRun) {
|
|
302
|
+
for (const file of plan.files) tree.write(file.filePath, file.content);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
dryRun,
|
|
306
|
+
layer,
|
|
307
|
+
name: options.name,
|
|
308
|
+
project: app.project,
|
|
309
|
+
layerDir,
|
|
310
|
+
directory: plan.directory,
|
|
311
|
+
publicApiName: plan.publicApiName,
|
|
312
|
+
refused: false,
|
|
313
|
+
refusalReason: null,
|
|
314
|
+
action,
|
|
315
|
+
files: plan.files
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/generators/slice/render.ts
|
|
320
|
+
var ACTION_LINES = {
|
|
321
|
+
created: "created",
|
|
322
|
+
unchanged: "unchanged (already matches the generated content)",
|
|
323
|
+
skipped: "skipped \u2014 a slice with this name already exists with different content"
|
|
324
|
+
};
|
|
325
|
+
function renderSliceReport(report) {
|
|
326
|
+
const lines = [];
|
|
327
|
+
const label = `${report.layer} "${report.name}"`;
|
|
328
|
+
if (report.refused) {
|
|
329
|
+
lines.push(`${label}: refused \u2014 nothing was written.`);
|
|
330
|
+
lines.push("");
|
|
331
|
+
if (report.refusalReason === "invalid-name") {
|
|
332
|
+
lines.push(` "${report.name}" is not a valid kebab-case slice name.`);
|
|
333
|
+
lines.push(' Use lowercase letters, digits, and single hyphens, starting with a letter (e.g. "add-to-cart").');
|
|
334
|
+
} else if (report.refusalReason === "invalid-public-api-name") {
|
|
335
|
+
lines.push(" The public API name is not a usable JavaScript identifier (or is a reserved word).");
|
|
336
|
+
lines.push(' Pass --publicApiName with a valid identifier, e.g. "addToCart" (this also happens when');
|
|
337
|
+
lines.push(' the slice name camelCases into a reserved word, like "class" or "delete").');
|
|
338
|
+
} else if (report.refusalReason === "project-not-found") {
|
|
339
|
+
lines.push(` --project=${report.project} names no project in this workspace.`);
|
|
340
|
+
} else if (report.refusalReason === "ambiguous-frontend") {
|
|
341
|
+
lines.push(" More than one frontend app was found \u2014 rerun with --project=<one of them>:");
|
|
342
|
+
for (const name of report.candidates ?? []) lines.push(` - ${name}`);
|
|
343
|
+
} else if (report.refusalReason === "invalid-project-json") {
|
|
344
|
+
lines.push(` A project.json could not be parsed (${report.detail ?? "unknown"}) \u2014 fix it, then rerun.`);
|
|
345
|
+
} else if (report.refusalReason === "no-frontend-app") {
|
|
346
|
+
lines.push(" The workspace has no frontend application to hold FSD layers \u2014 create one, or pass --project.");
|
|
347
|
+
} else if (report.refusalReason === "invalid-segment") {
|
|
348
|
+
lines.push(` --segments: "${report.detail ?? ""}" is not a valid segment name.`);
|
|
349
|
+
lines.push(" Use lowercase letters, digits, and single hyphens, starting with a letter (e.g. ui, api, lib).");
|
|
350
|
+
} else if (report.refusalReason === "segments-on-shared") {
|
|
351
|
+
lines.push(" --segments does not apply to shared: a shared segment holds no segments of its own.");
|
|
352
|
+
lines.push(" Run the generator once per segment instead (e.g. --name=ui, then --name=api).");
|
|
353
|
+
} else {
|
|
354
|
+
const offender = report.detail ?? report.name;
|
|
355
|
+
lines.push(` "${offender}" names what a segment contains, not what it is for.`);
|
|
356
|
+
lines.push(" Canonical FSD names segments by purpose \u2014 ui, api, model, lib, config \u2014 so the code stays");
|
|
357
|
+
lines.push(" findable by what it does (e.g. a date helper lives in shared/lib, a Button in shared/ui).");
|
|
358
|
+
}
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
if (report.dryRun) {
|
|
362
|
+
lines.push("dry-run \u2014 no files were written.");
|
|
363
|
+
lines.push("");
|
|
364
|
+
}
|
|
365
|
+
lines.push(`${label}: ${ACTION_LINES[report.action]}`);
|
|
366
|
+
if (report.action === "skipped") {
|
|
367
|
+
lines.push(` ${report.directory} already exists and does not exactly match the generated content.`);
|
|
368
|
+
lines.push(" Pick a new slice name (a name collision is a signal to rename, not to merge)");
|
|
369
|
+
lines.push(" \u2014 or remove the existing directory manually first if you intended to replace it.");
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
|
372
|
+
if (report.dryRun) {
|
|
373
|
+
for (const file of report.files) {
|
|
374
|
+
lines.push("");
|
|
375
|
+
lines.push(`${file.filePath}:`);
|
|
376
|
+
lines.push(file.content);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/generators/shared/dry-run.ts
|
|
383
|
+
function resolveDryRun(options) {
|
|
384
|
+
return process.env["NX_DRY_RUN"] === "true" || (options.dryRun ?? false);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/generators/page/generator.ts
|
|
388
|
+
async function pageGenerator(tree, options) {
|
|
389
|
+
console.log(renderSliceReport(runFsdSlice(tree, "pages", { ...options, dryRun: resolveDryRun(options) })));
|
|
390
|
+
}
|
|
391
|
+
var generator_default = pageGenerator;
|
|
392
|
+
export {
|
|
393
|
+
generator_default as default,
|
|
394
|
+
pageGenerator
|
|
395
|
+
};
|