view-contracts 0.3.3 → 0.3.5

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.
Files changed (37) hide show
  1. package/README.md +6 -6
  2. package/cli-contract.yaml +1 -1
  3. package/dist/view-contracts.bundle.mjs +169 -153
  4. package/dist/view-contracts.bundle.mjs.map +4 -4
  5. package/docs/cli-reference.md +1 -1
  6. package/package.json +1 -1
  7. package/renderers/NATIVE_THEME_EMIT.md +73 -0
  8. package/renderers/compose/README.md +7 -0
  9. package/renderers/compose/templates/Screen.kt.hbs +3 -5
  10. package/renderers/compose/templates/VcTheme.kt.hbs +7 -13
  11. package/renderers/react/defaults/theme.yaml +7 -0
  12. package/renderers/react/defaults/tokens.yaml +11 -0
  13. package/renderers/react/lowering/foldStyle.ts +87 -0
  14. package/renderers/react/lowering/lowerToJsx.ts +58 -69
  15. package/renderers/react/renderComponents.ts +180 -24
  16. package/renderers/react/renderRuntime.ts +36 -43
  17. package/renderers/react/routes.yaml +2 -2
  18. package/renderers/react/runtime/index.ts +1 -1
  19. package/renderers/react/runtime/style.ts +3 -55
  20. package/renderers/react/runtime/theme.css +1 -1
  21. package/renderers/react/runtime/types.ts +0 -24
  22. package/renderers/react/style/foldLiterals.ts +129 -0
  23. package/renderers/react/syncGeneratedRuntime.ts +35 -47
  24. package/renderers/react/templates/components-index.ts.hbs +2 -2
  25. package/renderers/react/templates/runtime.ts.hbs +10 -0
  26. package/renderers/react/templates/style-helpers.ts.hbs +2 -2
  27. package/renderers/swiftui/README.md +7 -0
  28. package/renderers/swiftui/templates/Screen.swift.hbs +3 -5
  29. package/renderers/swiftui/templates/VcTheme.swift.hbs +8 -16
  30. package/spec/ui-dsl.schema.json +1331 -345
  31. package/src/generated/schema/allowed-components.ts +1 -1
  32. package/src/generated/schema/dsl-enums.ts +1 -1
  33. package/src/generated/schema/dsl-properties.ts +1 -1
  34. package/src/generated/schema/index.ts +1 -1
  35. package/src/generated/schema/lowering-style-properties.ts +9 -2
  36. package/src/generated/schema/react-renderer-element-config.ts +1 -1
  37. package/src/generated/schema/schema-document.ts +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  Contract-first UI design toolchain. Compiles restricted view-contract TSX to language-neutral View IR and renders platform targets (React, SwiftUI, Compose).
4
4
 
5
- **Version:** 0.3.3
5
+ **Version:** 0.3.5
6
6
 
