@specverse/engines 4.1.30 → 4.2.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/assets/examples/manifests/frontend-only.yaml +3 -6
- package/assets/examples/manifests/fullstack-app.yaml +5 -7
- package/assets/examples/manifests/fullstack-monorepo.yaml +3 -6
- package/dist/inference/comprehensive-engine.d.ts.map +1 -1
- package/dist/inference/comprehensive-engine.js +3 -19
- package/dist/inference/comprehensive-engine.js.map +1 -1
- package/dist/inference/core/rule-engine.d.ts +31 -0
- package/dist/inference/core/rule-engine.d.ts.map +1 -1
- package/dist/inference/core/rule-engine.js +117 -33
- package/dist/inference/core/rule-engine.js.map +1 -1
- package/dist/inference/core/rule-file-types.d.ts +0 -2
- package/dist/inference/core/rule-file-types.d.ts.map +1 -1
- package/dist/inference/core/rule-file-types.js +3 -6
- package/dist/inference/core/rule-file-types.js.map +1 -1
- package/dist/inference/core/rule-loader.d.ts +5 -15
- package/dist/inference/core/rule-loader.d.ts.map +1 -1
- package/dist/inference/core/rule-loader.js +43 -132
- package/dist/inference/core/rule-loader.js.map +1 -1
- package/dist/inference/core/types.d.ts +0 -6
- package/dist/inference/core/types.d.ts.map +1 -1
- package/dist/inference/core/types.js +0 -4
- package/dist/inference/core/types.js.map +1 -1
- package/dist/inference/logical/generators/component-type-resolver.d.ts +0 -26
- package/dist/inference/logical/generators/component-type-resolver.d.ts.map +1 -1
- package/dist/inference/logical/generators/component-type-resolver.js +0 -19
- package/dist/inference/logical/generators/component-type-resolver.js.map +1 -1
- package/dist/inference/logical/generators/specialist-view-expander.d.ts +1 -17
- package/dist/inference/logical/generators/specialist-view-expander.d.ts.map +1 -1
- package/dist/inference/logical/generators/specialist-view-expander.js +0 -15
- package/dist/inference/logical/generators/specialist-view-expander.js.map +1 -1
- package/dist/inference/logical/generators/view-generator.d.ts +4 -14
- package/dist/inference/logical/generators/view-generator.d.ts.map +1 -1
- package/dist/inference/logical/generators/view-generator.js +6 -26
- package/dist/inference/logical/generators/view-generator.js.map +1 -1
- package/dist/inference/logical/index.d.ts +2 -2
- package/dist/inference/logical/index.d.ts.map +1 -1
- package/dist/inference/logical/logical-engine.d.ts.map +1 -1
- package/dist/inference/logical/logical-engine.js +17 -80
- package/dist/inference/logical/logical-engine.js.map +1 -1
- package/dist/inference/quint-transpiler.d.ts +5 -3
- package/dist/inference/quint-transpiler.d.ts.map +1 -1
- package/dist/inference/quint-transpiler.js +11 -6
- package/dist/inference/quint-transpiler.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +110 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +121 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +78 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +190 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +45 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/html-to-jsx.js +192 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +46 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +30 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +38 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/regen-safety.js +89 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +56 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +66 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +14 -11
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +15 -22
- package/dist/realize/index.js.map +1 -1
- package/dist/registry/utils/manifest-adapter.d.ts +8 -1
- package/dist/registry/utils/manifest-adapter.d.ts.map +1 -1
- package/dist/registry/utils/manifest-adapter.js +8 -1
- package/dist/registry/utils/manifest-adapter.js.map +1 -1
- package/libs/instance-factories/applications/react-app-starter.yaml +150 -0
- package/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +153 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +145 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +175 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/helpers-emitter.test.ts +55 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/html-to-jsx.test.ts +140 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +146 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +163 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p2-factory-imports.test.ts +116 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +183 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/regen-safety.test.ts +144 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +114 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/view-emitter.test.ts +107 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +139 -0
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +141 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +174 -0
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +135 -0
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +306 -0
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +60 -0
- package/libs/instance-factories/applications/templates/react-starter/html-to-jsx.ts +334 -0
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +120 -0
- package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +80 -0
- package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +57 -0
- package/libs/instance-factories/applications/templates/react-starter/regen-safety.ts +157 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +47 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +94 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +114 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +72 -0
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +151 -0
- package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +137 -0
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +14 -11
- package/package.json +3 -3
- package/dist/libs/instance-factories/applications/templates/react/_view-components-source.js +0 -530
- package/dist/libs/instance-factories/applications/templates/react/app-tsx-generator.js +0 -73
- package/dist/libs/instance-factories/applications/templates/react/field-helpers-generator.js +0 -99
- package/dist/libs/instance-factories/applications/templates/react/package-json-generator.js +0 -49
- package/dist/libs/instance-factories/applications/templates/react/pattern-adapter-generator.js +0 -156
- package/dist/libs/instance-factories/applications/templates/react/react-pattern-adapter.js +0 -935
- package/dist/libs/instance-factories/applications/templates/react/relationship-field-generator.js +0 -143
- package/dist/libs/instance-factories/applications/templates/react/tailwind-adapter-generator.js +0 -646
- package/dist/libs/instance-factories/applications/templates/react/tailwind-adapter-wrapper-generator.js +0 -65
- package/dist/libs/instance-factories/applications/templates/react/view-dashboard-generator.js +0 -143
- package/dist/libs/instance-factories/applications/templates/react/view-detail-generator.js +0 -143
- package/dist/libs/instance-factories/applications/templates/react/view-form-generator.js +0 -355
- package/dist/libs/instance-factories/applications/templates/react/view-list-generator.js +0 -91
- package/dist/libs/instance-factories/applications/templates/react/view-router-generator.js +0 -79
- package/dist/libs/instance-factories/views/index.js +0 -48
- package/dist/libs/instance-factories/views/templates/react/adapters/antd-adapter.js +0 -742
- package/dist/libs/instance-factories/views/templates/react/adapters/mui-adapter.js +0 -824
- package/dist/libs/instance-factories/views/templates/react/adapters/shadcn-adapter.js +0 -719
- package/dist/libs/instance-factories/views/templates/react/app-generator.js +0 -45
- package/dist/libs/instance-factories/views/templates/react/components-generator.js +0 -820
- package/dist/libs/instance-factories/views/templates/react/forms-generator.js +0 -275
- package/dist/libs/instance-factories/views/templates/react/frontend-package-json-generator.js +0 -46
- package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +0 -81
- package/dist/libs/instance-factories/views/templates/react/index-css-generator.js +0 -9
- package/dist/libs/instance-factories/views/templates/react/index-html-generator.js +0 -23
- package/dist/libs/instance-factories/views/templates/react/main-tsx-generator.js +0 -21
- package/dist/libs/instance-factories/views/templates/react/react-component-generator.js +0 -299
- package/dist/libs/instance-factories/views/templates/react/router-generator.js +0 -136
- package/dist/libs/instance-factories/views/templates/react/router-generic-generator.js +0 -107
- package/dist/libs/instance-factories/views/templates/react/shared-utils-generator.js +0 -187
- package/dist/libs/instance-factories/views/templates/react/spec-json-generator.js +0 -7
- package/dist/libs/instance-factories/views/templates/react/types-generator.js +0 -56
- package/dist/libs/instance-factories/views/templates/react/views-metadata-generator.js +0 -27
- package/dist/libs/instance-factories/views/templates/react/vite-config-generator.js +0 -29
- package/dist/libs/instance-factories/views/templates/runtime/runtime-view-renderer.js +0 -261
- package/dist/libs/instance-factories/views/templates/shared/adapter-types.js +0 -34
- package/dist/libs/instance-factories/views/templates/shared/atomic-components-registry.js +0 -800
- package/dist/libs/instance-factories/views/templates/shared/base-generator.js +0 -305
- package/dist/libs/instance-factories/views/templates/shared/component-metadata.js +0 -517
- package/dist/libs/instance-factories/views/templates/shared/composite-pattern-types.js +0 -0
- package/dist/libs/instance-factories/views/templates/shared/composite-patterns.js +0 -445
- package/dist/libs/instance-factories/views/templates/shared/index.js +0 -80
- package/dist/libs/instance-factories/views/templates/shared/pattern-validator.js +0 -210
- package/dist/libs/instance-factories/views/templates/shared/property-mapper.js +0 -492
- package/dist/libs/instance-factories/views/templates/shared/syntax-mapper.js +0 -321
- package/dist/realize/index.js.bak +0 -758
- package/libs/instance-factories/applications/react-app.yaml +0 -186
- package/libs/instance-factories/applications/templates/react/_view-components-source.ts +0 -555
- package/libs/instance-factories/applications/templates/react/app-tsx-generator.ts +0 -94
- package/libs/instance-factories/applications/templates/react/field-helpers-generator.ts +0 -106
- package/libs/instance-factories/applications/templates/react/package-json-generator.ts +0 -57
- package/libs/instance-factories/applications/templates/react/pattern-adapter-generator.ts +0 -179
- package/libs/instance-factories/applications/templates/react/react-pattern-adapter.tsx +0 -1347
- package/libs/instance-factories/applications/templates/react/relationship-field-generator.ts +0 -150
- package/libs/instance-factories/applications/templates/react/tailwind-adapter-generator.ts +0 -704
- package/libs/instance-factories/applications/templates/react/tailwind-adapter-wrapper-generator.ts +0 -84
- package/libs/instance-factories/applications/templates/react/view-dashboard-generator.ts +0 -150
- package/libs/instance-factories/applications/templates/react/view-detail-generator.ts +0 -150
- package/libs/instance-factories/applications/templates/react/view-form-generator.ts +0 -362
- package/libs/instance-factories/applications/templates/react/view-list-generator.ts +0 -98
- package/libs/instance-factories/applications/templates/react/view-router-generator.ts +0 -89
- package/libs/instance-factories/views/README.md +0 -62
- package/libs/instance-factories/views/index.d.ts +0 -13
- package/libs/instance-factories/views/index.d.ts.map +0 -1
- package/libs/instance-factories/views/index.js +0 -18
- package/libs/instance-factories/views/index.js.map +0 -1
- package/libs/instance-factories/views/index.ts +0 -45
- package/libs/instance-factories/views/react-components.yaml +0 -129
- package/libs/instance-factories/views/templates/ARCHITECTURE.md +0 -198
- package/libs/instance-factories/views/templates/react/adapters/antd-adapter.ts +0 -869
- package/libs/instance-factories/views/templates/react/adapters/mui-adapter.ts +0 -953
- package/libs/instance-factories/views/templates/react/adapters/shadcn-adapter.ts +0 -806
- package/libs/instance-factories/views/templates/react/app-generator.ts +0 -55
- package/libs/instance-factories/views/templates/react/components-generator.ts +0 -938
- package/libs/instance-factories/views/templates/react/forms-generator.ts +0 -325
- package/libs/instance-factories/views/templates/react/frontend-package-json-generator.ts +0 -57
- package/libs/instance-factories/views/templates/react/hooks-generator.ts +0 -106
- package/libs/instance-factories/views/templates/react/index-css-generator.ts +0 -14
- package/libs/instance-factories/views/templates/react/index-html-generator.ts +0 -34
- package/libs/instance-factories/views/templates/react/main-tsx-generator.ts +0 -29
- package/libs/instance-factories/views/templates/react/react-component-generator.d.ts +0 -152
- package/libs/instance-factories/views/templates/react/react-component-generator.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/react/react-component-generator.js +0 -398
- package/libs/instance-factories/views/templates/react/react-component-generator.js.map +0 -1
- package/libs/instance-factories/views/templates/react/react-component-generator.ts +0 -533
- package/libs/instance-factories/views/templates/react/router-generator.ts +0 -197
- package/libs/instance-factories/views/templates/react/router-generic-generator.ts +0 -132
- package/libs/instance-factories/views/templates/react/shared-utils-generator.ts +0 -196
- package/libs/instance-factories/views/templates/react/spec-json-generator.ts +0 -17
- package/libs/instance-factories/views/templates/react/types-generator.ts +0 -76
- package/libs/instance-factories/views/templates/react/views-metadata-generator.ts +0 -42
- package/libs/instance-factories/views/templates/react/vite-config-generator.ts +0 -38
- package/libs/instance-factories/views/templates/runtime/runtime-view-renderer.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/runtime/runtime-view-renderer.js.map +0 -1
- package/libs/instance-factories/views/templates/runtime/runtime-view-renderer.ts +0 -474
- package/libs/instance-factories/views/templates/shared/__tests__/composite-patterns.test.ts +0 -242
- package/libs/instance-factories/views/templates/shared/adapter-types.d.ts +0 -77
- package/libs/instance-factories/views/templates/shared/adapter-types.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/adapter-types.js +0 -47
- package/libs/instance-factories/views/templates/shared/adapter-types.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/adapter-types.ts +0 -142
- package/libs/instance-factories/views/templates/shared/atomic-components-registry.d.ts +0 -63
- package/libs/instance-factories/views/templates/shared/atomic-components-registry.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/atomic-components-registry.js +0 -822
- package/libs/instance-factories/views/templates/shared/atomic-components-registry.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/atomic-components-registry.ts +0 -908
- package/libs/instance-factories/views/templates/shared/base-generator.d.ts +0 -247
- package/libs/instance-factories/views/templates/shared/base-generator.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/base-generator.js +0 -363
- package/libs/instance-factories/views/templates/shared/base-generator.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/base-generator.ts +0 -608
- package/libs/instance-factories/views/templates/shared/component-metadata.d.ts +0 -254
- package/libs/instance-factories/views/templates/shared/component-metadata.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/component-metadata.js +0 -602
- package/libs/instance-factories/views/templates/shared/component-metadata.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/component-metadata.ts +0 -803
- package/libs/instance-factories/views/templates/shared/composite-pattern-types.ts +0 -250
- package/libs/instance-factories/views/templates/shared/composite-patterns.ts +0 -535
- package/libs/instance-factories/views/templates/shared/index.ts +0 -68
- package/libs/instance-factories/views/templates/shared/pattern-validator.ts +0 -279
- package/libs/instance-factories/views/templates/shared/property-mapper.d.ts +0 -149
- package/libs/instance-factories/views/templates/shared/property-mapper.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/property-mapper.js +0 -580
- package/libs/instance-factories/views/templates/shared/property-mapper.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/property-mapper.ts +0 -700
- package/libs/instance-factories/views/templates/shared/syntax-mapper.d.ts +0 -143
- package/libs/instance-factories/views/templates/shared/syntax-mapper.d.ts.map +0 -1
- package/libs/instance-factories/views/templates/shared/syntax-mapper.js +0 -420
- package/libs/instance-factories/views/templates/shared/syntax-mapper.js.map +0 -1
- package/libs/instance-factories/views/templates/shared/syntax-mapper.ts +0 -539
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form-view body composer for ReactAppStarter
|
|
3
|
+
*
|
|
4
|
+
* Emits one <label>+<input> pair per editable attribute of the model.
|
|
5
|
+
* Input type is inferred from the attribute's type + constraints
|
|
6
|
+
* (mirrors the canonical mapping in
|
|
7
|
+
* `entities/src/core/views/inference/component-mappings.json`, minus
|
|
8
|
+
* the atomic-component indirection).
|
|
9
|
+
*
|
|
10
|
+
* Auto-generated fields (id, timestamps, `auto=...` markers) are
|
|
11
|
+
* omitted — the backend assigns them.
|
|
12
|
+
*
|
|
13
|
+
* Relationship fields (belongsTo) are emitted as plain-text inputs
|
|
14
|
+
* for the FK column with a TODO comment. Wiring them to a proper
|
|
15
|
+
* dropdown requires a hook that loads the related list at form render
|
|
16
|
+
* time; deferred to a later pass. The inline TODO is the hook for
|
|
17
|
+
* users who want to upgrade.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { METADATA_FIELDS } from '@specverse/runtime/views/core';
|
|
21
|
+
import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Attribute names the backend always generates — never render as
|
|
25
|
+
* inputs. Sourced from the pattern library's metadata set; any
|
|
26
|
+
* attribute with `auto=...` or category=metadata is additionally
|
|
27
|
+
* skipped via per-field checks in `selectFormFields` below.
|
|
28
|
+
*/
|
|
29
|
+
const AUTO_GENERATED_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compose the form body as JSX-safe source. Dropped at `{{BODY}}`.
|
|
33
|
+
*/
|
|
34
|
+
export function composeFormBody(context: EmitContext): string {
|
|
35
|
+
const belongsTo = extractBelongsTo(context.model);
|
|
36
|
+
// FK columns shadowed by a belongsTo relationship are emitted in the
|
|
37
|
+
// belongsTo section with a nicer label ("Author" instead of
|
|
38
|
+
// "Author Id") — skip them in the main loop so we don't render twice.
|
|
39
|
+
const shadowedFKs = new Set(belongsTo.map(rel => `${rel}Id`));
|
|
40
|
+
const fields = selectFormFields(context.model, shadowedFKs);
|
|
41
|
+
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
lines.push('<div className="space-y-4">');
|
|
44
|
+
|
|
45
|
+
if (fields.length === 0 && belongsTo.length === 0) {
|
|
46
|
+
lines.push(' <p className="text-sm text-gray-400">No editable fields for this model.</p>');
|
|
47
|
+
lines.push('</div>');
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const field of fields) {
|
|
52
|
+
lines.push(...renderInput(field));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (belongsTo.length > 0) {
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push(' {/* TODO: swap these text inputs for <select>s that load the related');
|
|
58
|
+
lines.push(' entities. See useEntitiesQuery(TargetModel) in the runtime hooks. */}');
|
|
59
|
+
for (const rel of belongsTo) {
|
|
60
|
+
lines.push(...renderInput({
|
|
61
|
+
name: `${rel}Id`,
|
|
62
|
+
label: humanize(rel),
|
|
63
|
+
type: 'String',
|
|
64
|
+
required: true,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push('</div>');
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
74
|
+
// Field selection
|
|
75
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
interface FieldInfo {
|
|
78
|
+
name: string;
|
|
79
|
+
label: string;
|
|
80
|
+
type: string;
|
|
81
|
+
required: boolean;
|
|
82
|
+
values?: string[]; // enum options if present
|
|
83
|
+
isLongString?: boolean;
|
|
84
|
+
auto?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function selectFormFields(
|
|
88
|
+
model: ModelSpec,
|
|
89
|
+
skip: Set<string> = new Set()
|
|
90
|
+
): FieldInfo[] {
|
|
91
|
+
const out: FieldInfo[] = [];
|
|
92
|
+
const attrs = model.attributes ?? {};
|
|
93
|
+
for (const [name, rawDef] of Object.entries(attrs)) {
|
|
94
|
+
if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
|
|
95
|
+
if (skip.has(name)) continue;
|
|
96
|
+
|
|
97
|
+
const def = rawDef as AttributeShape;
|
|
98
|
+
if (def?.auto) continue;
|
|
99
|
+
|
|
100
|
+
// Heuristic: parse a type like "String required unique" to detect
|
|
101
|
+
// convention-string shapes. We prefer the structured fields if
|
|
102
|
+
// they're present.
|
|
103
|
+
const type = (def?.type ?? parseTypeFromConvention(def)) ?? 'String';
|
|
104
|
+
const required = def?.required === true || hasConventionFlag(def, 'required');
|
|
105
|
+
const values = def?.values as string[] | undefined;
|
|
106
|
+
const maxLength = def?.maxLength ?? def?.max as number | undefined;
|
|
107
|
+
const isLongString =
|
|
108
|
+
typeof maxLength === 'number' && maxLength > 100;
|
|
109
|
+
|
|
110
|
+
out.push({
|
|
111
|
+
name,
|
|
112
|
+
label: humanize(name),
|
|
113
|
+
type: normalizeType(type),
|
|
114
|
+
required,
|
|
115
|
+
values,
|
|
116
|
+
isLongString,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Structured attribute object from the parsed spec. */
|
|
123
|
+
interface AttributeShape {
|
|
124
|
+
type?: string;
|
|
125
|
+
required?: boolean;
|
|
126
|
+
unique?: boolean;
|
|
127
|
+
auto?: string;
|
|
128
|
+
values?: string[];
|
|
129
|
+
maxLength?: number;
|
|
130
|
+
max?: number;
|
|
131
|
+
[k: string]: unknown;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseTypeFromConvention(def: unknown): string | undefined {
|
|
135
|
+
// Convention form: "String required unique"
|
|
136
|
+
if (typeof def === 'string') {
|
|
137
|
+
const first = def.trim().split(/\s+/)[0];
|
|
138
|
+
return first;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasConventionFlag(def: unknown, flag: string): boolean {
|
|
144
|
+
if (typeof def === 'string') {
|
|
145
|
+
return def.split(/\s+/).includes(flag);
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeType(type: string): string {
|
|
151
|
+
// Strip any trailing modifiers from a compact convention-string form.
|
|
152
|
+
return type.replace(/[^A-Za-z].*$/, '');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractBelongsTo(model: ModelSpec): string[] {
|
|
156
|
+
const rels = model.relationships ?? {};
|
|
157
|
+
const out: string[] = [];
|
|
158
|
+
for (const [name, def] of Object.entries(rels)) {
|
|
159
|
+
if ((def as { type?: string })?.type === 'belongsTo') out.push(name);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
165
|
+
// Input rendering
|
|
166
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const INPUT_CLS =
|
|
169
|
+
'w-full rounded border border-gray-300 px-3 py-2 text-sm ' +
|
|
170
|
+
'focus:outline-none focus:ring-2 focus:ring-blue-500 ' +
|
|
171
|
+
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100';
|
|
172
|
+
|
|
173
|
+
const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
|
174
|
+
|
|
175
|
+
function renderInput(field: FieldInfo): string[] {
|
|
176
|
+
const requiredMark = field.required ? ' *' : '';
|
|
177
|
+
const reqAttr = field.required ? ' required' : '';
|
|
178
|
+
|
|
179
|
+
// Enum → <select>
|
|
180
|
+
if (field.values && field.values.length > 0) {
|
|
181
|
+
const options = field.values
|
|
182
|
+
.map(v => ` <option value="${escapeAttr(v)}">${escapeText(v)}</option>`)
|
|
183
|
+
.join('\n');
|
|
184
|
+
return [
|
|
185
|
+
' <div>',
|
|
186
|
+
` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
|
|
187
|
+
` <select`,
|
|
188
|
+
` id="${field.name}"`,
|
|
189
|
+
` className="${INPUT_CLS}"`,
|
|
190
|
+
` value={String((formData as any).${field.name} ?? '')}`,
|
|
191
|
+
` onChange={e => handleChange('${field.name}', e.target.value)}${reqAttr}`,
|
|
192
|
+
` >`,
|
|
193
|
+
` <option value="">— choose —</option>`,
|
|
194
|
+
options,
|
|
195
|
+
` </select>`,
|
|
196
|
+
' </div>',
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Long-string / Text → <textarea>
|
|
201
|
+
if (field.type === 'Text' || field.isLongString) {
|
|
202
|
+
return [
|
|
203
|
+
' <div>',
|
|
204
|
+
` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
|
|
205
|
+
` <textarea`,
|
|
206
|
+
` id="${field.name}"`,
|
|
207
|
+
` className="${INPUT_CLS}"`,
|
|
208
|
+
` rows={4}`,
|
|
209
|
+
` value={String((formData as any).${field.name} ?? '')}`,
|
|
210
|
+
` onChange={e => handleChange('${field.name}', e.target.value)}${reqAttr}`,
|
|
211
|
+
` />`,
|
|
212
|
+
' </div>',
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Boolean → checkbox
|
|
217
|
+
if (field.type === 'Boolean') {
|
|
218
|
+
return [
|
|
219
|
+
' <div>',
|
|
220
|
+
` <label className="inline-flex items-center gap-2">`,
|
|
221
|
+
` <input`,
|
|
222
|
+
` id="${field.name}"`,
|
|
223
|
+
` type="checkbox"`,
|
|
224
|
+
` checked={Boolean((formData as any).${field.name})}`,
|
|
225
|
+
` onChange={e => handleChange('${field.name}', e.target.checked)}`,
|
|
226
|
+
` />`,
|
|
227
|
+
` <span className="text-sm text-gray-700 dark:text-gray-300">${field.label}${requiredMark}</span>`,
|
|
228
|
+
` </label>`,
|
|
229
|
+
' </div>',
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const inputType = mapInputType(field.type);
|
|
234
|
+
return [
|
|
235
|
+
' <div>',
|
|
236
|
+
` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
|
|
237
|
+
` <input`,
|
|
238
|
+
` id="${field.name}"`,
|
|
239
|
+
` type="${inputType}"`,
|
|
240
|
+
` className="${INPUT_CLS}"`,
|
|
241
|
+
` value={String((formData as any).${field.name} ?? '')}`,
|
|
242
|
+
` onChange={e => handleChange('${field.name}', ${coerceOnChange(field.type)})}${reqAttr}`,
|
|
243
|
+
` />`,
|
|
244
|
+
' </div>',
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Scalar SpecVerse type → HTML input type attribute. */
|
|
249
|
+
function mapInputType(type: string): string {
|
|
250
|
+
switch (type) {
|
|
251
|
+
case 'Integer':
|
|
252
|
+
case 'Float':
|
|
253
|
+
case 'Number':
|
|
254
|
+
case 'Money':
|
|
255
|
+
return 'number';
|
|
256
|
+
case 'DateTime':
|
|
257
|
+
return 'datetime-local';
|
|
258
|
+
case 'Date':
|
|
259
|
+
return 'date';
|
|
260
|
+
case 'Email':
|
|
261
|
+
return 'email';
|
|
262
|
+
case 'URL':
|
|
263
|
+
return 'url';
|
|
264
|
+
case 'String':
|
|
265
|
+
case 'UUID':
|
|
266
|
+
default:
|
|
267
|
+
return 'text';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* JSX snippet for the onChange handler's second argument — coerces
|
|
273
|
+
* the string value to the right shape for the field. The user can
|
|
274
|
+
* edit this; defaults are a reasonable starter.
|
|
275
|
+
*/
|
|
276
|
+
function coerceOnChange(type: string): string {
|
|
277
|
+
switch (type) {
|
|
278
|
+
case 'Integer':
|
|
279
|
+
case 'Number':
|
|
280
|
+
return "e.target.value === '' ? undefined : Number(e.target.value)";
|
|
281
|
+
case 'Float':
|
|
282
|
+
case 'Money':
|
|
283
|
+
return "e.target.value === '' ? undefined : parseFloat(e.target.value)";
|
|
284
|
+
default:
|
|
285
|
+
return 'e.target.value';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
290
|
+
// Small helpers
|
|
291
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function humanize(name: string): string {
|
|
294
|
+
return name
|
|
295
|
+
.replace(/([A-Z])/g, ' $1')
|
|
296
|
+
.replace(/^./, c => c.toUpperCase())
|
|
297
|
+
.trim();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function escapeAttr(s: string): string {
|
|
301
|
+
return s.replace(/"/g, '"');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function escapeText(s: string): string {
|
|
305
|
+
return s.replace(/</g, '<').replace(/>/g, '>');
|
|
306
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers emitter for ReactAppStarter
|
|
3
|
+
*
|
|
4
|
+
* Returns the source of small utility files that generated projects
|
|
5
|
+
* use. Inlined into `src/lib/` at realize time rather than imported
|
|
6
|
+
* from `@specverse/runtime` — Factory B's defining trait is that the
|
|
7
|
+
* generated code has no @specverse/* runtime dependency.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Source of `src/lib/entity-display.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors `getEntityDisplayName` from
|
|
14
|
+
* `@specverse/runtime/views/core/entity-display`. Used by:
|
|
15
|
+
* - The FK-resolution TODO in detail-view output (users who upgrade
|
|
16
|
+
* the plain-FK display to a resolved display name import this).
|
|
17
|
+
* - Any user-written code that wants to label a record.
|
|
18
|
+
*/
|
|
19
|
+
export function emitEntityDisplay(): string {
|
|
20
|
+
return `/**
|
|
21
|
+
* Pick the best human-readable label for an entity.
|
|
22
|
+
*
|
|
23
|
+
* Walks a prioritized list of candidate fields. Falls back to a
|
|
24
|
+
* truncated id if none match. Inlined into this project by
|
|
25
|
+
* @specverse/realize (ReactAppStarter); edit freely — nothing in
|
|
26
|
+
* the factory's regeneration will clobber your edits.
|
|
27
|
+
*/
|
|
28
|
+
export function getEntityDisplayName(entity: Record<string, unknown> | null | undefined): string {
|
|
29
|
+
if (!entity) return '';
|
|
30
|
+
|
|
31
|
+
const candidates = ['name', 'title', 'displayName', 'label', 'username', 'email'];
|
|
32
|
+
for (const key of candidates) {
|
|
33
|
+
const value = entity[key];
|
|
34
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = entity.id;
|
|
40
|
+
if (typeof id === 'string') {
|
|
41
|
+
return id.length > 8 ? id.slice(0, 8) + '…' : id;
|
|
42
|
+
}
|
|
43
|
+
return id != null ? String(id) : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map an entity id to the display name of the record with that id in
|
|
48
|
+
* a provided list. Handy for resolving belongsTo FK columns in
|
|
49
|
+
* list / detail views.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveEntityDisplayName(
|
|
52
|
+
id: unknown,
|
|
53
|
+
records: ReadonlyArray<Record<string, unknown>>
|
|
54
|
+
): string {
|
|
55
|
+
if (id == null) return '';
|
|
56
|
+
const match = records.find(r => r.id === id);
|
|
57
|
+
return match ? getEntityDisplayName(match) : String(id);
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → JSX transformer for ReactAppStarter
|
|
3
|
+
*
|
|
4
|
+
* Input: HTML strings produced by the canonical Tailwind adapter
|
|
5
|
+
* (`@specverse/runtime/views/tailwind`). Output: JSX-safe source
|
|
6
|
+
* that can be dropped between JSX tags in a .tsx template.
|
|
7
|
+
*
|
|
8
|
+
* Scope is deliberately narrow:
|
|
9
|
+
* - Controlled input — only the adapter's output shape matters.
|
|
10
|
+
* - String transformer with a small tokenizer, not a full HTML parser.
|
|
11
|
+
* - No SVG / MathML / script / style tags.
|
|
12
|
+
*
|
|
13
|
+
* If the adapter ever produces markup outside this scope, extend the
|
|
14
|
+
* ATTRIBUTE_RENAMES map / VOID_ELEMENTS set + add a focused test,
|
|
15
|
+
* don't broaden the tokenizer into a general HTML parser.
|
|
16
|
+
*
|
|
17
|
+
* See: README.md ("The html-to-jsx transformer — scope").
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HTML void elements — must be self-closing in JSX.
|
|
22
|
+
*/
|
|
23
|
+
const VOID_ELEMENTS = new Set([
|
|
24
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
|
25
|
+
'input', 'link', 'meta', 'source', 'track', 'wbr',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* HTML attribute → JSX attribute renames. JSX is case-sensitive and
|
|
30
|
+
* camelCases several HTML attribute names. Only lists the ones the
|
|
31
|
+
* Tailwind adapter actually emits.
|
|
32
|
+
*/
|
|
33
|
+
const ATTRIBUTE_RENAMES: Record<string, string> = {
|
|
34
|
+
class: 'className',
|
|
35
|
+
for: 'htmlFor',
|
|
36
|
+
tabindex: 'tabIndex',
|
|
37
|
+
readonly: 'readOnly',
|
|
38
|
+
maxlength: 'maxLength',
|
|
39
|
+
minlength: 'minLength',
|
|
40
|
+
colspan: 'colSpan',
|
|
41
|
+
rowspan: 'rowSpan',
|
|
42
|
+
autofocus: 'autoFocus',
|
|
43
|
+
autocomplete: 'autoComplete',
|
|
44
|
+
cellpadding: 'cellPadding',
|
|
45
|
+
cellspacing: 'cellSpacing',
|
|
46
|
+
enctype: 'encType',
|
|
47
|
+
usemap: 'useMap',
|
|
48
|
+
accesskey: 'accessKey',
|
|
49
|
+
contenteditable: 'contentEditable',
|
|
50
|
+
spellcheck: 'spellCheck',
|
|
51
|
+
crossorigin: 'crossOrigin',
|
|
52
|
+
srcset: 'srcSet',
|
|
53
|
+
srcdoc: 'srcDoc',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert an HTML string to JSX-safe source.
|
|
58
|
+
*
|
|
59
|
+
* Idempotent on already-JSX-shaped input.
|
|
60
|
+
*/
|
|
61
|
+
export function htmlToJsx(html: string): string {
|
|
62
|
+
if (!html) return '';
|
|
63
|
+
|
|
64
|
+
const tokens = tokenize(html);
|
|
65
|
+
return tokens.map(transformToken).join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Tokenizer
|
|
70
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
type Token =
|
|
73
|
+
| { kind: 'text'; value: string }
|
|
74
|
+
| { kind: 'tag'; raw: string; tagName: string; isClosing: boolean; isSelfClosed: boolean };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Split an HTML string into text and tag tokens. Quoted attribute
|
|
78
|
+
* values never close a tag, even if they contain '>'. The adapter
|
|
79
|
+
* does not emit unbalanced quotes, so a simple state machine is
|
|
80
|
+
* sufficient.
|
|
81
|
+
*/
|
|
82
|
+
function tokenize(input: string): Token[] {
|
|
83
|
+
const tokens: Token[] = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
const n = input.length;
|
|
86
|
+
|
|
87
|
+
while (i < n) {
|
|
88
|
+
const ch = input[i];
|
|
89
|
+
if (ch === '<') {
|
|
90
|
+
// Start of tag. Consume until the matching '>', respecting quotes.
|
|
91
|
+
const tagStart = i;
|
|
92
|
+
i++; // consume '<'
|
|
93
|
+
|
|
94
|
+
let quote: '"' | "'" | null = null;
|
|
95
|
+
while (i < n) {
|
|
96
|
+
const c = input[i];
|
|
97
|
+
if (quote) {
|
|
98
|
+
if (c === quote) quote = null;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (c === '"' || c === "'") {
|
|
103
|
+
quote = c;
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === '>') {
|
|
108
|
+
i++; // include the '>'
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const raw = input.slice(tagStart, i);
|
|
115
|
+
const isClosing = raw.startsWith('</');
|
|
116
|
+
const isSelfClosed = raw.endsWith('/>');
|
|
117
|
+
const tagName = parseTagName(raw);
|
|
118
|
+
tokens.push({ kind: 'tag', raw, tagName, isClosing, isSelfClosed });
|
|
119
|
+
} else {
|
|
120
|
+
// Text until the next '<'.
|
|
121
|
+
const textStart = i;
|
|
122
|
+
while (i < n && input[i] !== '<') i++;
|
|
123
|
+
tokens.push({ kind: 'text', value: input.slice(textStart, i) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return tokens;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseTagName(tagRaw: string): string {
|
|
131
|
+
// Handles '<foo', '</foo', '<foo ', '<foo/>', '<foo>' etc.
|
|
132
|
+
const m = tagRaw.match(/^<\/?([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
133
|
+
return m ? m[1].toLowerCase() : '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
137
|
+
// Transformation
|
|
138
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function transformToken(token: Token): string {
|
|
141
|
+
if (token.kind === 'text') {
|
|
142
|
+
// Adapter output never contains raw '{' or '}' in text nodes in
|
|
143
|
+
// the current implementation. If that changes, escape them here
|
|
144
|
+
// via `{'{'}` / `{'}'}`. Flagged for future: a regression test
|
|
145
|
+
// in view-emitter.test.ts that feeds a spec containing literal
|
|
146
|
+
// braces and expects them round-tripped.
|
|
147
|
+
return token.value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return transformTag(token.raw, token.tagName, token.isClosing, token.isSelfClosed);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Transform a single tag: rename attributes, self-close if void.
|
|
155
|
+
*
|
|
156
|
+
* Works inside-out: split into `<`, open-portion, attributes, close-portion, `>`.
|
|
157
|
+
* We only rewrite attribute NAMES (identifier tokens at attribute start),
|
|
158
|
+
* never attribute values or text. The tokenizer guarantees we see the
|
|
159
|
+
* full tag as one unit.
|
|
160
|
+
*/
|
|
161
|
+
function transformTag(
|
|
162
|
+
raw: string,
|
|
163
|
+
tagName: string,
|
|
164
|
+
isClosing: boolean,
|
|
165
|
+
isSelfClosed: boolean
|
|
166
|
+
): string {
|
|
167
|
+
if (isClosing) {
|
|
168
|
+
// '</div>' — nothing to rewrite
|
|
169
|
+
return raw;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Split the tag into the tag-name section and the attributes section.
|
|
173
|
+
// Strip the leading '<' and trailing '>' or '/>'.
|
|
174
|
+
const inner = isSelfClosed
|
|
175
|
+
? raw.slice(1, -2).trimEnd()
|
|
176
|
+
: raw.slice(1, -1);
|
|
177
|
+
|
|
178
|
+
// Separate tag name from attributes. The tag name is the first word.
|
|
179
|
+
const nameMatch = inner.match(/^([a-zA-Z][a-zA-Z0-9-]*)(.*)$/s);
|
|
180
|
+
if (!nameMatch) return raw;
|
|
181
|
+
|
|
182
|
+
const elementName = nameMatch[1];
|
|
183
|
+
const attrsSection = nameMatch[2];
|
|
184
|
+
|
|
185
|
+
// Parse attributes into structured form, rename names, re-serialise.
|
|
186
|
+
// This guarantees we never rewrite anything inside an attribute value.
|
|
187
|
+
const attrs = parseAttributes(attrsSection);
|
|
188
|
+
const renamedAttrs = attrs.map(renameAttribute);
|
|
189
|
+
const rebuiltAttrs = serializeAttributes(renamedAttrs);
|
|
190
|
+
|
|
191
|
+
const shouldSelfClose = VOID_ELEMENTS.has(tagName) && !isSelfClosed;
|
|
192
|
+
|
|
193
|
+
if (shouldSelfClose || isSelfClosed) {
|
|
194
|
+
return `<${elementName}${rebuiltAttrs} />`;
|
|
195
|
+
}
|
|
196
|
+
return `<${elementName}${rebuiltAttrs}>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
200
|
+
// Attribute tokenizer
|
|
201
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
interface Attribute {
|
|
204
|
+
name: string;
|
|
205
|
+
/** undefined = boolean attribute, no value. */
|
|
206
|
+
value?: string;
|
|
207
|
+
/** How the original source quoted the value — preserve on output. */
|
|
208
|
+
quote?: '"' | "'";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse the attributes section of a tag (the substring after the tag
|
|
213
|
+
* name, before the closing `>` or `/>`) into a list of attributes.
|
|
214
|
+
* Whitespace is consumed between attributes; values inside quotes are
|
|
215
|
+
* opaque.
|
|
216
|
+
*/
|
|
217
|
+
function parseAttributes(section: string): Attribute[] {
|
|
218
|
+
const attrs: Attribute[] = [];
|
|
219
|
+
let i = 0;
|
|
220
|
+
const n = section.length;
|
|
221
|
+
|
|
222
|
+
while (i < n) {
|
|
223
|
+
// Skip whitespace
|
|
224
|
+
while (i < n && /\s/.test(section[i])) i++;
|
|
225
|
+
if (i >= n) break;
|
|
226
|
+
|
|
227
|
+
// Read name (letters, digits, hyphens, colons for namespaced attrs)
|
|
228
|
+
const nameStart = i;
|
|
229
|
+
while (i < n && /[a-zA-Z0-9:_-]/.test(section[i])) i++;
|
|
230
|
+
if (i === nameStart) {
|
|
231
|
+
// Nothing we recognise — advance to avoid infinite loop
|
|
232
|
+
i++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const name = section.slice(nameStart, i);
|
|
236
|
+
|
|
237
|
+
// Look for '=value' or end-of-attr
|
|
238
|
+
// Skip whitespace before '='
|
|
239
|
+
let j = i;
|
|
240
|
+
while (j < n && /\s/.test(section[j])) j++;
|
|
241
|
+
if (j >= n || section[j] !== '=') {
|
|
242
|
+
// Boolean attribute (e.g. `readonly`, `disabled`)
|
|
243
|
+
attrs.push({ name });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// Consume '=' and optional whitespace
|
|
247
|
+
i = j + 1;
|
|
248
|
+
while (i < n && /\s/.test(section[i])) i++;
|
|
249
|
+
if (i >= n) {
|
|
250
|
+
// Malformed: attribute with '=' and no value. Treat as boolean.
|
|
251
|
+
attrs.push({ name });
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Read value — quoted or unquoted
|
|
256
|
+
const vc = section[i];
|
|
257
|
+
if (vc === '"' || vc === "'") {
|
|
258
|
+
const quote = vc as '"' | "'";
|
|
259
|
+
i++; // skip opening quote
|
|
260
|
+
const valStart = i;
|
|
261
|
+
while (i < n && section[i] !== quote) i++;
|
|
262
|
+
const value = section.slice(valStart, i);
|
|
263
|
+
if (i < n) i++; // skip closing quote
|
|
264
|
+
attrs.push({ name, value, quote });
|
|
265
|
+
} else {
|
|
266
|
+
// Unquoted value — run until whitespace or '/' or '>'
|
|
267
|
+
const valStart = i;
|
|
268
|
+
while (i < n && !/[\s/>]/.test(section[i])) i++;
|
|
269
|
+
const value = section.slice(valStart, i);
|
|
270
|
+
attrs.push({ name, value });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return attrs;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Apply HTML→JSX name rewrite and, for `style`, value conversion. */
|
|
278
|
+
function renameAttribute(attr: Attribute): Attribute {
|
|
279
|
+
const lower = attr.name.toLowerCase();
|
|
280
|
+
const newName = ATTRIBUTE_RENAMES[lower] ?? attr.name;
|
|
281
|
+
|
|
282
|
+
if (lower === 'style' && attr.value !== undefined) {
|
|
283
|
+
return { name: newName, value: cssStringToJsxObject(attr.value), quote: undefined };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { ...attr, name: newName };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Serialise a list of attributes back into source form. Preserves the
|
|
291
|
+
* original quote style. A value of `{{ ... }}` (already a JSX object
|
|
292
|
+
* expression) is emitted unquoted (no surrounding `"`); it's already
|
|
293
|
+
* valid JSX syntax.
|
|
294
|
+
*/
|
|
295
|
+
function serializeAttributes(attrs: Attribute[]): string {
|
|
296
|
+
if (attrs.length === 0) return '';
|
|
297
|
+
const parts = attrs.map(attr => {
|
|
298
|
+
if (attr.value === undefined) return ` ${attr.name}`;
|
|
299
|
+
if (attr.value.startsWith('{{') && attr.value.endsWith('}}')) {
|
|
300
|
+
// JSX expression like {{ color: 'red' }} — no quotes.
|
|
301
|
+
return ` ${attr.name}=${attr.value}`;
|
|
302
|
+
}
|
|
303
|
+
const q = attr.quote ?? '"';
|
|
304
|
+
return ` ${attr.name}=${q}${attr.value}${q}`;
|
|
305
|
+
});
|
|
306
|
+
return parts.join('');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
310
|
+
// CSS → JSX style object
|
|
311
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
function cssStringToJsxObject(css: string): string {
|
|
314
|
+
const declarations = css
|
|
315
|
+
.split(';')
|
|
316
|
+
.map(d => d.trim())
|
|
317
|
+
.filter(d => d.length > 0);
|
|
318
|
+
|
|
319
|
+
if (declarations.length === 0) return '{{}}';
|
|
320
|
+
|
|
321
|
+
const entries = declarations
|
|
322
|
+
.map(decl => {
|
|
323
|
+
const colonIdx = decl.indexOf(':');
|
|
324
|
+
if (colonIdx === -1) return null;
|
|
325
|
+
const prop = decl.slice(0, colonIdx).trim();
|
|
326
|
+
const value = decl.slice(colonIdx + 1).trim();
|
|
327
|
+
const camelProp = prop.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
|
|
328
|
+
const escapedValue = value.replace(/'/g, "\\'");
|
|
329
|
+
return `${camelProp}: '${escapedValue}'`;
|
|
330
|
+
})
|
|
331
|
+
.filter((e): e is string => e !== null);
|
|
332
|
+
|
|
333
|
+
return `{{ ${entries.join(', ')} }}`;
|
|
334
|
+
}
|