@specverse/engines 4.2.0 → 4.2.2
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/assets/templates/default/specs/main.specly +65 -0
- package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
- package/dist/libs/instance-factories/README.md +73 -0
- package/dist/libs/instance-factories/applications/README.md +51 -0
- package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
- package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
- package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
- package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
- package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
- package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
- package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
- package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
- package/dist/libs/instance-factories/cli/README.md +43 -0
- package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
- package/dist/libs/instance-factories/communication/README.md +47 -0
- package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
- package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
- package/dist/libs/instance-factories/controllers/README.md +42 -0
- package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
- package/dist/libs/instance-factories/infrastructure/README.md +29 -0
- package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
- package/dist/libs/instance-factories/orms/README.md +54 -0
- package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
- package/dist/libs/instance-factories/scaffolding/README.md +49 -0
- package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
- package/dist/libs/instance-factories/sdks/README.md +28 -0
- package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
- package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
- package/dist/libs/instance-factories/services/README.md +55 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
- package/dist/libs/instance-factories/storage/README.md +34 -0
- package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
- package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
- package/dist/libs/instance-factories/storage/redis.yaml +79 -0
- package/dist/libs/instance-factories/testing/README.md +40 -0
- package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
- package/dist/libs/instance-factories/tools/README.md +70 -0
- package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
- package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
- package/dist/libs/instance-factories/validation/README.md +38 -0
- package/dist/libs/instance-factories/validation/zod.yaml +56 -0
- package/dist/realize/engines/code-generator.d.ts.map +1 -1
- package/dist/realize/engines/code-generator.js +3 -0
- package/dist/realize/engines/code-generator.js.map +1 -1
- package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
- package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
- package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
- package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
- package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
- package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
- package/libs/instance-factories/controllers/fastify.yaml +7 -0
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
- package/package.json +2 -1
package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { METADATA_FIELDS } from "@specverse/runtime/views/core";
|
|
2
|
+
import { extractBelongsToTargets } from "./belongs-to.js";
|
|
2
3
|
const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
3
4
|
function composeDetailBody(context) {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
5
|
+
const belongsTo = extractBelongsToTargets(context.model);
|
|
6
|
+
const fkExcludes = new Set(belongsTo.map((r) => `${r.name}Id`));
|
|
7
|
+
const { business, metadata } = partitionFields(context.model, fkExcludes);
|
|
6
8
|
const lines = [];
|
|
7
9
|
lines.push('<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">');
|
|
8
10
|
if (business.length > 0) {
|
|
@@ -11,17 +13,14 @@ function composeDetailBody(context) {
|
|
|
11
13
|
lines.push(...renderField(field, { muted: false }));
|
|
12
14
|
}
|
|
13
15
|
lines.push(" </dl>");
|
|
14
|
-
} else {
|
|
16
|
+
} else if (belongsTo.length === 0) {
|
|
15
17
|
lines.push(' <p className="text-sm text-gray-400">No business fields defined for this model.</p>');
|
|
16
18
|
}
|
|
17
19
|
if (belongsTo.length > 0) {
|
|
18
20
|
lines.push("");
|
|
19
|
-
lines.push(" {/* TODO: resolve FK ids \u2192 related entity display names.");
|
|
20
|
-
lines.push(" See @specverse/runtime/views/core/entity-display for the");
|
|
21
|
-
lines.push(" canonical resolver, or load the related record in a hook. */}");
|
|
22
21
|
lines.push(' <dl className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 border-t border-gray-200 dark:border-gray-700 pt-4">');
|
|
23
22
|
for (const rel of belongsTo) {
|
|
24
|
-
lines.push(...
|
|
23
|
+
lines.push(...renderRelationshipField(rel));
|
|
25
24
|
}
|
|
26
25
|
lines.push(" </dl>");
|
|
27
26
|
}
|
|
@@ -36,11 +35,12 @@ function composeDetailBody(context) {
|
|
|
36
35
|
lines.push("</div>");
|
|
37
36
|
return lines.join("\n");
|
|
38
37
|
}
|
|
39
|
-
function partitionFields(model) {
|
|
38
|
+
function partitionFields(model, exclude = /* @__PURE__ */ new Set()) {
|
|
40
39
|
const attrs = Object.keys(model.attributes ?? {});
|
|
41
40
|
const business = [];
|
|
42
41
|
const metadata = [];
|
|
43
42
|
for (const name of attrs) {
|
|
43
|
+
if (exclude.has(name)) continue;
|
|
44
44
|
const field = { name, label: humanize(name) };
|
|
45
45
|
if (METADATA_FIELD_NAMES.has(name)) {
|
|
46
46
|
metadata.push(field);
|
|
@@ -50,14 +50,16 @@ function partitionFields(model) {
|
|
|
50
50
|
}
|
|
51
51
|
return { business, metadata };
|
|
52
52
|
}
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
function renderRelationshipField(rel) {
|
|
54
|
+
const label = humanize(rel.name);
|
|
55
|
+
const fkName = `${rel.name}Id`;
|
|
56
|
+
const optionsVar = `${rel.name}Options`;
|
|
57
|
+
return [
|
|
58
|
+
" <div>",
|
|
59
|
+
` <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>`,
|
|
60
|
+
` <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-words">{resolveEntityDisplayName((item as any).${fkName}, ${optionsVar})}</dd>`,
|
|
61
|
+
" </div>"
|
|
62
|
+
];
|
|
61
63
|
}
|
|
62
64
|
function renderField(field, opts) {
|
|
63
65
|
const label = field.label ?? humanize(field.name);
|
package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { METADATA_FIELDS } from "@specverse/runtime/views/core";
|
|
2
|
+
import { extractBelongsToTargets } from "./belongs-to.js";
|
|
2
3
|
const AUTO_GENERATED_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
3
4
|
function composeFormBody(context) {
|
|
4
|
-
const belongsTo =
|
|
5
|
-
const shadowedFKs = new Set(belongsTo.map((rel) => `${rel}Id`));
|
|
5
|
+
const belongsTo = extractBelongsToTargets(context.model);
|
|
6
|
+
const shadowedFKs = new Set(belongsTo.map((rel) => `${rel.name}Id`));
|
|
6
7
|
const fields = selectFormFields(context.model, shadowedFKs);
|
|
7
8
|
const lines = [];
|
|
8
9
|
lines.push('<div className="space-y-4">');
|
|
@@ -14,25 +15,37 @@ function composeFormBody(context) {
|
|
|
14
15
|
for (const field of fields) {
|
|
15
16
|
lines.push(...renderInput(field));
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
lines.push(
|
|
19
|
-
lines.push(" {/* TODO: swap these text inputs for <select>s that load the related");
|
|
20
|
-
lines.push(" entities. See useEntitiesQuery(TargetModel) in the runtime hooks. */}");
|
|
21
|
-
for (const rel of belongsTo) {
|
|
22
|
-
lines.push(...renderInput({
|
|
23
|
-
name: `${rel}Id`,
|
|
24
|
-
label: humanize(rel),
|
|
25
|
-
type: "String",
|
|
26
|
-
required: true
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
18
|
+
for (const rel of belongsTo) {
|
|
19
|
+
lines.push(...renderRelationshipSelect(rel));
|
|
29
20
|
}
|
|
30
21
|
lines.push("</div>");
|
|
31
22
|
return lines.join("\n");
|
|
32
23
|
}
|
|
24
|
+
function renderRelationshipSelect(rel) {
|
|
25
|
+
const fkName = `${rel.name}Id`;
|
|
26
|
+
const varName = `${rel.name}Options`;
|
|
27
|
+
const label = humanize(rel.name);
|
|
28
|
+
return [
|
|
29
|
+
" <div>",
|
|
30
|
+
` <label className="${LABEL_CLS}" htmlFor="${fkName}">${label} *</label>`,
|
|
31
|
+
` <select`,
|
|
32
|
+
` id="${fkName}"`,
|
|
33
|
+
` className="${INPUT_CLS}"`,
|
|
34
|
+
` value={String((formData as any).${fkName} ?? '')}`,
|
|
35
|
+
` onChange={e => handleChange('${fkName}', e.target.value)} required`,
|
|
36
|
+
` >`,
|
|
37
|
+
` <option value="">\u2014 choose \u2014</option>`,
|
|
38
|
+
` {${varName}.map((opt: any) => (`,
|
|
39
|
+
` <option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>`,
|
|
40
|
+
` ))}`,
|
|
41
|
+
` </select>`,
|
|
42
|
+
" </div>"
|
|
43
|
+
];
|
|
44
|
+
}
|
|
33
45
|
function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
|
|
34
46
|
const out = [];
|
|
35
47
|
const attrs = model.attributes ?? {};
|
|
48
|
+
const lifecycleStates = extractLifecycleStates(model);
|
|
36
49
|
for (const [name, rawDef] of Object.entries(attrs)) {
|
|
37
50
|
if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
|
|
38
51
|
if (skip.has(name)) continue;
|
|
@@ -40,7 +53,9 @@ function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
|
|
|
40
53
|
if (def?.auto) continue;
|
|
41
54
|
const type = def?.type ?? parseTypeFromConvention(def) ?? "String";
|
|
42
55
|
const required = def?.required === true || hasConventionFlag(def, "required");
|
|
43
|
-
const
|
|
56
|
+
const declaredValues = def?.values;
|
|
57
|
+
const lifecycleValues = lifecycleStates.get(name);
|
|
58
|
+
const values = declaredValues ?? lifecycleValues;
|
|
44
59
|
const maxLength = def?.maxLength ?? def?.max;
|
|
45
60
|
const isLongString = typeof maxLength === "number" && maxLength > 100;
|
|
46
61
|
out.push({
|
|
@@ -54,6 +69,26 @@ function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
|
|
|
54
69
|
}
|
|
55
70
|
return out;
|
|
56
71
|
}
|
|
72
|
+
function extractLifecycleStates(model) {
|
|
73
|
+
const out = /* @__PURE__ */ new Map();
|
|
74
|
+
const lifecycles = model.lifecycles ?? {};
|
|
75
|
+
for (const [name, rawDef] of Object.entries(lifecycles)) {
|
|
76
|
+
if (!rawDef || typeof rawDef !== "object") continue;
|
|
77
|
+
const def = rawDef;
|
|
78
|
+
if (Array.isArray(def.states) && def.states.length > 0) {
|
|
79
|
+
const names = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
|
|
80
|
+
if (names.length > 0) {
|
|
81
|
+
out.set(name, names);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (typeof def.flow === "string") {
|
|
86
|
+
const states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
|
|
87
|
+
if (states.length > 0) out.set(name, states);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
57
92
|
function parseTypeFromConvention(def) {
|
|
58
93
|
if (typeof def === "string") {
|
|
59
94
|
const first = def.trim().split(/\s+/)[0];
|
|
@@ -70,14 +105,6 @@ function hasConventionFlag(def, flag) {
|
|
|
70
105
|
function normalizeType(type) {
|
|
71
106
|
return type.replace(/[^A-Za-z].*$/, "");
|
|
72
107
|
}
|
|
73
|
-
function extractBelongsTo(model) {
|
|
74
|
-
const rels = model.relationships ?? {};
|
|
75
|
-
const out = [];
|
|
76
|
-
for (const [name, def] of Object.entries(rels)) {
|
|
77
|
-
if (def?.type === "belongsTo") out.push(name);
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
108
|
const INPUT_CLS = "w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
|
|
82
109
|
const LABEL_CLS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
|
83
110
|
function renderInput(field) {
|
package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js
CHANGED
|
@@ -28,14 +28,20 @@ export function getEntityDisplayName(entity: Record<string, unknown> | null | un
|
|
|
28
28
|
/**
|
|
29
29
|
* Map an entity id to the display name of the record with that id in
|
|
30
30
|
* a provided list. Handy for resolving belongsTo FK columns in
|
|
31
|
-
* list / detail views.
|
|
31
|
+
* list / detail / dashboard views.
|
|
32
|
+
*
|
|
33
|
+
* Typed as \`readonly unknown[]\` so callers can pass arrays of
|
|
34
|
+
* specific model types (e.g. \`User[]\`) without needing to cast; the
|
|
35
|
+
* runtime check on \`.id\` narrows safely.
|
|
32
36
|
*/
|
|
33
37
|
export function resolveEntityDisplayName(
|
|
34
38
|
id: unknown,
|
|
35
|
-
records:
|
|
39
|
+
records: readonly unknown[]
|
|
36
40
|
): string {
|
|
37
41
|
if (id == null) return '';
|
|
38
|
-
const match = records.find(r
|
|
42
|
+
const match = records.find((r): r is Record<string, unknown> =>
|
|
43
|
+
typeof r === 'object' && r !== null && (r as { id?: unknown }).id === id
|
|
44
|
+
);
|
|
39
45
|
return match ? getEntityDisplayName(match) : String(id);
|
|
40
46
|
}
|
|
41
47
|
`;
|
package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { createUniversalTailwindAdapter } from "@specverse/runtime/views/tailwind";
|
|
2
2
|
import { inferFieldsFromSchema } from "@specverse/runtime/views/core";
|
|
3
3
|
import { htmlToJsx } from "./html-to-jsx.js";
|
|
4
|
+
import { buildFKMap } from "./belongs-to.js";
|
|
4
5
|
const TBODY_SENTINEL = "__SPECVERSE_TBODY_ROWS__";
|
|
5
6
|
function composeListBody(context) {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
7
|
+
const fkMap = buildFKMap(context.model);
|
|
8
|
+
const inferred = inferColumns(context);
|
|
9
|
+
const inferredSet = new Set(inferred);
|
|
10
|
+
const extraFKs = [...fkMap.keys()].filter((k) => !inferredSet.has(k));
|
|
11
|
+
const columns = [...inferred, ...extraFKs];
|
|
12
|
+
const headers = columns.map((col) => {
|
|
13
|
+
const fk = fkMap.get(col);
|
|
14
|
+
return humanize(fk ? fk.name : col);
|
|
15
|
+
});
|
|
8
16
|
const adapter = createUniversalTailwindAdapter({ darkMode: true });
|
|
9
17
|
const shellHtml = adapter.components.table.render({
|
|
10
18
|
properties: { columns: headers },
|
|
11
19
|
children: TBODY_SENTINEL
|
|
12
20
|
});
|
|
13
21
|
const shellJsx = htmlToJsx(shellHtml);
|
|
14
|
-
const rowMap = buildRowMap(columns);
|
|
22
|
+
const rowMap = buildRowMap(columns, fkMap);
|
|
15
23
|
if (!shellJsx.includes(TBODY_SENTINEL)) {
|
|
16
24
|
throw new Error(
|
|
17
25
|
"composeListBody: tbody sentinel not present in adapter output. The canonical Tailwind adapter may have changed its table rendering."
|
|
@@ -25,10 +33,12 @@ function inferColumns(context) {
|
|
|
25
33
|
function humanize(name) {
|
|
26
34
|
return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
|
|
27
35
|
}
|
|
28
|
-
function buildRowMap(columns) {
|
|
29
|
-
const cells = columns.map(
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
function buildRowMap(columns, fkMap) {
|
|
37
|
+
const cells = columns.map((col) => {
|
|
38
|
+
const fk = fkMap.get(col);
|
|
39
|
+
const expr = fk ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}` : `{String((item as any).${col} ?? '')}`;
|
|
40
|
+
return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">${expr}</td>`;
|
|
41
|
+
}).join("\n");
|
|
32
42
|
return [
|
|
33
43
|
"{filtered.map((item, idx) => (",
|
|
34
44
|
" <tr",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
1
3
|
import { generate as generateViews } from "./views-generator.js";
|
|
2
4
|
import { generate as generateAppTsx } from "./app-tsx-generator.js";
|
|
3
5
|
import { generate as generatePackageJson } from "./package-json-generator.js";
|
|
@@ -9,7 +11,9 @@ import {
|
|
|
9
11
|
HASHES_FILE
|
|
10
12
|
} from "./regen-safety.js";
|
|
11
13
|
async function generate(context) {
|
|
12
|
-
const { spec,
|
|
14
|
+
const { spec, outputDir } = context;
|
|
15
|
+
const frontendDir = context.frontendDir || "frontend";
|
|
16
|
+
const projectRoot = frontendDir === "." ? outputDir : join(outputDir, frontendDir);
|
|
13
17
|
const proposed = {};
|
|
14
18
|
const viewFiles = await generateViews({ spec });
|
|
15
19
|
Object.assign(proposed, viewFiles);
|
|
@@ -18,13 +22,20 @@ async function generate(context) {
|
|
|
18
22
|
const prevManifest = loadHashManifest(projectRoot);
|
|
19
23
|
const result = reconcileWrites(projectRoot, proposed, prevManifest);
|
|
20
24
|
console.log(summarize(result, projectRoot));
|
|
21
|
-
const
|
|
22
|
-
const out = {
|
|
25
|
+
const toWrite = {
|
|
23
26
|
...result.approvedWrites,
|
|
24
|
-
[
|
|
27
|
+
[`${HASHES_DIR}/${HASHES_FILE}`]: JSON.stringify(result.manifest, null, 2) + "\n"
|
|
25
28
|
};
|
|
26
|
-
|
|
29
|
+
for (const [rel, content] of Object.entries(toWrite)) {
|
|
30
|
+
const abs = join(projectRoot, rel);
|
|
31
|
+
const dir = dirname(abs);
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(abs, content, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
27
36
|
}
|
|
37
|
+
var stdin_default = generate;
|
|
28
38
|
export {
|
|
39
|
+
stdin_default as default,
|
|
29
40
|
generate
|
|
30
41
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{MODEL_NAME}}DashboardView — generated by @specverse/realize (ReactAppStarter)
|
|
3
|
+
*
|
|
4
|
+
* Safe to edit. Edits are preserved across regeneration via content
|
|
5
|
+
* hashing (see .specverse-gen/hashes.json). To accept an upstream
|
|
6
|
+
* regeneration of this file, delete it first, then run `spv realize`.
|
|
7
|
+
*
|
|
8
|
+
* A minimal dashboard: summary counts derived from the list query,
|
|
9
|
+
* plus a compact preview of recent records. Charts and aggregation
|
|
10
|
+
* metrics are deferred — add them as the backend grows suitable
|
|
11
|
+
* endpoints.
|
|
12
|
+
*/
|
|
13
|
+
import { useMemo } from 'react';
|
|
14
|
+
import { use{{PLURAL_MODEL}}Query } from '../hooks/useApi';
|
|
15
|
+
{{RELATED_IMPORTS}}
|
|
16
|
+
import type { {{MODEL_NAME}} } from '../types/api';
|
|
17
|
+
|
|
18
|
+
interface {{MODEL_NAME}}DashboardViewProps {
|
|
19
|
+
/** Number of recent records to preview. Default 5. */
|
|
20
|
+
previewLimit?: number;
|
|
21
|
+
onSelect?: (item: {{MODEL_NAME}}) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function {{MODEL_NAME}}DashboardView({
|
|
25
|
+
previewLimit = 5,
|
|
26
|
+
onSelect,
|
|
27
|
+
}: {{MODEL_NAME}}DashboardViewProps) {
|
|
28
|
+
const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
|
|
29
|
+
{{RELATED_HOOKS}}
|
|
30
|
+
|
|
31
|
+
const preview = useMemo(
|
|
32
|
+
() => items.slice(0, previewLimit),
|
|
33
|
+
[items, previewLimit]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (isLoading) return <div className="p-4 text-gray-500">Loading dashboard…</div>;
|
|
37
|
+
if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="p-6 space-y-6">
|
|
41
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
42
|
+
{{MODEL_NAME}} dashboard
|
|
43
|
+
</h2>
|
|
44
|
+
|
|
45
|
+
{/* Pattern-rendered dashboard body (metrics + preview). Edit freely. */}
|
|
46
|
+
{{BODY}}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{MODEL_NAME}}DetailView — generated by @specverse/realize (ReactAppStarter)
|
|
3
|
+
*
|
|
4
|
+
* Safe to edit. Edits are preserved across regeneration via content
|
|
5
|
+
* hashing (see .specverse-gen/hashes.json). To accept an upstream
|
|
6
|
+
* regeneration of this file, delete it first, then run `spv realize`.
|
|
7
|
+
*/
|
|
8
|
+
import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
|
|
9
|
+
{{RELATED_IMPORTS}}
|
|
10
|
+
import type { {{MODEL_NAME}} } from '../types/api';
|
|
11
|
+
|
|
12
|
+
interface {{MODEL_NAME}}DetailViewProps {
|
|
13
|
+
entityId: string | number;
|
|
14
|
+
onEdit?: (item: {{MODEL_NAME}}) => void;
|
|
15
|
+
onBack?: () => void;
|
|
16
|
+
/** Called after the delete mutation succeeds. */
|
|
17
|
+
onDeleted?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function {{MODEL_NAME}}DetailView({
|
|
21
|
+
entityId,
|
|
22
|
+
onEdit,
|
|
23
|
+
onBack,
|
|
24
|
+
onDeleted,
|
|
25
|
+
}: {{MODEL_NAME}}DetailViewProps) {
|
|
26
|
+
const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
|
|
27
|
+
const deleteItem = useDelete{{MODEL_NAME}}Mutation();
|
|
28
|
+
{{RELATED_HOOKS}}
|
|
29
|
+
|
|
30
|
+
const item = items.find(
|
|
31
|
+
(x: {{MODEL_NAME}}) => (x as any).id === entityId
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (isLoading) return <div className="p-4 text-gray-500">Loading…</div>;
|
|
35
|
+
if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
|
|
36
|
+
if (!item) return <div className="p-4 text-gray-400">No {{SINGULAR_LOWER}} matching id {String(entityId)}.</div>;
|
|
37
|
+
|
|
38
|
+
const handleDelete = async () => {
|
|
39
|
+
if (!confirm('Delete this {{SINGULAR_LOWER}}?')) return;
|
|
40
|
+
try {
|
|
41
|
+
await deleteItem.mutateAsync((item as any).id);
|
|
42
|
+
onDeleted?.();
|
|
43
|
+
} catch {
|
|
44
|
+
// deleteItem.error is surfaced below
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="p-6 space-y-4">
|
|
50
|
+
<div className="flex items-center justify-between">
|
|
51
|
+
<div className="flex items-center gap-3">
|
|
52
|
+
{onBack && (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={onBack}
|
|
56
|
+
className="text-sm text-gray-500 hover:text-gray-700"
|
|
57
|
+
>
|
|
58
|
+
← Back
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
62
|
+
{{MODEL_NAME}} detail
|
|
63
|
+
</h2>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex gap-2">
|
|
66
|
+
{onEdit && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => onEdit(item)}
|
|
70
|
+
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
71
|
+
>
|
|
72
|
+
Edit
|
|
73
|
+
</button>
|
|
74
|
+
)}
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={handleDelete}
|
|
78
|
+
disabled={deleteItem.isPending}
|
|
79
|
+
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
|
|
80
|
+
>
|
|
81
|
+
{deleteItem.isPending ? 'Deleting…' : 'Delete'}
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Pattern-rendered detail body (fields). Edit freely. */}
|
|
87
|
+
{{BODY}}
|
|
88
|
+
|
|
89
|
+
{deleteItem.isError && (
|
|
90
|
+
<div className="p-2 text-sm text-red-600">
|
|
91
|
+
Delete failed: {String(deleteItem.error)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{MODEL_NAME}}FormView — generated by @specverse/realize (ReactAppStarter)
|
|
3
|
+
*
|
|
4
|
+
* Safe to edit. Edits are preserved across regeneration via content
|
|
5
|
+
* hashing (see .specverse-gen/hashes.json). To accept an upstream
|
|
6
|
+
* regeneration of this file, delete it first, then run `spv realize`.
|
|
7
|
+
*
|
|
8
|
+
* Controlled form. One field per non-auto-generated attribute.
|
|
9
|
+
* Mode: 'create' (default) or 'update' — passed as a prop.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
use{{PLURAL_MODEL}}Query,
|
|
14
|
+
useCreate{{MODEL_NAME}}Mutation,
|
|
15
|
+
useUpdate{{MODEL_NAME}}Mutation,
|
|
16
|
+
} from '../hooks/useApi';
|
|
17
|
+
{{RELATED_IMPORTS}}
|
|
18
|
+
import type { {{MODEL_NAME}} } from '../types/api';
|
|
19
|
+
|
|
20
|
+
type FormMode = 'create' | 'update';
|
|
21
|
+
|
|
22
|
+
interface {{MODEL_NAME}}FormViewProps {
|
|
23
|
+
mode?: FormMode;
|
|
24
|
+
/** Required in update mode. */
|
|
25
|
+
entityId?: string | number;
|
|
26
|
+
onSuccess?: (item: {{MODEL_NAME}}) => void;
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function {{MODEL_NAME}}FormView({
|
|
31
|
+
mode = 'create',
|
|
32
|
+
entityId,
|
|
33
|
+
onSuccess,
|
|
34
|
+
onCancel,
|
|
35
|
+
}: {{MODEL_NAME}}FormViewProps) {
|
|
36
|
+
const { data: items = [] } = use{{PLURAL_MODEL}}Query();
|
|
37
|
+
const createItem = useCreate{{MODEL_NAME}}Mutation();
|
|
38
|
+
const updateItem = useUpdate{{MODEL_NAME}}Mutation();
|
|
39
|
+
{{RELATED_HOOKS}}
|
|
40
|
+
|
|
41
|
+
const existing =
|
|
42
|
+
mode === 'update'
|
|
43
|
+
? items.find((x: {{MODEL_NAME}}) => (x as any).id === entityId)
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
const [formData, setFormData] = useState<Partial<{{MODEL_NAME}}>>(existing ?? {});
|
|
47
|
+
|
|
48
|
+
// When the fetched list lands after initial render (update mode),
|
|
49
|
+
// hydrate the form with the loaded entity's data.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (existing) setFormData(existing);
|
|
52
|
+
}, [existing]);
|
|
53
|
+
|
|
54
|
+
const handleChange = (field: string, value: unknown) => {
|
|
55
|
+
setFormData(prev => ({ ...prev, [field]: value }) as Partial<{{MODEL_NAME}}>);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleSubmit = async (event: React.FormEvent) => {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
try {
|
|
61
|
+
const result =
|
|
62
|
+
mode === 'create'
|
|
63
|
+
? await createItem.mutateAsync(formData as {{MODEL_NAME}})
|
|
64
|
+
: await updateItem.mutateAsync({
|
|
65
|
+
id: entityId as any,
|
|
66
|
+
data: formData as Partial<{{MODEL_NAME}}>,
|
|
67
|
+
});
|
|
68
|
+
onSuccess?.(result as {{MODEL_NAME}});
|
|
69
|
+
} catch {
|
|
70
|
+
// Mutation errors are surfaced below via createItem / updateItem.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const mutation = mode === 'create' ? createItem : updateItem;
|
|
75
|
+
const submitLabel =
|
|
76
|
+
mode === 'create'
|
|
77
|
+
? mutation.isPending ? 'Creating…' : 'Create {{MODEL_NAME}}'
|
|
78
|
+
: mutation.isPending ? 'Updating…' : 'Update {{MODEL_NAME}}';
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
|
82
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
83
|
+
{mode === 'create' ? 'New {{MODEL_NAME}}' : 'Edit {{MODEL_NAME}}'}
|
|
84
|
+
</h2>
|
|
85
|
+
|
|
86
|
+
{/* Pattern-rendered form fields. Edit freely. */}
|
|
87
|
+
{{BODY}}
|
|
88
|
+
|
|
89
|
+
<div className="flex gap-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
90
|
+
<button
|
|
91
|
+
type="submit"
|
|
92
|
+
disabled={mutation.isPending}
|
|
93
|
+
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
|
94
|
+
>
|
|
95
|
+
{submitLabel}
|
|
96
|
+
</button>
|
|
97
|
+
{onCancel && (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onCancel}
|
|
101
|
+
className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
|
102
|
+
>
|
|
103
|
+
Cancel
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{mutation.isError && (
|
|
109
|
+
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
|
110
|
+
{mode === 'create' ? 'Create failed: ' : 'Update failed: '}
|
|
111
|
+
{String(mutation.error)}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</form>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{MODEL_NAME}}ListView — generated by @specverse/realize (ReactAppStarter)
|
|
3
|
+
*
|
|
4
|
+
* Safe to edit. Edits are preserved across regeneration via content
|
|
5
|
+
* hashing (see .specverse-gen/hashes.json). To accept an upstream
|
|
6
|
+
* regeneration of this file, delete it first, then run `spv realize`.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useMemo } from 'react';
|
|
9
|
+
import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
|
|
10
|
+
{{RELATED_IMPORTS}}
|
|
11
|
+
import type { {{MODEL_NAME}} } from '../types/api';
|
|
12
|
+
|
|
13
|
+
interface {{MODEL_NAME}}ListViewProps {
|
|
14
|
+
onSelect?: (item: {{MODEL_NAME}}) => void;
|
|
15
|
+
onCreate?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}ListViewProps) {
|
|
19
|
+
const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
|
|
20
|
+
const deleteItem = useDelete{{MODEL_NAME}}Mutation();
|
|
21
|
+
{{RELATED_HOOKS}}
|
|
22
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
23
|
+
|
|
24
|
+
const filtered = useMemo(
|
|
25
|
+
() =>
|
|
26
|
+
items.filter((item: {{MODEL_NAME}}) =>
|
|
27
|
+
// Cast to `any` inside JSX context — `Record<string, unknown>`
|
|
28
|
+
// looks like a JSX opening tag to the TSX parser when this
|
|
29
|
+
// expression ends up inside JSX. `any` is safe here: we only
|
|
30
|
+
// read values for substring-matching against the search term.
|
|
31
|
+
Object.values(item as any).some(v =>
|
|
32
|
+
String(v ?? '').toLowerCase().includes(searchTerm.toLowerCase())
|
|
33
|
+
)
|
|
34
|
+
),
|
|
35
|
+
[items, searchTerm]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (isLoading) return <div className="p-4 text-gray-500">Loading {{PLURAL_LOWER}}…</div>;
|
|
39
|
+
if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="p-6 space-y-4">
|
|
43
|
+
<div className="flex items-center justify-between">
|
|
44
|
+
<input
|
|
45
|
+
type="search"
|
|
46
|
+
placeholder="Search {{PLURAL_LOWER}}…"
|
|
47
|
+
value={searchTerm}
|
|
48
|
+
onChange={e => setSearchTerm(e.target.value)}
|
|
49
|
+
className="w-64 rounded border border-gray-300 px-3 py-2 text-sm"
|
|
50
|
+
/>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={onCreate}
|
|
54
|
+
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
55
|
+
>
|
|
56
|
+
+ New {{MODEL_NAME}}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Pattern-rendered list body (table + rows). Edit freely. */}
|
|
61
|
+
{{BODY}}
|
|
62
|
+
|
|
63
|
+
{filtered.length === 0 && (
|
|
64
|
+
<div className="p-8 text-center text-gray-400">No {{PLURAL_LOWER}} yet.</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{deleteItem.isError && (
|
|
68
|
+
<div className="p-2 text-sm text-red-600">
|
|
69
|
+
Delete failed: {String(deleteItem.error)}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|