view-contracts 0.2.0 → 0.3.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/README.md +7 -9
- package/cli-contract.yaml +1 -38
- package/dist/view-contracts.bundle.mjs +208 -254
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +1 -38
- package/package.json +7 -4
- package/renderers/compose/templates/Screen.kt.hbs +15 -0
- package/renderers/compose/templates/VcTheme.kt.hbs +19 -0
- package/renderers/react/README.md +1 -0
- package/renderers/react/defaults/theme.yaml +26 -0
- package/renderers/react/defaults/tokens.yaml +59 -0
- package/renderers/react/element-config.meta.json +2 -1
- package/renderers/react/elementMap.ts +4 -2
- package/renderers/react/elements.yaml +76 -21
- package/renderers/react/lowering/lowerToJsx.ts +183 -28
- package/renderers/react/renderComponents.ts +23 -8
- package/renderers/react/renderRuntime.ts +24 -75
- package/renderers/react/routes.yaml +1 -1
- package/renderers/react/runtime/style.ts +45 -36
- package/renderers/react/runtime/theme.css +51 -54
- package/renderers/react/runtime/types.ts +20 -43
- package/renderers/react/syncGeneratedRuntime.ts +77 -0
- package/renderers/react/templates/components-index.ts.hbs +1 -1
- package/renderers/react/templates/runtime-types.ts.hbs +5 -0
- package/renderers/react/templates/style-helpers.ts.hbs +1 -1
- package/renderers/swiftui/templates/Screen.swift.hbs +21 -0
- package/renderers/swiftui/templates/VcTheme.swift.hbs +21 -0
- package/spec/ui-dsl-extensions.meta.json +62 -0
- package/spec/ui-dsl.schema.json +24 -16
- package/src/generated/schema/allowed-components.ts +1 -1
- package/src/generated/schema/{tokens.ts → dsl-enums.ts} +1 -1
- package/src/generated/schema/dsl-properties.ts +3 -2
- package/src/generated/schema/index.ts +2 -2
- package/src/generated/schema/lowering-style-properties.ts +1 -9
- package/src/generated/schema/react-renderer-element-config.ts +3 -2
- package/src/generated/schema/schema-document.ts +2 -2
|
@@ -96,9 +96,40 @@ function extraFlexStyle(entry: ElementMapEntry, props: Record<string, IrValue>):
|
|
|
96
96
|
return `, ${parts.join(', ')}`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function scrollOverflowLiteral(mode: string): { overflowX: string; overflowY: string } {
|
|
100
|
+
if (mode === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
|
|
101
|
+
if (mode === 'both') return { overflowX: 'auto', overflowY: 'auto' };
|
|
102
|
+
if (mode === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
|
|
103
|
+
return { overflowX: 'hidden', overflowY: 'auto' };
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
function styleAttr(entry: ElementMapEntry, props: Record<string, IrValue>): string {
|
|
100
107
|
const base = collectStyleProps(props);
|
|
101
108
|
const flex = extraFlexStyle(entry, props);
|
|
109
|
+
if (entry.scrollContainer) {
|
|
110
|
+
const scrollLit = propLiteral(props, 'scroll');
|
|
111
|
+
const axisLit = propLiteral(props, 'axis');
|
|
112
|
+
const literalMode = typeof scrollLit === 'string'
|
|
113
|
+
? scrollLit
|
|
114
|
+
: typeof axisLit === 'string'
|
|
115
|
+
? (axisLit === 'horizontal' ? 'horizontal' : 'vertical')
|
|
116
|
+
: undefined;
|
|
117
|
+
if (literalMode) {
|
|
118
|
+
const { overflowX, overflowY } = scrollOverflowLiteral(literalMode);
|
|
119
|
+
return `style={{ ...commonStyle(${base})${flex}, overflowX: '${overflowX}', overflowY: '${overflowY}' }}`;
|
|
120
|
+
}
|
|
121
|
+
const scroll = propExpr(props, 'scroll');
|
|
122
|
+
const axis = propExpr(props, 'axis');
|
|
123
|
+
const modeExpr = scroll
|
|
124
|
+
?? (axis ? `(${axis} === 'horizontal' ? 'horizontal' : 'vertical')` : `'vertical'`);
|
|
125
|
+
return `style={{ ...commonStyle(${base})${flex}, ...(() => {
|
|
126
|
+
const __scroll = ${modeExpr};
|
|
127
|
+
if (__scroll === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
|
|
128
|
+
if (__scroll === 'both') return { overflowX: 'auto', overflowY: 'auto' };
|
|
129
|
+
if (__scroll === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
|
|
130
|
+
return { overflowX: 'hidden', overflowY: 'auto' };
|
|
131
|
+
})() }}`;
|
|
132
|
+
}
|
|
102
133
|
if (entry.flexGrowProp && props[entry.flexGrowProp]) {
|
|
103
134
|
const grow = propExpr(props, entry.flexGrowProp);
|
|
104
135
|
return `style={{ ...commonStyle(${base})${flex}, flexGrow: ${grow} ?? 1 }}`;
|
|
@@ -119,22 +150,47 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
|
|
|
119
150
|
return `onClick={() => { void dispatchAction(${expr}); }}`;
|
|
120
151
|
}
|
|
121
152
|
|
|
122
|
-
function
|
|
153
|
+
function componentToKebab(name: string): string {
|
|
154
|
+
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function variantClassSuffix(elementName: string, props: Record<string, IrValue>): string | undefined {
|
|
158
|
+
const variantLit = propLiteral(props, 'variant');
|
|
159
|
+
if (typeof variantLit === 'string') {
|
|
160
|
+
return ` vc-${componentToKebab(elementName)}--${variantLit}`;
|
|
161
|
+
}
|
|
162
|
+
const variant = propExpr(props, 'variant');
|
|
163
|
+
if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function classNameAttr(
|
|
168
|
+
elementName: string,
|
|
169
|
+
entry: ElementMapEntry,
|
|
170
|
+
props: Record<string, IrValue>,
|
|
171
|
+
headingLevel?: number,
|
|
172
|
+
): string {
|
|
123
173
|
const base = entry.className ?? '';
|
|
124
174
|
if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
|
|
125
|
-
|
|
175
|
+
const levelClass = `${base} vc-heading-${headingLevel}`;
|
|
176
|
+
const variantSuffix = variantClassSuffix(elementName, props);
|
|
177
|
+
if (!variantSuffix) return `className="${levelClass}"`;
|
|
178
|
+
const variantLit = propLiteral(props, 'variant');
|
|
179
|
+
if (typeof variantLit === 'string') {
|
|
180
|
+
return `className="${levelClass}${variantSuffix}"`;
|
|
181
|
+
}
|
|
182
|
+
return `className={\`${levelClass}${variantSuffix}\`}`;
|
|
126
183
|
}
|
|
127
|
-
|
|
184
|
+
const variantSuffix = variantClassSuffix(elementName, props);
|
|
185
|
+
if (variantSuffix) {
|
|
128
186
|
const variantLit = propLiteral(props, 'variant');
|
|
129
187
|
if (typeof variantLit === 'string') {
|
|
130
|
-
return `className="${base}
|
|
188
|
+
return `className="${base}${variantSuffix}"`;
|
|
131
189
|
}
|
|
132
|
-
|
|
133
|
-
if (variant) return `className={\`${base} ui-button-\${${variant} ?? 'secondary'}\`}`;
|
|
134
|
-
return `className="${base} ui-button-secondary"`;
|
|
190
|
+
return `className={\`${base}${variantSuffix}\`}`;
|
|
135
191
|
}
|
|
136
192
|
if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
|
|
137
|
-
return `className="${base}
|
|
193
|
+
return `className="${base} vc-wrap"`;
|
|
138
194
|
}
|
|
139
195
|
if (entry.sizeClass && props.size) {
|
|
140
196
|
const size = propExpr(props, 'size');
|
|
@@ -150,22 +206,22 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
|
|
|
150
206
|
const label = propExpr(props, 'label');
|
|
151
207
|
const help = propExpr(props, 'help');
|
|
152
208
|
const error = propExpr(props, 'error');
|
|
153
|
-
const lines = [`${indent(level)}<label className="
|
|
209
|
+
const lines = [`${indent(level)}<label className="vc-field">`];
|
|
154
210
|
if (typeof labelLit === 'string') {
|
|
155
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
211
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
|
|
156
212
|
} else if (label) {
|
|
157
|
-
lines.push(`${indent(level + 1)}{${label} ? <span className="
|
|
213
|
+
lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
|
|
158
214
|
}
|
|
159
215
|
lines.push(`${indent(level + 1)}${inner}`);
|
|
160
216
|
if (typeof helpLit === 'string') {
|
|
161
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
217
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
|
|
162
218
|
} else if (help) {
|
|
163
|
-
lines.push(`${indent(level + 1)}{${help} ? <span className="
|
|
219
|
+
lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
|
|
164
220
|
}
|
|
165
221
|
if (typeof errorLit === 'string') {
|
|
166
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
222
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
|
|
167
223
|
} else if (error) {
|
|
168
|
-
lines.push(`${indent(level + 1)}{${error} ? <span className="
|
|
224
|
+
lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
|
|
169
225
|
}
|
|
170
226
|
lines.push(`${indent(level)}</label>`);
|
|
171
227
|
return lines.join('\n');
|
|
@@ -176,12 +232,59 @@ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
|
|
|
176
232
|
const label = propExpr(props, 'label') ?? '""';
|
|
177
233
|
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
178
234
|
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
179
|
-
return `${indent(level)}<label className="
|
|
235
|
+
return `${indent(level)}<label className="vc-checkbox">
|
|
180
236
|
${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
|
|
181
237
|
${indent(level + 1)}<span>{${label}}</span>
|
|
182
238
|
${indent(level)}</label>`;
|
|
183
239
|
}
|
|
184
240
|
|
|
241
|
+
function emitRadio(props: Record<string, IrValue>, level: number): string {
|
|
242
|
+
const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
|
|
243
|
+
const value = propExpr(props, 'value') ?? '""';
|
|
244
|
+
const label = propExpr(props, 'label') ?? '""';
|
|
245
|
+
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
246
|
+
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
247
|
+
return `${indent(level)}<label className="vc-radio">
|
|
248
|
+
${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checked} ${disabled} />
|
|
249
|
+
${indent(level + 1)}<span>{${label}}</span>
|
|
250
|
+
${indent(level)}</label>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function emitSwitch(props: Record<string, IrValue>, level: number): string {
|
|
254
|
+
const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
|
|
255
|
+
const label = propExpr(props, 'label') ?? '""';
|
|
256
|
+
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
257
|
+
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
258
|
+
return `${indent(level)}<label className="vc-switch">
|
|
259
|
+
${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checked} ${disabled} />
|
|
260
|
+
${indent(level + 1)}<span>{${label}}</span>
|
|
261
|
+
${indent(level)}</label>`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emitIcon(node: IrComponentNode, entry: ElementMapEntry, level: number): string {
|
|
265
|
+
const props = node.props;
|
|
266
|
+
const hasAlt = props.alt !== undefined;
|
|
267
|
+
if (hasAlt) {
|
|
268
|
+
const attrs: string[] = [
|
|
269
|
+
classNameAttr(node.name, entry, props),
|
|
270
|
+
styleAttr(entry, props),
|
|
271
|
+
`src={${propExpr(props, 'name') ?? '""'}}`,
|
|
272
|
+
`alt={${propExpr(props, 'alt') ?? '""'}}`,
|
|
273
|
+
];
|
|
274
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
275
|
+
return `${indent(level)}<img ${attrs.join(' ')} />`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const attrs: string[] = [
|
|
279
|
+
classNameAttr(node.name, entry, props),
|
|
280
|
+
styleAttr(entry, props),
|
|
281
|
+
`aria-hidden`,
|
|
282
|
+
];
|
|
283
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
284
|
+
if (props.name) attrs.push(`data-icon={${propExpr(props, 'name')}}`);
|
|
285
|
+
return `${indent(level)}<span ${attrs.join(' ')} />`;
|
|
286
|
+
}
|
|
287
|
+
|
|
185
288
|
function emitSelectOptions(props: Record<string, IrValue>, level: number): string {
|
|
186
289
|
const options = props.options;
|
|
187
290
|
if (!options || typeof options !== 'object' || !('kind' in options)) return '';
|
|
@@ -193,19 +296,42 @@ function emitSelectOptions(props: Record<string, IrValue>, level: number): strin
|
|
|
193
296
|
return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
|
|
194
297
|
}
|
|
195
298
|
|
|
196
|
-
function emitVoidInput(
|
|
197
|
-
|
|
299
|
+
function emitVoidInput(
|
|
300
|
+
elementName: string,
|
|
301
|
+
entry: ElementMapEntry,
|
|
302
|
+
props: Record<string, IrValue>,
|
|
303
|
+
level: number,
|
|
304
|
+
): string {
|
|
305
|
+
const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
|
|
306
|
+
if (entry.ariaHidden) attrs.push('aria-hidden');
|
|
307
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
308
|
+
if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
|
|
309
|
+
else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
|
|
310
|
+
if (entry.ariaLabel) {
|
|
311
|
+
if (props.ariaLabel) attrs.push(`aria-label={${propExpr(props, 'ariaLabel')}}`);
|
|
312
|
+
else if (props.label) attrs.push(`aria-label={${propExpr(props, 'label')}}`);
|
|
313
|
+
else if (props.title) attrs.push(`aria-label={${propExpr(props, 'title')}}`);
|
|
314
|
+
}
|
|
198
315
|
if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
|
|
199
316
|
if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
|
|
200
317
|
if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
|
|
201
318
|
if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
|
|
202
319
|
if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
|
|
203
320
|
if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
|
|
204
|
-
if (props.
|
|
321
|
+
if (entry.tag === 'img' && props.src) attrs.push(`src={${propExpr(props, 'src')}}`);
|
|
322
|
+
if (entry.tag === 'img' && props.alt) attrs.push(`alt={${propExpr(props, 'alt')}}`);
|
|
323
|
+
if (entry.tag === 'progress' && props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
|
|
324
|
+
if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}}`);
|
|
205
325
|
return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
|
|
206
326
|
}
|
|
207
327
|
|
|
208
|
-
function emitOpenTag(
|
|
328
|
+
function emitOpenTag(
|
|
329
|
+
elementName: string,
|
|
330
|
+
entry: ElementMapEntry,
|
|
331
|
+
props: Record<string, IrValue>,
|
|
332
|
+
tag: string,
|
|
333
|
+
headingLevel?: number,
|
|
334
|
+
): string {
|
|
209
335
|
const attrs: string[] = [];
|
|
210
336
|
if (entry.ariaHidden) attrs.push('aria-hidden');
|
|
211
337
|
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
@@ -223,7 +349,7 @@ function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag
|
|
|
223
349
|
if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
|
|
224
350
|
if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
|
|
225
351
|
if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
|
|
226
|
-
attrs.push(classNameAttr(entry, props, headingLevel));
|
|
352
|
+
attrs.push(classNameAttr(elementName, entry, props, headingLevel));
|
|
227
353
|
attrs.push(styleAttr(entry, props));
|
|
228
354
|
return `<${tag} ${attrs.join(' ')}`;
|
|
229
355
|
}
|
|
@@ -286,14 +412,43 @@ function lowerVocabularyElement(
|
|
|
286
412
|
return visibilityGuard(emitCheckbox(props, level), props, level);
|
|
287
413
|
}
|
|
288
414
|
|
|
415
|
+
if (node.name === 'Radio') {
|
|
416
|
+
return visibilityGuard(emitRadio(props, level), props, level);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (node.name === 'Switch') {
|
|
420
|
+
return visibilityGuard(emitSwitch(props, level), props, level);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (node.name === 'Icon') {
|
|
424
|
+
return visibilityGuard(emitIcon(node, entry, level), props, level);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (node.name === 'Spinner') {
|
|
428
|
+
const attrs = [
|
|
429
|
+
classNameAttr(node.name, entry, props),
|
|
430
|
+
styleAttr(entry, props),
|
|
431
|
+
'aria-busy="true"',
|
|
432
|
+
];
|
|
433
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
434
|
+
if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
|
|
435
|
+
else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
|
|
436
|
+
return visibilityGuard(`${indent(level)}<div ${attrs.join(' ')} />`, props, level);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (entry.fieldContainer) {
|
|
440
|
+
const inner = childContent || `${indent(level + 1)}<div />`;
|
|
441
|
+
return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
|
|
442
|
+
}
|
|
443
|
+
|
|
289
444
|
if (entry.fieldWrap) {
|
|
290
445
|
const tag = entry.tag;
|
|
291
446
|
let inner: string;
|
|
292
447
|
if (entry.optionsProp && props[entry.optionsProp]) {
|
|
293
|
-
const open = emitOpenTag(entry, props, tag);
|
|
448
|
+
const open = emitOpenTag(node.name, entry, props, tag);
|
|
294
449
|
inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
|
|
295
450
|
} else {
|
|
296
|
-
inner = emitVoidInput(entry, props, level + 1);
|
|
451
|
+
inner = emitVoidInput(node.name, entry, props, level + 1);
|
|
297
452
|
}
|
|
298
453
|
return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
|
|
299
454
|
}
|
|
@@ -303,7 +458,7 @@ function lowerVocabularyElement(
|
|
|
303
458
|
if (typeof levelLit === 'number') {
|
|
304
459
|
const clamped = Math.min(6, Math.max(1, levelLit));
|
|
305
460
|
const tag = `h${clamped}`;
|
|
306
|
-
const open = emitOpenTag(entry, props, tag, clamped);
|
|
461
|
+
const open = emitOpenTag(node.name, entry, props, tag, clamped);
|
|
307
462
|
const body = childContent
|
|
308
463
|
? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
|
|
309
464
|
: `${indent(level)}${open} />`;
|
|
@@ -311,17 +466,17 @@ function lowerVocabularyElement(
|
|
|
311
466
|
}
|
|
312
467
|
const levelExpr = propExpr(props, 'level') ?? '2';
|
|
313
468
|
const inner = childContent
|
|
314
|
-
? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`
|
|
469
|
+
? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`vc-heading vc-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
|
|
315
470
|
: '';
|
|
316
|
-
return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
|
|
471
|
+
return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
|
|
317
472
|
}
|
|
318
473
|
|
|
319
474
|
const tag = entry.tag;
|
|
320
475
|
if (entry.void) {
|
|
321
|
-
return visibilityGuard(
|
|
476
|
+
return visibilityGuard(emitVoidInput(node.name, entry, props, level), props, level);
|
|
322
477
|
}
|
|
323
478
|
|
|
324
|
-
const open = emitOpenTag(entry, props, tag);
|
|
479
|
+
const open = emitOpenTag(node.name, entry, props, tag);
|
|
325
480
|
let body: string;
|
|
326
481
|
if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
|
|
327
482
|
body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
|
|
@@ -2,10 +2,18 @@ import type { ViewIrDocument } from '../../src/ir/types.js';
|
|
|
2
2
|
import { loadReactElementMap } from './elementMap.js';
|
|
3
3
|
import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
|
|
4
4
|
|
|
5
|
+
export interface PreviewImportSpecifiers {
|
|
6
|
+
dispatch: string;
|
|
7
|
+
styleHelpers: string;
|
|
8
|
+
contracts: string;
|
|
9
|
+
extensions: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export interface RenderReactOptions {
|
|
6
13
|
componentsImportFrom: string;
|
|
7
14
|
allowlistExtra?: string[];
|
|
8
15
|
elementMapPath?: string;
|
|
16
|
+
previewImports?: PreviewImportSpecifiers;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
export async function renderReactComponent(
|
|
@@ -28,20 +36,27 @@ export async function renderReactComponent(
|
|
|
28
36
|
collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
|
|
29
37
|
const extensionImports = [...extensions].sort();
|
|
30
38
|
|
|
39
|
+
const preview = options.previewImports;
|
|
40
|
+
const dispatchModule = preview?.dispatch ?? '../runtime/dispatch.js';
|
|
41
|
+
const styleModule = preview?.styleHelpers ?? '../components/style-helpers.js';
|
|
42
|
+
const contractsModule = preview?.contracts ?? '../contracts';
|
|
43
|
+
const componentsModule = preview?.extensions ?? options.componentsImportFrom;
|
|
44
|
+
|
|
31
45
|
const imports: string[] = [
|
|
32
46
|
`import type { ReactElement } from 'react';`,
|
|
33
|
-
`import { dispatchAction } from '
|
|
34
|
-
`import { commonStyle, mapAlign, mapJustify } from '
|
|
35
|
-
`import type { ${doc.viewModel}, ActionDescriptor } from '
|
|
47
|
+
`import { dispatchAction } from '${dispatchModule}';`,
|
|
48
|
+
`import { commonStyle, mapAlign, mapJustify } from '${styleModule}';`,
|
|
49
|
+
`import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`,
|
|
36
50
|
];
|
|
37
51
|
if (extensionImports.length > 0) {
|
|
38
|
-
imports.push(`import { ${extensionImports.join(', ')} } from '${
|
|
52
|
+
imports.push(`import { ${extensionImports.join(', ')} } from '${componentsModule}';`);
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
* DO NOT EDIT — regenerate with: view-contracts render react
|
|
44
|
-
|
|
55
|
+
const headerComment = preview
|
|
56
|
+
? `/**\n * Preview module — compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
|
|
57
|
+
: `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
|
|
58
|
+
|
|
59
|
+
return `${headerComment}
|
|
45
60
|
${imports.join('\n')}
|
|
46
61
|
|
|
47
62
|
export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
|
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, stat,
|
|
1
|
+
import { readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import type { ViewContractsConfig } from '../../src/config.js';
|
|
4
|
-
import { projectRoot } from '../../src/config.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { projectRoot, requireDesignPaths } from '../../src/config.js';
|
|
5
|
+
import { buildThemeCss } from '../../src/design/buildThemeCss.js';
|
|
6
|
+
import { loadValidateDesign } from '../../src/design/index.js';
|
|
7
7
|
import { renderTemplate } from '../../src/renderer/template.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const toolchainRoot = packageRoot();
|
|
11
|
-
|
|
12
|
-
function toolchainSchemaDir(): string {
|
|
13
|
-
return resolve(toolchainRoot, 'src/generated/schema');
|
|
14
|
-
}
|
|
8
|
+
import { reactTemplatesDir } from './paths.js';
|
|
9
|
+
import { rewriteExtensionImportsForGenerated, syncGeneratedRuntime } from './syncGeneratedRuntime.js';
|
|
15
10
|
|
|
16
11
|
async function pathExists(target: string): Promise<boolean> {
|
|
17
12
|
try {
|
|
@@ -22,42 +17,22 @@ async function pathExists(target: string): Promise<boolean> {
|
|
|
22
17
|
}
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const schemaDest = resolve(componentsDir, 'schema');
|
|
34
|
-
await cp(schemaSrc, schemaDest, { recursive: true });
|
|
35
|
-
for (const name of ['dsl-properties.ts', 'tokens.ts', 'schema-document.ts', 'index.ts'] as const) {
|
|
36
|
-
const file = resolve(schemaDest, name);
|
|
37
|
-
if (await pathExists(file)) {
|
|
38
|
-
await writeFile(file, rewriteSchemaImports(await readFile(file, 'utf-8')), 'utf-8');
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function copyStyleBundle(componentsDir: string): Promise<void> {
|
|
44
|
-
const runtimeSrc = reactRuntimeDir();
|
|
45
|
-
const style = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'style.ts'), 'utf-8'));
|
|
46
|
-
await writeFile(resolve(componentsDir, 'style.ts'), style, 'utf-8');
|
|
47
|
-
|
|
48
|
-
const types = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'types.ts'), 'utf-8'));
|
|
49
|
-
await writeFile(resolve(componentsDir, 'types.ts'), types, 'utf-8');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function writeThemeCss(root: string, themeOut: string, templatesDir?: string): Promise<void> {
|
|
53
|
-
const runtimeTheme = resolve(reactRuntimeDir(), 'theme.css');
|
|
54
|
-
let css = await loadThemeCss(runtimeTheme);
|
|
55
|
-
const extensionsCss = resolve(root, 'src/theme.extensions.css');
|
|
56
|
-
if (await pathExists(extensionsCss)) {
|
|
57
|
-
css += `\n\n${await readFile(extensionsCss, 'utf-8')}`;
|
|
58
|
-
}
|
|
20
|
+
async function writeThemeCss(
|
|
21
|
+
configPath: string,
|
|
22
|
+
themeOut: string,
|
|
23
|
+
config: ViewContractsConfig,
|
|
24
|
+
templatesDir?: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const css = await buildThemeCss(config, configPath);
|
|
59
27
|
const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
|
|
60
28
|
await writeFile(themeOut, content, 'utf-8');
|
|
29
|
+
|
|
30
|
+
const { tokensPath, themePath } = requireDesignPaths(config);
|
|
31
|
+
const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
|
|
32
|
+
const themeDir = resolve(config.generated.reactDir, '../theme');
|
|
33
|
+
await mkdir(themeDir, { recursive: true });
|
|
34
|
+
await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
|
|
35
|
+
await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
|
|
61
36
|
}
|
|
62
37
|
|
|
63
38
|
async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
|
|
@@ -76,32 +51,16 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
|
|
|
76
51
|
? resolve(root, config.rendererTemplatesDir)
|
|
77
52
|
: undefined;
|
|
78
53
|
const templates = reactTemplatesDir(templatesDir);
|
|
79
|
-
const componentsDir = resolve(config.generated.reactDir, '../components');
|
|
80
|
-
const runtimeDir = resolve(config.generated.reactDir, '../runtime');
|
|
81
54
|
const srcComponentsDir = resolve(root, 'src/components');
|
|
82
55
|
|
|
83
|
-
|
|
84
|
-
await mkdir(runtimeDir, { recursive: true });
|
|
85
|
-
|
|
56
|
+
const { componentsDir, runtimeDir } = await syncGeneratedRuntime(configPath, config, templatesDir);
|
|
86
57
|
await removeStaleGeneratedFiles(componentsDir);
|
|
87
|
-
await copySchemaBundle(componentsDir);
|
|
88
|
-
await copyStyleBundle(componentsDir);
|
|
89
|
-
await writeFile(
|
|
90
|
-
resolve(componentsDir, 'style-helpers.ts'),
|
|
91
|
-
await renderTemplate(templates, 'style-helpers.ts.hbs', {}),
|
|
92
|
-
'utf-8',
|
|
93
|
-
);
|
|
94
|
-
await writeFile(
|
|
95
|
-
resolve(componentsDir, 'index.ts'),
|
|
96
|
-
await renderTemplate(templates, 'components-index.ts.hbs', {}),
|
|
97
|
-
'utf-8',
|
|
98
|
-
);
|
|
99
58
|
|
|
100
59
|
for (const name of ['extensions.tsx', 'sample-types.ts'] as const) {
|
|
101
60
|
const src = resolve(srcComponentsDir, name);
|
|
102
61
|
let body = await readFile(src, 'utf-8');
|
|
103
|
-
if (name === '
|
|
104
|
-
body = body
|
|
62
|
+
if (name === 'extensions.tsx') {
|
|
63
|
+
body = rewriteExtensionImportsForGenerated(body);
|
|
105
64
|
}
|
|
106
65
|
await writeFile(resolve(componentsDir, name), body, 'utf-8');
|
|
107
66
|
}
|
|
@@ -117,17 +76,7 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
|
|
|
117
76
|
'utf-8',
|
|
118
77
|
);
|
|
119
78
|
|
|
120
|
-
await writeThemeCss(
|
|
121
|
-
await writeFile(
|
|
122
|
-
resolve(runtimeDir, 'types.ts'),
|
|
123
|
-
await renderTemplate(templates, 'runtime-types.ts.hbs', {}),
|
|
124
|
-
'utf-8',
|
|
125
|
-
);
|
|
126
|
-
await writeFile(
|
|
127
|
-
resolve(runtimeDir, 'dispatch.ts'),
|
|
128
|
-
await renderTemplate(templates, 'dispatch.ts.hbs', {}),
|
|
129
|
-
'utf-8',
|
|
130
|
-
);
|
|
79
|
+
await writeThemeCss(configPath, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
|
|
131
80
|
|
|
132
81
|
console.log(` components → ${componentsDir}`);
|
|
133
82
|
console.log(` theme → ${resolve(config.generated.reactDir, '../theme.css')}`);
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { CSSProperties } from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
import type { CommonProps } from './types.js';
|
|
2
|
+
import type { LoweredStyleInput } from './types.js';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
/** Map flex alignment shorthand (from lowering) to CSS align-items / align-self values. */
|
|
5
|
+
export function mapAlign(value?: string): string | undefined {
|
|
6
6
|
if (!value) return undefined;
|
|
7
7
|
if (value === 'start') return 'flex-start';
|
|
8
8
|
if (value === 'end') return 'flex-end';
|
|
9
9
|
return value;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Map flex justification shorthand (from lowering) to CSS justify-content values. */
|
|
13
|
+
export function mapJustify(value?: string): string | undefined {
|
|
13
14
|
if (!value) return undefined;
|
|
14
15
|
if (value === 'start') return 'flex-start';
|
|
15
16
|
if (value === 'end') return 'flex-end';
|
|
@@ -23,13 +24,7 @@ function len(value: number): string {
|
|
|
23
24
|
return `${value}px`;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const
|
|
27
|
-
sm: '0 1px 3px rgba(15,23,42,0.08)',
|
|
28
|
-
md: '0 10px 30px rgba(15, 23, 42, 0.08)',
|
|
29
|
-
lg: '0 20px 40px rgba(15, 23, 42, 0.12)',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const CONTENT_ALIGN: Record<ContentAlign, string> = {
|
|
27
|
+
const PLACE_ITEMS: Record<string, string> = {
|
|
33
28
|
topStart: 'start start',
|
|
34
29
|
top: 'center start',
|
|
35
30
|
topEnd: 'end start',
|
|
@@ -41,18 +36,12 @@ const CONTENT_ALIGN: Record<ContentAlign, string> = {
|
|
|
41
36
|
bottomEnd: 'end end',
|
|
42
37
|
};
|
|
43
38
|
|
|
44
|
-
function shadowStyle(shadow: Shadow | boolean | undefined): string | undefined {
|
|
45
|
-
if (shadow === true) return SHADOW.md;
|
|
46
|
-
if (!shadow || shadow === 'none') return undefined;
|
|
47
|
-
return SHADOW[shadow];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
39
|
function fontSizeStyle(size: number | string | undefined): string | undefined {
|
|
51
40
|
if (size === undefined) return undefined;
|
|
52
41
|
return typeof size === 'number' ? len(size) : size;
|
|
53
42
|
}
|
|
54
43
|
|
|
55
|
-
export function commonStyle(props:
|
|
44
|
+
export function commonStyle(props: LoweredStyleInput): CSSProperties {
|
|
56
45
|
const style: CSSProperties = {};
|
|
57
46
|
if (typeof props.width === 'number') style.width = len(props.width);
|
|
58
47
|
if (typeof props.minWidth === 'number') style.minWidth = len(props.minWidth);
|
|
@@ -63,34 +52,54 @@ export function commonStyle(props: CommonProps): CSSProperties {
|
|
|
63
52
|
if (props.aspectRatio !== undefined) style.aspectRatio = String(props.aspectRatio);
|
|
64
53
|
if (typeof props.weight === 'number') style.flexGrow = props.weight;
|
|
65
54
|
if (typeof props.padding === 'number') style.padding = len(props.padding);
|
|
66
|
-
if (typeof
|
|
55
|
+
if (typeof props.margin === 'number') style.margin = len(props.margin);
|
|
67
56
|
if (typeof props.gap === 'number') style.gap = len(props.gap);
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
if (props.
|
|
73
|
-
if (props.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (props.textAlign) style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : 'center';
|
|
77
|
-
if (props.contentAlign) style.placeItems = CONTENT_ALIGN[props.contentAlign];
|
|
78
|
-
if ((props as { overflow?: string }).overflow) style.overflow = (props as { overflow: string }).overflow;
|
|
79
|
-
if (props.selfAlign) style.alignSelf = props.selfAlign === 'start' ? 'flex-start' : props.selfAlign === 'end' ? 'flex-end' : props.selfAlign;
|
|
57
|
+
if (props.textAlign) {
|
|
58
|
+
style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : props.textAlign as CSSProperties['textAlign'];
|
|
59
|
+
}
|
|
60
|
+
if (props.contentAlign) style.placeItems = PLACE_ITEMS[props.contentAlign];
|
|
61
|
+
if (props.overflow) style.overflow = props.overflow as CSSProperties['overflow'];
|
|
62
|
+
if (props.selfAlign) {
|
|
63
|
+
style.alignSelf = props.selfAlign === 'start' ? 'flex-start' : props.selfAlign === 'end' ? 'flex-end' : props.selfAlign;
|
|
64
|
+
}
|
|
80
65
|
const fontSize = fontSizeStyle(props.size);
|
|
81
66
|
if (fontSize) style.fontSize = fontSize;
|
|
82
67
|
const fontWeight = typeof props.weight === 'string' ? props.weight : props.fontWeight;
|
|
83
|
-
if (fontWeight) style.fontWeight = fontWeight
|
|
68
|
+
if (fontWeight) style.fontWeight = fontWeight;
|
|
84
69
|
return style;
|
|
85
70
|
}
|
|
86
71
|
|
|
87
|
-
|
|
88
|
-
|
|
72
|
+
/** Preview vocabulary helper — lowered production JSX does not use visible. */
|
|
73
|
+
export function isHidden(props: { visible?: boolean; hidden?: boolean }): boolean {
|
|
74
|
+
return props.hidden === true || props.visible === false;
|
|
89
75
|
}
|
|
90
76
|
|
|
91
|
-
/** Sample/extension helper
|
|
77
|
+
/** Sample/extension helper for preview tone class names. */
|
|
92
78
|
export function toneClass(tone: string | undefined): string {
|
|
93
79
|
return tone ? `ui-tone-${tone}` : 'ui-tone-default';
|
|
94
80
|
}
|
|
95
81
|
|
|
96
|
-
|
|
82
|
+
function componentToKebab(name: string): string {
|
|
83
|
+
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_VARIANT_ALIASES = new Set(['default', 'muted']);
|
|
87
|
+
|
|
88
|
+
/** Resolve theme variant name from variant or legacy tone prop. */
|
|
89
|
+
export function resolveExtensionVariant(variant?: string, tone?: string): string {
|
|
90
|
+
const raw = variant ?? tone;
|
|
91
|
+
if (!raw || DEFAULT_VARIANT_ALIASES.has(raw)) return 'default';
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Build vc-* base + --variant modifier for themed extension components. */
|
|
96
|
+
export function extensionClassName(
|
|
97
|
+
component: string,
|
|
98
|
+
options?: { variant?: string; tone?: string; extra?: string | false },
|
|
99
|
+
): string {
|
|
100
|
+
const base = `vc-${componentToKebab(component)}`;
|
|
101
|
+
const variant = resolveExtensionVariant(options?.variant, options?.tone);
|
|
102
|
+
const parts = [base, `${base}--${variant}`];
|
|
103
|
+
if (options?.extra) parts.push(options.extra);
|
|
104
|
+
return parts.filter(Boolean).join(' ');
|
|
105
|
+
}
|