7
7
  ## Table of Contents
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,73 @@
1
+ # Native theme codegen (SwiftUI / Compose)
2
+
3
+ Web と同様、**トークンとテーマはビルド時に解決し、プラットフォーム専用の生成コードとして出力する**。アプリが JSON を読み込む設計は採用しない。
4
+
5
+ ## Pipeline(React と同型)
6
+
7
+ ```txt
8
+ views/design/tokens.yaml + theme.yaml
9
+ ↓ loadValidateDesign() → Token IR + Theme IR(メモリ上のみ)
10
+
11
+ ├ react → emitTokenCss + emitDefaultCss + emitVariantCss → generated/react/theme.css
12
+ ├ swiftui → emitSwiftUITheme(tokenIR, themeIR) → generated/swiftui/Theme/*.swift
13
+ └ compose → emitComposeTheme(tokenIR, themeIR) → generated/compose/theme/*.kt
14
+ ```
15
+
16
+ IR は renderer の **入力**であり、成果物ではない。React が `theme.css` を出すのと同じ関係で、ネイティブは Swift / Kotlin を出す。
17
+
18
+ ## 実装方針(変換テンプレートで完結)
19
+
20
+ `src/design/emitDefaultCss.ts` / `emitVariantCss.ts` と同じ Token IR + Theme IR を、Handlebars テンプレートへ渡してコード生成する。
21
+
22
+ | モジュール | テンプレート | 出力例 |
23
+ | --- | --- | --- |
24
+ | `src/design/emit/swiftui.ts` | `renderers/swiftui/templates/theme/*.hbs` | `VcColors.swift`, `VcButtonStyles.swift` |
25
+ | `src/design/emit/compose.ts` | `renderers/compose/templates/theme/*.hbs` | `VcColors.kt`, `VcButtonModifiers.kt` |
26
+
27
+ ### Token IR → 定数
28
+
29
+ `TokenEntry.value` はビルド時に解決済み。テンプレートでは型ごとに写像する。
30
+
31
+ | `$type` | SwiftUI | Compose |
32
+ | --- | --- | --- |
33
+ | `color` | `Color(red:…)` または `Color("path")` + Asset catalog | `Color(0xFF2563EB)` |
34
+ | `dimension` | `CGFloat` 定数 | `Dp` 定数 |
35
+ | `shadow` | `Shadow` 構造体 | `Shadow` data class |
36
+ | typography | `Font` ヘルパ | `TextStyle` |
37
+
38
+ ### Theme IR → variant スタイル
39
+
40
+ `theme.defaults` / `theme.variants` の token ref を、上記定数へ解決してスタイル定義を生成する。
41
+
42
+ | Theme | Web(現行) | SwiftUI | Compose |
43
+ | --- | --- | --- | --- |
44
+ | `Button.primary` | `.vc-button--primary` | `PrimaryButtonStyle` | `Modifier.primaryButton()` |
45
+ | `Text.muted` | `.vc-text--muted` | `VcTextMutedModifier` | `Modifier.vcTextMuted()` |
46
+
47
+ 命名規則: `{Component}{VariantPascal}Style` / `Modifier.{variant}{Component}()` — Theme IR から決定論的に導出。
48
+
49
+ ### View lowering との接続
50
+
51
+ `lowerToSwiftUI.ts` / `lowerToCompose.ts` は View IR の `variant` prop を **生成済みスタイル名**へ写像するだけ。runtime で theme を解決しない。
52
+
53
+ ```swift
54
+ // generated — variant は compile-time class/style 参照
55
+ Button("Save") { }
56
+ .buttonStyle(PrimaryButtonStyle())
57
+ ```
58
+
59
+ ## スキャフォールドとの境界
60
+
61
+ `scaffoldNativeTargets()` が出す `VcTheme.swift` / `VcTheme.kt` は **Screen stub のみ**(native target 未実装時のプレースホルダ)。テーマ本体は `emit/swiftui.ts` / `emit/compose.ts` が `build` 時に上書き生成する。
62
+
63
+ ## 参照実装(React)
64
+
65
+ | 関心 | ファイル |
66
+ | --- | --- |
67
+ | IR 読み込み | `src/design/index.ts` → `loadValidateDesign()` |
68
+ | Token → CSS 変数 | `src/design/emitTokenCss.ts` |
69
+ | defaults / variants → CSS class | `src/design/emitDefaultCss.ts`, `emitVariantCss.ts` |
70
+ | ビルド統合 | `src/design/buildThemeCss.ts`, `renderers/react/renderRuntime.ts` |
71
+ | variant class(lowering) | `renderers/react/lowering/lowerToJsx.ts` |
72
+
73
+ ネイティブ emit は上記 CSS emit と **同じ IR・同じ解決ロジック**を共有し、出力形式だけテンプレートで差し替える。
@@ -1,8 +1,15 @@
1
1
  # Compose renderer scaffold
2
2
 
3
+ **Not implemented yet** beyond theme / screen stubs. View IR lowering and modifier emission are future work.
4
+
5
+ Compose `Modifier` chains are **compositional** (not CSS-style override). view-contracts may later introduce a separate **modifier pipeline** concept (wrapper-style transforms, analogous to nested HTML layout shells) — that is **out of scope for the current phase**. When styling is implemented, defaults + variant + instance props will be **folded at compile time** into resolved values, not chained for cascade-like override. See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) § Native renderers.
6
+
3
7
  - Maps: `elements.yaml`, `properties.yaml` (hand-maintained under this directory)
4
8
  - Routes: `routes.yaml`
5
9
  - Templates: `templates/`
10
+ - Theme codegen spec: [../NATIVE_THEME_EMIT.md](../NATIVE_THEME_EMIT.md)
6
11
 
7
12
  Implement Compose code generation by reading View IR + renderer maps.
13
+ Theme is emitted at build time via `src/design/emit/compose.ts` and `templates/theme/*.hbs` — same Token IR + Theme IR as React `theme.css`, not runtime JSON.
14
+
8
15
  Vocabulary: `spec/ui-dsl.schema.json` (from `ui-dsl-*.csv`).
@@ -1,8 +1,6 @@
1
- // {{screenName}} — Jetpack Compose scaffold (Phase 5)
2
- // Theme IR: {{themeJsonPath}}
3
- // Token IR: {{tokensJsonPath}}
4
- //
5
- // TODO(Phase C): Load theme.json and emit Modifier.primaryButton(), vcTextMuted(), etc.
1
+ // {{screenName}} — Jetpack Compose scaffold
2
+ // View IR lowering: renderers/compose/lowerToCompose.ts (planned)
3
+ // Theme: renderers/NATIVE_THEME_EMIT.md
6
4
 
7
5
  package {{packageName}}
8
6
 
@@ -1,19 +1,13 @@
1
- // VcTheme — Compose theme scaffold (Phase 5)
2
- // Load Token IR + Theme IR from assets:
3
- // {{tokensJsonPath}}
4
- // {{themeJsonPath}}
1
+ // VcTheme — Compose theme (scaffold placeholder)
5
2
  //
6
- // TODO(Phase C): Map Theme IR variants to Modifier extensions and ColorScheme.
3
+ // Theme is emitted at build time from Token IR + Theme IR via Handlebars templates.
4
+ // See renderers/NATIVE_THEME_EMIT.md — not loaded from JSON at runtime.
5
+ //
6
+ // Implementation: src/design/emit/compose.ts + renderers/compose/templates/theme/*.hbs
7
+ // Output target: generated/compose/theme/
7
8
 
8
9
  package {{packageName}}
9
10
 
10
- import android.content.Context
11
-
12
11
  object VcTheme {
13
- const val TOKENS_ASSET = "{{tokensJsonPath}}"
14
- const val THEME_ASSET = "{{themeJsonPath}}"
15
-
16
- /** Placeholder — read theme.json from assets in Phase C. */
17
- fun loadThemeJson(context: Context): String? =
18
- context.assets.open(THEME_ASSET).bufferedReader().use { it.readText() }
12
+ // Populated by emitComposeTheme() when compose target is enabled.
19
13
  }
@@ -1,4 +1,11 @@
1
1
  # Default theme variants — generic vocabulary only (no sample-specific layouts).
2
+ defaults:
3
+ Button:
4
+ background: "{ui.button-secondary}"
5
+ foreground: "{ui.text}"
6
+ radius: "{ui.space.radius-md}"
7
+ padding: "{ui.space.button-padding}"
8
+ fontWeight: "{ui.layout.font-bold}"
2
9
  variants:
3
10
  Button:
4
11
  primary:
@@ -57,3 +57,14 @@ ui:
57
57
  font:
58
58
  $type: typography
59
59
  $value: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
60
+ layout:
61
+ font-bold:
62
+ $type: layout
63
+ $value: "700"
64
+ space:
65
+ radius-md:
66
+ $type: dimension
67
+ $value: "10px"
68
+ button-padding:
69
+ $type: dimension
70
+ $value: "10px 14px"
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Compile-time style folding for React lowering.
3
+ * Production generated JSX must not call runtime style helpers.
4
+ */
5
+ import { LOWERING_STYLE_PROPERTIES } from '../../../src/generated/schema/lowering-style-properties.js';
6
+ import type { IrLiteral, IrValue } from '../../../src/ir/types.js';
7
+ import type { ElementMapEntry } from '../elementMap.js';
8
+ import { foldLiteralsToCss, foldedStyleToJsx, type FoldedCss } from '../style/foldLiterals.js';
9
+
10
+ function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
11
+ const value = props[key];
12
+ if (value === null || value === undefined) return undefined;
13
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
14
+ if (typeof value === 'object' && 'kind' in value && value.kind === 'literal') {
15
+ return (value as IrLiteral).value;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ function assertLiteralStyleProp(key: string, value: IrValue): void {
21
+ if (value === null || value === undefined) return;
22
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return;
23
+ if (typeof value === 'object' && 'kind' in value && value.kind === 'literal') return;
24
+ throw new Error(
25
+ `Style property "${key}" must be a compile-time literal; ViewModel bindings are not allowed on layout/visual props`,
26
+ );
27
+ }
28
+
29
+ export function collectLiteralStyleProps(props: Record<string, IrValue>): Record<string, unknown> {
30
+ const literals: Record<string, unknown> = {};
31
+ for (const [key, value] of Object.entries(props)) {
32
+ if (!LOWERING_STYLE_PROPERTIES.has(key)) continue;
33
+ assertLiteralStyleProp(key, value);
34
+ const lit = propLiteral(props, key);
35
+ if (lit !== undefined) literals[key] = lit;
36
+ }
37
+ return literals;
38
+ }
39
+
40
+ export function foldFlexContainer(entry: ElementMapEntry): FoldedCss {
41
+ if (!entry.flex) return {};
42
+ return {
43
+ display: 'flex',
44
+ flexDirection: entry.flex === 'column' ? 'column' : 'row',
45
+ };
46
+ }
47
+
48
+ function scrollOverflowLiteral(mode: string): FoldedCss {
49
+ if (mode === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
50
+ if (mode === 'both') return { overflowX: 'auto', overflowY: 'auto' };
51
+ if (mode === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
52
+ return { overflowX: 'hidden', overflowY: 'auto' };
53
+ }
54
+
55
+ export function styleAttrForElement(entry: ElementMapEntry, props: Record<string, IrValue>): string {
56
+ const literals = collectLiteralStyleProps(props);
57
+ let folded = foldLiteralsToCss(literals);
58
+ folded = { ...folded, ...foldFlexContainer(entry) };
59
+
60
+ if (entry.scrollContainer) {
61
+ const scrollLit = propLiteral(props, 'scroll');
62
+ const axisLit = propLiteral(props, 'axis');
63
+ const literalMode = typeof scrollLit === 'string'
64
+ ? scrollLit
65
+ : typeof axisLit === 'string'
66
+ ? (axisLit === 'horizontal' ? 'horizontal' : 'vertical')
67
+ : undefined;
68
+ if (literalMode) {
69
+ folded = { ...folded, ...scrollOverflowLiteral(literalMode) };
70
+ return foldedStyleToJsx(folded);
71
+ }
72
+ throw new Error('ScrollArea scroll/axis must be compile-time literals');
73
+ }
74
+
75
+ if (entry.flexGrowProp && props[entry.flexGrowProp]) {
76
+ const growLit = propLiteral(props, entry.flexGrowProp);
77
+ if (typeof growLit === 'number') {
78
+ folded.flexGrow = growLit;
79
+ return foldedStyleToJsx(folded);
80
+ }
81
+ throw new Error(`"${entry.flexGrowProp}" on <Spacer> must be a compile-time literal`);
82
+ }
83
+
84
+ return foldedStyleToJsx(folded);
85
+ }
86
+
87
+ export { foldLiteralsToCss, foldedStyleToJsx } from '../style/foldLiterals.js';
@@ -1,5 +1,5 @@
1
1
  import type { ElementMapEntry, ReactElementMap } from '../elementMap.js';
2
- import { LOWERING_STYLE_PROPERTIES } from '../../../src/generated/schema/lowering-style-properties.js';
2
+ import { styleAttrForElement, collectLiteralStyleProps, foldLiteralsToCss, foldedStyleToJsx } from './foldStyle.js';
3
3
  import type {
4
4
  IrAction,
5
5
  IrBinding,
@@ -10,6 +10,7 @@ import type {
10
10
  IrNode,
11
11
  IrTextNode,
12
12
  IrValue,
13
+ IrViewRefNode,
13
14
  } from '../../../src/ir/types.js';
14
15
 
15
16
  function indent(level: number): string {
@@ -81,61 +82,9 @@ function propExpr(props: Record<string, IrValue>, key: string): string | undefin
81
82
  return renderValue(value, 0);
82
83
  }
83
84
 
84
- function collectStyleProps(props: Record<string, IrValue>): string {
85
- const entries = Object.entries(props).filter(([key]) => LOWERING_STYLE_PROPERTIES.has(key));
86
- if (entries.length === 0) return '{}';
87
- const body = entries.map(([key, val]) => `${key}: ${renderValue(val, 0)}`).join(', ');
88
- return `{ ${body} }`;
89
- }
90
-
91
- function extraFlexStyle(entry: ElementMapEntry, props: Record<string, IrValue>): string {
92
- if (!entry.flex) return '';
93
- const parts = [`display: 'flex'`, `flexDirection: '${entry.flex === 'column' ? 'column' : 'row'}'`];
94
- if (props.align) parts.push(`alignItems: mapAlign(${propExpr(props, 'align')})`);
95
- if (props.justify) parts.push(`justifyContent: mapJustify(${propExpr(props, 'justify')})`);
96
- return `, ${parts.join(', ')}`;
97
- }
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
-
106
- function styleAttr(entry: ElementMapEntry, props: Record<string, IrValue>): string {
107
- const base = collectStyleProps(props);
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
- }
133
- if (entry.flexGrowProp && props[entry.flexGrowProp]) {
134
- const grow = propExpr(props, entry.flexGrowProp);
135
- return `style={{ ...commonStyle(${base})${flex}, flexGrow: ${grow} ?? 1 }}`;
136
- }
137
- if (flex) return `style={{ ...commonStyle(${base})${flex} }}`;
138
- return `style={commonStyle(${base})}`;
85
+ function pushStyleAttr(attrs: string[], entry: ElementMapEntry, props: Record<string, IrValue>): void {
86
+ const style = styleAttrForElement(entry, props);
87
+ if (style) attrs.push(style);
139
88
  }
140
89
 
141
90
  function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSubmit'): string | undefined {
@@ -267,19 +216,19 @@ function emitIcon(node: IrComponentNode, entry: ElementMapEntry, level: number):
267
216
  if (hasAlt) {
268
217
  const attrs: string[] = [
269
218
  classNameAttr(node.name, entry, props),
270
- styleAttr(entry, props),
271
219
  `src={${propExpr(props, 'name') ?? '""'}}`,
272
220
  `alt={${propExpr(props, 'alt') ?? '""'}}`,
273
221
  ];
222
+ pushStyleAttr(attrs, entry, props);
274
223
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
275
224
  return `${indent(level)}<img ${attrs.join(' ')} />`;
276
225
  }
277
226
 
278
227
  const attrs: string[] = [
279
228
  classNameAttr(node.name, entry, props),
280
- styleAttr(entry, props),
281
229
  `aria-hidden`,
282
230
  ];
231
+ pushStyleAttr(attrs, entry, props);
283
232
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
284
233
  if (props.name) attrs.push(`data-icon={${propExpr(props, 'name')}}`);
285
234
  return `${indent(level)}<span ${attrs.join(' ')} />`;
@@ -302,7 +251,8 @@ function emitVoidInput(
302
251
  props: Record<string, IrValue>,
303
252
  level: number,
304
253
  ): string {
305
- const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
254
+ const attrs: string[] = [classNameAttr(elementName, entry, props)];
255
+ pushStyleAttr(attrs, entry, props);
306
256
  if (entry.ariaHidden) attrs.push('aria-hidden');
307
257
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
308
258
  if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
@@ -350,7 +300,7 @@ function emitOpenTag(
350
300
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
351
301
  if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
352
302
  attrs.push(classNameAttr(elementName, entry, props, headingLevel));
353
- attrs.push(styleAttr(entry, props));
303
+ pushStyleAttr(attrs, entry, props);
354
304
  return `<${tag} ${attrs.join(' ')}`;
355
305
  }
356
306
 
@@ -360,7 +310,7 @@ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: n
360
310
  return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
361
311
  }
362
312
 
363
- type NodeRenderer = (node: IrComponentNode, level: number, itemScope?: string) => string;
313
+ type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
364
314
 
365
315
  function renderChildren(
366
316
  children: IrNode[],
@@ -382,15 +332,14 @@ function renderChildren(
382
332
  if (child.kind === 'fragment') {
383
333
  return renderChildren(child.children, level, renderNested, itemScope);
384
334
  }
385
- if (child.kind === 'component') return renderNested(child, level, itemScope);
386
- return '';
335
+ return renderNested(child, level, itemScope);
387
336
  }).filter(Boolean);
388
337
  return parts.join('\n');
389
338
  }
390
339
 
391
340
  function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
392
341
  const item = node.item;
393
- let body = renderNested(node.body as IrComponentNode, level + 1, item);
342
+ let body = renderNested(node.body, level + 1, item);
394
343
  if (node.key) {
395
344
  const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
396
345
  body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
@@ -427,9 +376,9 @@ function lowerVocabularyElement(
427
376
  if (node.name === 'Spinner') {
428
377
  const attrs = [
429
378
  classNameAttr(node.name, entry, props),
430
- styleAttr(entry, props),
431
379
  'aria-busy="true"',
432
380
  ];
381
+ pushStyleAttr(attrs, entry, props);
433
382
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
434
383
  if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
435
384
  else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
@@ -465,10 +414,13 @@ function lowerVocabularyElement(
465
414
  return visibilityGuard(body, props, level);
466
415
  }
467
416
  const levelExpr = propExpr(props, 'level') ?? '2';
417
+ const foldedStyle = foldedStyleToJsx(foldLiteralsToCss(collectLiteralStyleProps(props)));
418
+ const styleFragment = foldedStyle ? ` ${foldedStyle}` : '';
468
419
  const inner = childContent
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>; })()}`
420
+ ? `${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}\`}${styleFragment}>{/* children below */}</Tag>; })()}`
470
421
  : '';
471
- return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
422
+ const fallbackStyle = styleAttrForElement(entry, props);
423
+ return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)}${fallbackStyle ? ` ${fallbackStyle}` : ''} />`, props, level);
472
424
  }
473
425
 
474
426
  const tag = entry.tag;
@@ -488,6 +440,22 @@ function lowerVocabularyElement(
488
440
  return visibilityGuard(body, props, level);
489
441
  }
490
442
 
443
+ function renderViewRefProps(props: Record<string, IrValue>, itemScope?: string): string {
444
+ const vmRef = itemScope ?? 'vm';
445
+ return Object.entries(props).map(([key, value]) => {
446
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
447
+ return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
448
+ }
449
+ return `${key}={${renderValue(value, 0)}}`;
450
+ }).join(' ');
451
+ }
452
+
453
+ function renderViewRefElement(node: IrViewRefNode, level: number, itemScope?: string): string {
454
+ const props = renderViewRefProps(node.props, itemScope);
455
+ const propStr = props ? ` ${props}` : '';
456
+ return `${indent(level)}<${node.name}${propStr} />`;
457
+ }
458
+
491
459
  function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
492
460
  const vmRef = itemScope ?? 'vm';
493
461
  return Object.entries(props).map(([key, value]) => {
@@ -521,6 +489,21 @@ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string
521
489
  return name in map.elements && !allowlistExtra.includes(name);
522
490
  }
523
491
 
492
+ export function collectViewRefComponents(node: IrNode, names: Set<string>): void {
493
+ if (node.kind === 'viewRef') {
494
+ names.add(node.name);
495
+ return;
496
+ }
497
+ if (node.kind === 'component') {
498
+ for (const child of node.children) collectViewRefComponents(child, names);
499
+ return;
500
+ }
501
+ if (node.kind === 'map') collectViewRefComponents(node.body, names);
502
+ if (node.kind === 'fragment') {
503
+ for (const child of node.children) collectViewRefComponents(child, names);
504
+ }
505
+ }
506
+
524
507
  export function collectExtensionComponents(
525
508
  node: IrNode,
526
509
  map: ReactElementMap,
@@ -541,12 +524,18 @@ export function collectExtensionComponents(
541
524
  export function createNodeRenderer(
542
525
  map: ReactElementMap,
543
526
  allowlistExtra: string[],
544
- ): NodeRenderer {
527
+ ): (node: IrComponentNode, level: number, itemScope?: string) => string {
545
528
  const renderNested: NodeRenderer = (node, level, itemScope) => {
529
+ if (node.kind === 'viewRef') {
530
+ return renderViewRefElement(node, level, itemScope);
531
+ }
532
+ if (node.kind !== 'component') {
533
+ return '';
534
+ }
546
535
  if (isVocabulary(node.name, map, allowlistExtra)) {
547
536
  return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
548
537
  }
549
538
  return renderExtensionElement(node, level, itemScope, renderNested);
550
539
  };
551
- return renderNested;
540
+ return (node, level, itemScope) => renderNested(node, level, itemScope);
552
541
  }