@embeddables/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/bin/embeddables.mjs +2 -0
- package/dist/auth/index.d.ts +43 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +100 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +75 -0
- package/dist/commands/build-workbench.d.ts +5 -0
- package/dist/commands/build-workbench.d.ts.map +1 -0
- package/dist/commands/build-workbench.js +122 -0
- package/dist/commands/build.d.ts +7 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +22 -0
- package/dist/commands/dev.d.ts +11 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +153 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +112 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +18 -0
- package/dist/commands/pull.d.ts +7 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/pull.js +97 -0
- package/dist/compiler/errors.d.ts +20 -0
- package/dist/compiler/errors.d.ts.map +1 -0
- package/dist/compiler/errors.js +35 -0
- package/dist/compiler/evalStatic.d.ts +3 -0
- package/dist/compiler/evalStatic.d.ts.map +1 -0
- package/dist/compiler/evalStatic.js +57 -0
- package/dist/compiler/flatten.js +1 -0
- package/dist/compiler/helpers/duplicateIds.d.ts +9 -0
- package/dist/compiler/helpers/duplicateIds.d.ts.map +1 -0
- package/dist/compiler/helpers/duplicateIds.js +71 -0
- package/dist/compiler/index.d.ts +16 -0
- package/dist/compiler/index.d.ts.map +1 -0
- package/dist/compiler/index.js +934 -0
- package/dist/compiler/parsePage.d.ts +15 -0
- package/dist/compiler/parsePage.d.ts.map +1 -0
- package/dist/compiler/parsePage.js +562 -0
- package/dist/compiler/registry.d.ts +4 -0
- package/dist/compiler/registry.d.ts.map +1 -0
- package/dist/compiler/registry.js +44 -0
- package/dist/compiler/reverse.d.ts +17 -0
- package/dist/compiler/reverse.d.ts.map +1 -0
- package/dist/compiler/reverse.js +1632 -0
- package/dist/compiler/types.d.ts +21 -0
- package/dist/compiler/types.d.ts.map +1 -0
- package/dist/compiler/types.js +1 -0
- package/dist/components/index.d.ts +21 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +21 -0
- package/dist/components/primitives/BaseComponent.d.ts +32 -0
- package/dist/components/primitives/BaseComponent.d.ts.map +1 -0
- package/dist/components/primitives/BaseComponent.js +26 -0
- package/dist/components/primitives/BookMeeting.d.ts +18 -0
- package/dist/components/primitives/BookMeeting.d.ts.map +1 -0
- package/dist/components/primitives/BookMeeting.js +5 -0
- package/dist/components/primitives/Chart.d.ts +41 -0
- package/dist/components/primitives/Chart.d.ts.map +1 -0
- package/dist/components/primitives/Chart.js +5 -0
- package/dist/components/primitives/Container.d.ts +8 -0
- package/dist/components/primitives/Container.d.ts.map +1 -0
- package/dist/components/primitives/Container.js +5 -0
- package/dist/components/primitives/CustomButton.d.ts +37 -0
- package/dist/components/primitives/CustomButton.d.ts.map +1 -0
- package/dist/components/primitives/CustomButton.js +10 -0
- package/dist/components/primitives/CustomHTML.d.ts +8 -0
- package/dist/components/primitives/CustomHTML.d.ts.map +1 -0
- package/dist/components/primitives/CustomHTML.js +5 -0
- package/dist/components/primitives/FileUpload.d.ts +18 -0
- package/dist/components/primitives/FileUpload.d.ts.map +1 -0
- package/dist/components/primitives/FileUpload.js +16 -0
- package/dist/components/primitives/InputBox.d.ts +34 -0
- package/dist/components/primitives/InputBox.d.ts.map +1 -0
- package/dist/components/primitives/InputBox.js +25 -0
- package/dist/components/primitives/Lottie.d.ts +11 -0
- package/dist/components/primitives/Lottie.d.ts.map +1 -0
- package/dist/components/primitives/Lottie.js +5 -0
- package/dist/components/primitives/MediaEmbed.d.ts +13 -0
- package/dist/components/primitives/MediaEmbed.d.ts.map +1 -0
- package/dist/components/primitives/MediaEmbed.js +6 -0
- package/dist/components/primitives/MediaImage.d.ts +8 -0
- package/dist/components/primitives/MediaImage.d.ts.map +1 -0
- package/dist/components/primitives/MediaImage.js +5 -0
- package/dist/components/primitives/OptionSelector.d.ts +35 -0
- package/dist/components/primitives/OptionSelector.d.ts.map +1 -0
- package/dist/components/primitives/OptionSelector.js +8 -0
- package/dist/components/primitives/PaypalCheckout.d.ts +25 -0
- package/dist/components/primitives/PaypalCheckout.d.ts.map +1 -0
- package/dist/components/primitives/PaypalCheckout.js +5 -0
- package/dist/components/primitives/PlainText.d.ts +6 -0
- package/dist/components/primitives/PlainText.d.ts.map +1 -0
- package/dist/components/primitives/PlainText.js +5 -0
- package/dist/components/primitives/ProgressBar.d.ts +15 -0
- package/dist/components/primitives/ProgressBar.d.ts.map +1 -0
- package/dist/components/primitives/ProgressBar.js +5 -0
- package/dist/components/primitives/RichText.d.ts +6 -0
- package/dist/components/primitives/RichText.d.ts.map +1 -0
- package/dist/components/primitives/RichText.js +5 -0
- package/dist/components/primitives/RichTextMarkdown.d.ts +6 -0
- package/dist/components/primitives/RichTextMarkdown.d.ts.map +1 -0
- package/dist/components/primitives/RichTextMarkdown.js +5 -0
- package/dist/components/primitives/Rive.d.ts +16 -0
- package/dist/components/primitives/Rive.d.ts.map +1 -0
- package/dist/components/primitives/Rive.js +8 -0
- package/dist/components/primitives/StripeCheckout.d.ts +52 -0
- package/dist/components/primitives/StripeCheckout.d.ts.map +1 -0
- package/dist/components/primitives/StripeCheckout.js +5 -0
- package/dist/components/primitives/StripeCheckout2.d.ts +30 -0
- package/dist/components/primitives/StripeCheckout2.d.ts.map +1 -0
- package/dist/components/primitives/StripeCheckout2.js +7 -0
- package/dist/proxy/injectApiInterceptor.d.ts +6 -0
- package/dist/proxy/injectApiInterceptor.d.ts.map +1 -0
- package/dist/proxy/injectApiInterceptor.js +66 -0
- package/dist/proxy/injectReload.d.ts +2 -0
- package/dist/proxy/injectReload.d.ts.map +1 -0
- package/dist/proxy/injectReload.js +14 -0
- package/dist/proxy/injectWorkbench.d.ts +4 -0
- package/dist/proxy/injectWorkbench.d.ts.map +1 -0
- package/dist/proxy/injectWorkbench.js +16 -0
- package/dist/proxy/server.d.ts +11 -0
- package/dist/proxy/server.d.ts.map +1 -0
- package/dist/proxy/server.js +246 -0
- package/dist/proxy/sse.d.ts +5 -0
- package/dist/proxy/sse.d.ts.map +1 -0
- package/dist/proxy/sse.js +17 -0
- package/dist/types-builder.d.ts +800 -0
- package/dist/types-builder.d.ts.map +1 -0
- package/dist/types-builder.js +20 -0
- package/dist/workbench/ActionsPanel.d.ts +6 -0
- package/dist/workbench/ActionsPanel.d.ts.map +1 -0
- package/dist/workbench/ActionsPanel.js +47 -0
- package/dist/workbench/AutofillPanel.d.ts +6 -0
- package/dist/workbench/AutofillPanel.d.ts.map +1 -0
- package/dist/workbench/AutofillPanel.js +543 -0
- package/dist/workbench/ComputedFieldsPanel.d.ts +6 -0
- package/dist/workbench/ComputedFieldsPanel.d.ts.map +1 -0
- package/dist/workbench/ComputedFieldsPanel.js +31 -0
- package/dist/workbench/ExperimentsPanel.d.ts +6 -0
- package/dist/workbench/ExperimentsPanel.d.ts.map +1 -0
- package/dist/workbench/ExperimentsPanel.js +182 -0
- package/dist/workbench/FieldEditorPanel.d.ts +9 -0
- package/dist/workbench/FieldEditorPanel.d.ts.map +1 -0
- package/dist/workbench/FieldEditorPanel.js +650 -0
- package/dist/workbench/InspectorPanel.d.ts +6 -0
- package/dist/workbench/InspectorPanel.d.ts.map +1 -0
- package/dist/workbench/InspectorPanel.js +341 -0
- package/dist/workbench/PageNavigator.d.ts +6 -0
- package/dist/workbench/PageNavigator.d.ts.map +1 -0
- package/dist/workbench/PageNavigator.js +123 -0
- package/dist/workbench/SchemaPanel.d.ts +6 -0
- package/dist/workbench/SchemaPanel.d.ts.map +1 -0
- package/dist/workbench/SchemaPanel.js +222 -0
- package/dist/workbench/UserDataPanel.d.ts +6 -0
- package/dist/workbench/UserDataPanel.d.ts.map +1 -0
- package/dist/workbench/UserDataPanel.js +350 -0
- package/dist/workbench/WorkbenchApp.d.ts +6 -0
- package/dist/workbench/WorkbenchApp.d.ts.map +1 -0
- package/dist/workbench/WorkbenchApp.js +193 -0
- package/dist/workbench/cloudflare-worker/README.md +31 -0
- package/dist/workbench/cloudflare-worker/public/workbench.css +1614 -0
- package/dist/workbench/cloudflare-worker/public/workbench.js +77 -0
- package/dist/workbench/cloudflare-worker/worker.js +40 -0
- package/dist/workbench/cloudflare-worker/wrangler.toml +10 -0
- package/dist/workbench/index.d.ts +9 -0
- package/dist/workbench/index.d.ts.map +1 -0
- package/dist/workbench/index.js +44 -0
- package/dist/workbench/workbench.css +1614 -0
- package/dist/workbench/workbench.js +77 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import CSSJSON from 'cssjson';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { TYPE_MAP } from './registry.js';
|
|
6
|
+
import { generateRandomIdByType } from './helpers/duplicateIds.js';
|
|
7
|
+
/**
|
|
8
|
+
* Deep clones an object or array while preserving the order of all properties
|
|
9
|
+
* at all nesting levels. Arrays preserve element order (already guaranteed),
|
|
10
|
+
* and objects preserve property insertion order.
|
|
11
|
+
*/
|
|
12
|
+
function deepClonePreservingOrder(value) {
|
|
13
|
+
if (value === null || value === undefined) {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
// For arrays, recursively clone each element
|
|
18
|
+
return value.map((item) => deepClonePreservingOrder(item));
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === 'object') {
|
|
21
|
+
// For objects, preserve property order by iterating through keys
|
|
22
|
+
const cloned = {};
|
|
23
|
+
const keys = Object.keys(value);
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
cloned[key] = deepClonePreservingOrder(value[key]);
|
|
26
|
+
}
|
|
27
|
+
return cloned;
|
|
28
|
+
}
|
|
29
|
+
// Primitive values (string, number, boolean) are returned as-is
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
// Reverse map: JSON type -> TSX tag name
|
|
33
|
+
const REVERSE_TYPE_MAP = Object.fromEntries(Object.entries(TYPE_MAP).map(([k, v]) => [v, k]));
|
|
34
|
+
// Component types to ignore during reverse compilation
|
|
35
|
+
const IGNORED_COMPONENT_TYPES = new Set(['PageTitle']);
|
|
36
|
+
// Component properties to ignore during reverse compilation
|
|
37
|
+
const IGNORED_COMPONENT_PROPERTIES = new Set(['clearable', 'central_play_button']);
|
|
38
|
+
// Base component properties (from BaseComponentProps)
|
|
39
|
+
const BASE_COMPONENT_PROPS = new Set([
|
|
40
|
+
'id',
|
|
41
|
+
'key',
|
|
42
|
+
'parent_id',
|
|
43
|
+
'_location',
|
|
44
|
+
'hide',
|
|
45
|
+
'conditions',
|
|
46
|
+
'tags',
|
|
47
|
+
'repeater_key',
|
|
48
|
+
'element_id',
|
|
49
|
+
'tooltip',
|
|
50
|
+
'page_reactive_classes',
|
|
51
|
+
'conditional_tags',
|
|
52
|
+
'outputs_onmounted',
|
|
53
|
+
'outputs_onunmounted',
|
|
54
|
+
'outputs_onrepeatablerender',
|
|
55
|
+
'always_rerender',
|
|
56
|
+
'ignore_in_qa_tests',
|
|
57
|
+
'ignore_recording',
|
|
58
|
+
]);
|
|
59
|
+
// Input component properties (from InputComponentProps - extends BaseComponentProps)
|
|
60
|
+
const INPUT_COMPONENT_PROPS = new Set([...BASE_COMPONENT_PROPS, 'isRequired', 'doNotSave']);
|
|
61
|
+
// Required props for each component type (id and key are always required and handled separately)
|
|
62
|
+
const REQUIRED_PROPS = {
|
|
63
|
+
PlainText: new Set(['text']),
|
|
64
|
+
RichText: new Set(['text']),
|
|
65
|
+
RichTextMarkdown: new Set(['text']),
|
|
66
|
+
CustomButton: new Set(),
|
|
67
|
+
BookMeeting: new Set(['service', 'embedUrl']),
|
|
68
|
+
StripeCheckout: new Set(['action', 'publishable_key', 'amount']),
|
|
69
|
+
// Add more as needed based on component interfaces
|
|
70
|
+
};
|
|
71
|
+
// Component-specific valid properties (extends base props)
|
|
72
|
+
// This is a best-effort mapping - if a prop isn't here but matches common patterns, it's still allowed
|
|
73
|
+
const COMPONENT_SPECIFIC_PROPS = {
|
|
74
|
+
OptionSelector: new Set([
|
|
75
|
+
...INPUT_COMPONENT_PROPS,
|
|
76
|
+
'label',
|
|
77
|
+
'conversions',
|
|
78
|
+
'buttons',
|
|
79
|
+
'multiple',
|
|
80
|
+
'allow_deselect',
|
|
81
|
+
'dropdown',
|
|
82
|
+
'is_advanced_dropdown',
|
|
83
|
+
'maxSelection',
|
|
84
|
+
'block_selection_over_max',
|
|
85
|
+
'checkbox',
|
|
86
|
+
'layout',
|
|
87
|
+
'checkbox_location',
|
|
88
|
+
'allow_typing',
|
|
89
|
+
'defaultVal',
|
|
90
|
+
'preset_buttons',
|
|
91
|
+
'button_repeater_key',
|
|
92
|
+
'empty_invalid_message',
|
|
93
|
+
'invalid_message',
|
|
94
|
+
'validation_formula',
|
|
95
|
+
'outputs_onchange',
|
|
96
|
+
'fetch_uri',
|
|
97
|
+
'fetch_headers',
|
|
98
|
+
'fetch_transformer',
|
|
99
|
+
'fetch_debounce',
|
|
100
|
+
'outputs_onbuttonsrepeatablerender',
|
|
101
|
+
'richDescription',
|
|
102
|
+
'richText',
|
|
103
|
+
'randomize_buttons',
|
|
104
|
+
'always_disabled',
|
|
105
|
+
]),
|
|
106
|
+
InputBox: new Set([
|
|
107
|
+
...INPUT_COMPONENT_PROPS,
|
|
108
|
+
'input_type',
|
|
109
|
+
'label',
|
|
110
|
+
'placeholder',
|
|
111
|
+
'use_tel_input',
|
|
112
|
+
'mismatch_values_message',
|
|
113
|
+
'invalid_message',
|
|
114
|
+
'empty_invalid_message',
|
|
115
|
+
'max_length',
|
|
116
|
+
'icon',
|
|
117
|
+
'range_min',
|
|
118
|
+
'range_max',
|
|
119
|
+
'range_step',
|
|
120
|
+
'width',
|
|
121
|
+
'label_on_both_side',
|
|
122
|
+
'off_label',
|
|
123
|
+
'multiline',
|
|
124
|
+
'trim_whitespace_on_blur',
|
|
125
|
+
'format_email_on_blur',
|
|
126
|
+
'validation_formula',
|
|
127
|
+
'validate_on_blur',
|
|
128
|
+
'outputs_onchange',
|
|
129
|
+
'integer_only',
|
|
130
|
+
'debounce_time',
|
|
131
|
+
'disable_auto_next',
|
|
132
|
+
'validate_value',
|
|
133
|
+
'phone_display_format',
|
|
134
|
+
'phone_store_format',
|
|
135
|
+
'custom_validation_function',
|
|
136
|
+
'always_disabled',
|
|
137
|
+
]),
|
|
138
|
+
CustomButton: new Set([
|
|
139
|
+
...BASE_COMPONENT_PROPS,
|
|
140
|
+
'text',
|
|
141
|
+
'action',
|
|
142
|
+
'conversions',
|
|
143
|
+
'actionUrl',
|
|
144
|
+
'toggle',
|
|
145
|
+
'icon',
|
|
146
|
+
'emojiIcon',
|
|
147
|
+
'imageUrl',
|
|
148
|
+
'imageAltText',
|
|
149
|
+
'richText',
|
|
150
|
+
'description',
|
|
151
|
+
'richDescription',
|
|
152
|
+
'openUrlInNewTab',
|
|
153
|
+
'ariaLabel',
|
|
154
|
+
'info_box_key',
|
|
155
|
+
'allow_skip_validation',
|
|
156
|
+
'hide_if_no_nav_target',
|
|
157
|
+
'needs_validation_passed',
|
|
158
|
+
'validation_show_state',
|
|
159
|
+
'shouldDownloadUrl',
|
|
160
|
+
'downloadFilename',
|
|
161
|
+
'outputs_onclick',
|
|
162
|
+
'use_custom_code',
|
|
163
|
+
'custom_code',
|
|
164
|
+
'output_key',
|
|
165
|
+
'track_clicks',
|
|
166
|
+
'show_loading_until_actions_resolved',
|
|
167
|
+
'success_message',
|
|
168
|
+
'loading_message',
|
|
169
|
+
'fail_message',
|
|
170
|
+
'primary_button',
|
|
171
|
+
'always_disabled',
|
|
172
|
+
]),
|
|
173
|
+
PlainText: new Set([...BASE_COMPONENT_PROPS, 'text']),
|
|
174
|
+
RichText: new Set([...BASE_COMPONENT_PROPS, 'text']),
|
|
175
|
+
RichTextMarkdown: new Set([...BASE_COMPONENT_PROPS, 'text']),
|
|
176
|
+
MediaImage: new Set([...BASE_COMPONENT_PROPS, 'src', 'caption', 'alt_text']),
|
|
177
|
+
MediaEmbed: new Set([...BASE_COMPONENT_PROPS, 'src', 'caption']),
|
|
178
|
+
Container: new Set([...BASE_COMPONENT_PROPS, 'container_type']),
|
|
179
|
+
FileUpload: new Set([
|
|
180
|
+
...INPUT_COMPONENT_PROPS,
|
|
181
|
+
'label',
|
|
182
|
+
'upload_type',
|
|
183
|
+
'custom_upload_type',
|
|
184
|
+
'icon',
|
|
185
|
+
'files_max_limit',
|
|
186
|
+
]),
|
|
187
|
+
Chart: new Set([
|
|
188
|
+
...BASE_COMPONENT_PROPS,
|
|
189
|
+
'chart_type',
|
|
190
|
+
'data',
|
|
191
|
+
'options',
|
|
192
|
+
'scales',
|
|
193
|
+
'maintain_aspect_ratio',
|
|
194
|
+
]),
|
|
195
|
+
Rive: new Set([
|
|
196
|
+
...BASE_COMPONENT_PROPS,
|
|
197
|
+
'src',
|
|
198
|
+
'autoplay',
|
|
199
|
+
'artboard',
|
|
200
|
+
'animation',
|
|
201
|
+
'capture_events',
|
|
202
|
+
]),
|
|
203
|
+
Lottie: new Set([...BASE_COMPONENT_PROPS, 'src', 'autoplay', 'loop']),
|
|
204
|
+
BookMeeting: new Set([
|
|
205
|
+
...BASE_COMPONENT_PROPS,
|
|
206
|
+
'service',
|
|
207
|
+
'embedUrl',
|
|
208
|
+
'next_on_complete',
|
|
209
|
+
'next_on_complete_timeout',
|
|
210
|
+
'isTesting',
|
|
211
|
+
]),
|
|
212
|
+
StripeCheckout: new Set([
|
|
213
|
+
...INPUT_COMPONENT_PROPS,
|
|
214
|
+
'label',
|
|
215
|
+
'action',
|
|
216
|
+
'publishable_key',
|
|
217
|
+
'amount',
|
|
218
|
+
'currency',
|
|
219
|
+
'description',
|
|
220
|
+
'button_text',
|
|
221
|
+
'pay_button_text',
|
|
222
|
+
'use_new_stripe_elements',
|
|
223
|
+
]),
|
|
224
|
+
StripeCheckout2: new Set([
|
|
225
|
+
...INPUT_COMPONENT_PROPS,
|
|
226
|
+
'label',
|
|
227
|
+
'elements_to_display',
|
|
228
|
+
'checkout_session',
|
|
229
|
+
'init_checkout_options',
|
|
230
|
+
'on_payment_complete_conversions',
|
|
231
|
+
]),
|
|
232
|
+
PaypalCheckout: new Set([
|
|
233
|
+
...INPUT_COMPONENT_PROPS,
|
|
234
|
+
'label',
|
|
235
|
+
'action',
|
|
236
|
+
'amount',
|
|
237
|
+
'currency',
|
|
238
|
+
'description',
|
|
239
|
+
]),
|
|
240
|
+
ProgressBar: new Set([...BASE_COMPONENT_PROPS, 'value', 'max', 'show_percentage']),
|
|
241
|
+
CustomHTML: new Set([...BASE_COMPONENT_PROPS, 'text']),
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Checks if a component has all required props.
|
|
245
|
+
* Returns true if all required props are present, false otherwise.
|
|
246
|
+
* When fix is true: logs a warning and returns false if required props are missing.
|
|
247
|
+
* When fix is false/undefined: throws if required props are missing.
|
|
248
|
+
*/
|
|
249
|
+
function hasRequiredProps(component, fix) {
|
|
250
|
+
const tagName = getComponentName(component.type);
|
|
251
|
+
const requiredProps = REQUIRED_PROPS[tagName] || REQUIRED_PROPS[component.type];
|
|
252
|
+
if (!requiredProps || requiredProps.size === 0) {
|
|
253
|
+
// No required props for this component type
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
const missingProps = [];
|
|
257
|
+
for (const prop of requiredProps) {
|
|
258
|
+
const value = component[prop];
|
|
259
|
+
if (value === null || value === undefined || value === '') {
|
|
260
|
+
missingProps.push(prop);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (missingProps.length > 0) {
|
|
264
|
+
if (fix) {
|
|
265
|
+
console.warn(`Removed ${component.type} component (id: ${component.id}, key: ${component.key}) missing required props: ${missingProps.join(', ')}.`);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`${component.type} component (id: ${component.id}, key: ${component.key}) missing required props: ${missingProps.join(', ')}.`);
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Validates props for a component.
|
|
274
|
+
* Returns valid prop keys to include in the generated JSX, and any extra (non-schema) props
|
|
275
|
+
* to store in the `props` attribute as a JSON object for round-trip preservation.
|
|
276
|
+
*/
|
|
277
|
+
function validateComponentProps(component, validProps) {
|
|
278
|
+
const validPropKeys = new Set();
|
|
279
|
+
const extraProps = {};
|
|
280
|
+
// Always include id and key - these are handled separately but should be considered valid
|
|
281
|
+
const excludedProps = new Set([
|
|
282
|
+
'id',
|
|
283
|
+
'key',
|
|
284
|
+
'tags',
|
|
285
|
+
'type',
|
|
286
|
+
'parent_id',
|
|
287
|
+
'buttons',
|
|
288
|
+
...IGNORED_COMPONENT_PROPERTIES,
|
|
289
|
+
]);
|
|
290
|
+
for (const [key, value] of Object.entries(component)) {
|
|
291
|
+
// Skip excluded props (they're handled separately or ignored)
|
|
292
|
+
if (excludedProps.has(key)) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Skip null/undefined values
|
|
296
|
+
if (value === null || value === undefined) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Check if prop is valid
|
|
300
|
+
if (validProps.has(key)) {
|
|
301
|
+
validPropKeys.add(key);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Extra prop (not in schema) - store for round-trip via props={...}
|
|
305
|
+
extraProps[key] = value;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { validPropKeys, extraProps };
|
|
309
|
+
}
|
|
310
|
+
// Fallback: if type not in map, use type name directly (component files match type names)
|
|
311
|
+
function getComponentName(type) {
|
|
312
|
+
// First try exact match
|
|
313
|
+
if (REVERSE_TYPE_MAP[type]) {
|
|
314
|
+
return REVERSE_TYPE_MAP[type];
|
|
315
|
+
}
|
|
316
|
+
// If not found, try case-insensitive match against TYPE_MAP values
|
|
317
|
+
// to find the correct casing and return the corresponding TSX tag name
|
|
318
|
+
const typeLower = type.toLowerCase();
|
|
319
|
+
for (const [tsxName, jsonType] of Object.entries(TYPE_MAP)) {
|
|
320
|
+
if (jsonType.toLowerCase() === typeLower) {
|
|
321
|
+
return tsxName; // Return the TSX tag name (e.g., "CustomHTML")
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// If still not found, return type as-is (for unknown types)
|
|
325
|
+
return type;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Collects all component and button IDs from all pages and checks for duplicates.
|
|
329
|
+
* Returns a map of occurrence key (pageKey:componentIndex:buttonIndex) -> new unique ID for any duplicates found.
|
|
330
|
+
*
|
|
331
|
+
* Note: This function only deduplicates IDs, not keys. Duplicate keys are intentional
|
|
332
|
+
* in many cases and should be preserved.
|
|
333
|
+
*/
|
|
334
|
+
function checkAndFixDuplicateIds(pages, fix) {
|
|
335
|
+
const idMapping = new Map(); // Maps occurrence key -> new unique ID
|
|
336
|
+
const idOccurrences = new Map();
|
|
337
|
+
const allIds = new Set();
|
|
338
|
+
// First pass: collect all IDs with their context
|
|
339
|
+
for (const page of pages) {
|
|
340
|
+
for (let compIndex = 0; compIndex < page.components.length; compIndex++) {
|
|
341
|
+
const component = page.components[compIndex];
|
|
342
|
+
// Handle components without type
|
|
343
|
+
if (!component.type) {
|
|
344
|
+
if (fix) {
|
|
345
|
+
// Will be removed in buildTree, skip here
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`Component (id: ${component.id}, key: ${component.key}) is missing required type property.`);
|
|
349
|
+
}
|
|
350
|
+
// Skip ignored types
|
|
351
|
+
if (IGNORED_COMPONENT_TYPES.has(component.type)) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const compId = component.id;
|
|
355
|
+
allIds.add(compId);
|
|
356
|
+
const occurrenceKey = `${page.key}:${compIndex}`;
|
|
357
|
+
if (!idOccurrences.has(compId)) {
|
|
358
|
+
idOccurrences.set(compId, []);
|
|
359
|
+
}
|
|
360
|
+
idOccurrences.get(compId).push({
|
|
361
|
+
pageKey: page.key,
|
|
362
|
+
type: `component (${component.type})`,
|
|
363
|
+
id: compId,
|
|
364
|
+
componentType: component.type,
|
|
365
|
+
componentIndex: compIndex,
|
|
366
|
+
occurrenceKey,
|
|
367
|
+
});
|
|
368
|
+
// Collect button IDs from OptionSelector components
|
|
369
|
+
if (component.type === 'OptionSelector' && component.buttons) {
|
|
370
|
+
const buttons = component.buttons;
|
|
371
|
+
if (Array.isArray(buttons)) {
|
|
372
|
+
for (let btnIndex = 0; btnIndex < buttons.length; btnIndex++) {
|
|
373
|
+
const button = buttons[btnIndex];
|
|
374
|
+
if (button && typeof button === 'object' && button.id) {
|
|
375
|
+
const buttonId = button.id;
|
|
376
|
+
allIds.add(buttonId);
|
|
377
|
+
const buttonOccurrenceKey = `${page.key}:${compIndex}:${btnIndex}`;
|
|
378
|
+
if (!idOccurrences.has(buttonId)) {
|
|
379
|
+
idOccurrences.set(buttonId, []);
|
|
380
|
+
}
|
|
381
|
+
idOccurrences.get(buttonId).push({
|
|
382
|
+
pageKey: page.key,
|
|
383
|
+
type: `button (in ${component.type} component)`,
|
|
384
|
+
id: buttonId,
|
|
385
|
+
componentType: component.type,
|
|
386
|
+
componentIndex: compIndex,
|
|
387
|
+
buttonIndex: btnIndex,
|
|
388
|
+
occurrenceKey: buttonOccurrenceKey,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Second pass: find duplicates and create unique IDs
|
|
397
|
+
for (const [id, occurrences] of idOccurrences.entries()) {
|
|
398
|
+
if (occurrences.length > 1) {
|
|
399
|
+
// This ID is duplicated
|
|
400
|
+
const isButton = occurrences[0].buttonIndex !== undefined;
|
|
401
|
+
const suffix = isButton ? ' (OptionSelector button)' : '';
|
|
402
|
+
console.warn(`Found duplicate ID "${id}"${suffix} used ${occurrences.length} times:`);
|
|
403
|
+
for (let i = 0; i < occurrences.length; i++) {
|
|
404
|
+
const occ = occurrences[i];
|
|
405
|
+
const location = occ.buttonIndex !== undefined
|
|
406
|
+
? `button at index ${occ.buttonIndex}`
|
|
407
|
+
: `component at index ${occ.componentIndex}`;
|
|
408
|
+
console.log(` - Page "${occ.pageKey}": ${occ.type} (${location}) with ID "${occ.id}"`);
|
|
409
|
+
}
|
|
410
|
+
// Create unique IDs for all but the first occurrence (type-based: plaintext_xxx, button_xxx, option_xxx)
|
|
411
|
+
for (let i = 1; i < occurrences.length; i++) {
|
|
412
|
+
const occ = occurrences[i];
|
|
413
|
+
const isOptionButton = occ.buttonIndex !== undefined;
|
|
414
|
+
const newId = generateRandomIdByType(occ.componentType, isOptionButton, allIds, idMapping.values());
|
|
415
|
+
allIds.add(newId);
|
|
416
|
+
// Map the occurrence key (not the ID) to the new unique ID
|
|
417
|
+
idMapping.set(occ.occurrenceKey, newId);
|
|
418
|
+
const location = occ.buttonIndex !== undefined
|
|
419
|
+
? `button at index ${occ.buttonIndex}`
|
|
420
|
+
: `component at index ${occ.componentIndex}`;
|
|
421
|
+
console.log(` → Renamed duplicate ID "${occ.id}" to "${newId}" in page "${occ.pageKey}" (${location})`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return idMapping;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Applies the ID mapping to all pages, updating component IDs and button IDs.
|
|
429
|
+
* The mapping uses occurrence keys (pageKey:componentIndex or pageKey:componentIndex:buttonIndex).
|
|
430
|
+
* Note: parent_id references are not automatically updated because we can't determine
|
|
431
|
+
* which specific occurrence a parent_id points to when there are duplicate IDs.
|
|
432
|
+
*/
|
|
433
|
+
function applyIdMapping(pages, idMapping) {
|
|
434
|
+
// Update component and button IDs using occurrence keys
|
|
435
|
+
for (const page of pages) {
|
|
436
|
+
for (let compIndex = 0; compIndex < page.components.length; compIndex++) {
|
|
437
|
+
const component = page.components[compIndex];
|
|
438
|
+
// Skip components without type or with ignored types
|
|
439
|
+
if (!component.type || IGNORED_COMPONENT_TYPES.has(component.type)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const occurrenceKey = `${page.key}:${compIndex}`;
|
|
443
|
+
if (idMapping.has(occurrenceKey)) {
|
|
444
|
+
component.id = idMapping.get(occurrenceKey);
|
|
445
|
+
}
|
|
446
|
+
// Update button IDs in OptionSelector components
|
|
447
|
+
if (component.type === 'OptionSelector' && component.buttons) {
|
|
448
|
+
const buttons = component.buttons;
|
|
449
|
+
if (Array.isArray(buttons)) {
|
|
450
|
+
for (let btnIndex = 0; btnIndex < buttons.length; btnIndex++) {
|
|
451
|
+
const button = buttons[btnIndex];
|
|
452
|
+
if (button && typeof button === 'object' && button.id) {
|
|
453
|
+
const buttonOccurrenceKey = `${page.key}:${compIndex}:${btnIndex}`;
|
|
454
|
+
if (idMapping.has(buttonOccurrenceKey)) {
|
|
455
|
+
button.id = idMapping.get(buttonOccurrenceKey);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
export async function reverseCompile(embeddable, embeddableId, opts) {
|
|
465
|
+
const fix = opts?.fix ?? false;
|
|
466
|
+
// Check for duplicate IDs across all pages and create a mapping
|
|
467
|
+
const idMapping = checkAndFixDuplicateIds(embeddable.pages, fix);
|
|
468
|
+
// Apply ID mapping to all pages
|
|
469
|
+
if (idMapping.size > 0) {
|
|
470
|
+
applyIdMapping(embeddable.pages, idMapping);
|
|
471
|
+
}
|
|
472
|
+
// Generate TSX pages
|
|
473
|
+
for (const page of embeddable.pages) {
|
|
474
|
+
await generatePageFile(page, embeddableId, fix);
|
|
475
|
+
}
|
|
476
|
+
// Generate CSS from styles
|
|
477
|
+
if (embeddable.styles && Object.keys(embeddable.styles).length > 0) {
|
|
478
|
+
await generateStylesFile(embeddable.styles, embeddableId);
|
|
479
|
+
}
|
|
480
|
+
// Generate config.json
|
|
481
|
+
await generateConfigFile(embeddable, embeddableId);
|
|
482
|
+
// Extract computedFields to JS files
|
|
483
|
+
if (embeddable.computedFields && embeddable.computedFields.length > 0) {
|
|
484
|
+
await extractComputedFields(embeddable.computedFields, embeddableId);
|
|
485
|
+
}
|
|
486
|
+
// Extract dataOutputs to JS files
|
|
487
|
+
if (embeddable.dataOutputs && embeddable.dataOutputs.length > 0) {
|
|
488
|
+
await extractDataOutputs(embeddable.dataOutputs, embeddableId);
|
|
489
|
+
}
|
|
490
|
+
// Extract global components to TSX files
|
|
491
|
+
if (embeddable.components &&
|
|
492
|
+
Array.isArray(embeddable.components) &&
|
|
493
|
+
embeddable.components.length > 0) {
|
|
494
|
+
await extractGlobalComponents(embeddable.components, embeddableId, fix);
|
|
495
|
+
}
|
|
496
|
+
console.log(`${pc.cyan(`✓ Generated ${embeddable.pages.length} page(s) and ${embeddable.styles && Object.keys(embeddable.styles).length > 0 ? 'styles' : 'no styles'}`)}`);
|
|
497
|
+
}
|
|
498
|
+
async function generatePageFile(page, embeddableId, fix) {
|
|
499
|
+
const pageKey = page.key;
|
|
500
|
+
const filePath = path.join('embeddables', embeddableId, 'pages', `${pageKey}.page.tsx`);
|
|
501
|
+
try {
|
|
502
|
+
// Build tree from flat component list
|
|
503
|
+
const tree = buildTree(page.components, pageKey, fix);
|
|
504
|
+
// Generate TSX code
|
|
505
|
+
const tsx = generateTSX(page, tree);
|
|
506
|
+
// Write to embeddables/<id>/pages/<pageKey>.page.tsx
|
|
507
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
508
|
+
fs.writeFileSync(filePath, tsx, 'utf8');
|
|
509
|
+
console.log(`${pc.gray(`Generated ${filePath}`)}`);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (error instanceof Error) {
|
|
513
|
+
throw new Error(`Page "${pageKey}" (${filePath}): ${error.message}`);
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function buildTree(components, pageKey, fix) {
|
|
519
|
+
// Filter out components without type and ignored component types
|
|
520
|
+
let filteredComponents = components.filter((comp) => {
|
|
521
|
+
if (!comp.type) {
|
|
522
|
+
if (fix) {
|
|
523
|
+
console.warn(`Removed component (id: ${comp.id}, key: ${comp.key}) – missing type property.`);
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
throw new Error(`Component (id: ${comp.id}, key: ${comp.key}) is missing required type property.`);
|
|
527
|
+
}
|
|
528
|
+
return !IGNORED_COMPONENT_TYPES.has(comp.type);
|
|
529
|
+
});
|
|
530
|
+
// Filter out components missing required props
|
|
531
|
+
filteredComponents = filteredComponents.filter((comp) => {
|
|
532
|
+
return hasRequiredProps(comp, fix);
|
|
533
|
+
});
|
|
534
|
+
// Create a map of id -> component
|
|
535
|
+
const componentMap = new Map();
|
|
536
|
+
for (const comp of filteredComponents) {
|
|
537
|
+
componentMap.set(comp.id, comp);
|
|
538
|
+
}
|
|
539
|
+
// Find root components (no parent_id)
|
|
540
|
+
const roots = [];
|
|
541
|
+
for (const comp of filteredComponents) {
|
|
542
|
+
if (!comp.parent_id) {
|
|
543
|
+
roots.push(comp);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Allow blank pages - return null if no components
|
|
547
|
+
if (roots.length === 0) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
// Build tree recursively for each root
|
|
551
|
+
const rootNodes = roots.map((root) => buildNode(root, componentMap, pageKey, fix));
|
|
552
|
+
// Return single node or array of nodes
|
|
553
|
+
return rootNodes.length === 1 ? rootNodes[0] : rootNodes;
|
|
554
|
+
}
|
|
555
|
+
function buildNode(component, componentMap, pageKey, fix) {
|
|
556
|
+
const children = [];
|
|
557
|
+
// Find all children of this component (excluding ignored types and those missing required props)
|
|
558
|
+
for (const comp of componentMap.values()) {
|
|
559
|
+
if (comp.parent_id === component.id &&
|
|
560
|
+
!IGNORED_COMPONENT_TYPES.has(comp.type) &&
|
|
561
|
+
hasRequiredProps(comp, fix)) {
|
|
562
|
+
children.push(buildNode(comp, componentMap, pageKey, fix));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
component,
|
|
567
|
+
children,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function generateTSX(page, tree) {
|
|
571
|
+
const pageName = capitalizeFirst(page.key) + 'Page';
|
|
572
|
+
const pageKey = page.key;
|
|
573
|
+
const imports = tree ? collectImports(tree) : [];
|
|
574
|
+
const hasButtons = tree ? hasButtonConstants(tree) : false;
|
|
575
|
+
const { constants, nameMap } = tree
|
|
576
|
+
? generateButtonConstants(tree)
|
|
577
|
+
: { constants: '', nameMap: new Map() };
|
|
578
|
+
const jsx = generateJSXFromTree(tree, 4, pageKey, nameMap);
|
|
579
|
+
return `"use client";
|
|
580
|
+
|
|
581
|
+
import React from "react";
|
|
582
|
+
${imports
|
|
583
|
+
.map((imp) => `import { ${imp} } from "../../../src/components/primitives/${imp}";`)
|
|
584
|
+
.join('\n')}
|
|
585
|
+
${hasButtons
|
|
586
|
+
? `
|
|
587
|
+
import type { OptionSelectorButton } from "../../../src/types-builder";`
|
|
588
|
+
: ''}
|
|
589
|
+
|
|
590
|
+
export default function ${pageName}() {
|
|
591
|
+
${constants}
|
|
592
|
+
return (
|
|
593
|
+
${jsx}
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
`;
|
|
597
|
+
}
|
|
598
|
+
function collectImports(node) {
|
|
599
|
+
if (node === null) {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
const imports = new Set();
|
|
603
|
+
const nodes = Array.isArray(node) ? node : [node];
|
|
604
|
+
for (const n of nodes) {
|
|
605
|
+
const type = n.component.type;
|
|
606
|
+
const tagName = getComponentName(type);
|
|
607
|
+
imports.add(tagName);
|
|
608
|
+
for (const child of n.children) {
|
|
609
|
+
for (const imp of collectImports(child)) {
|
|
610
|
+
imports.add(imp);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return Array.from(imports).sort();
|
|
615
|
+
}
|
|
616
|
+
function hasButtonConstants(node) {
|
|
617
|
+
if (node === null) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
function checkButtons(n) {
|
|
621
|
+
if (n.component.type === 'OptionSelector' && n.component.buttons) {
|
|
622
|
+
const buttons = n.component.buttons;
|
|
623
|
+
if (Array.isArray(buttons) && buttons.length > 0) {
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const child of n.children) {
|
|
628
|
+
if (checkButtons(child)) {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
const nodes = Array.isArray(node) ? node : [node];
|
|
635
|
+
for (const n of nodes) {
|
|
636
|
+
if (checkButtons(n)) {
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
function generateButtonConstants(node) {
|
|
643
|
+
if (node === null) {
|
|
644
|
+
return { constants: '', nameMap: new Map() };
|
|
645
|
+
}
|
|
646
|
+
const constants = [];
|
|
647
|
+
const usedNames = new Set(); // Track used constant names
|
|
648
|
+
const nameMap = new Map(); // Map component ID -> constant name
|
|
649
|
+
function collectButtons(n) {
|
|
650
|
+
if (n.component.type === 'OptionSelector' && n.component.buttons) {
|
|
651
|
+
const buttons = n.component.buttons;
|
|
652
|
+
if (Array.isArray(buttons) && buttons.length > 0) {
|
|
653
|
+
// Generate a constant name based on the component key or id
|
|
654
|
+
let baseName = camelCase(n.component.key || n.component.id) + 'Buttons';
|
|
655
|
+
let constName = baseName;
|
|
656
|
+
// Ensure uniqueness by checking if name is already used
|
|
657
|
+
let suffix = 1;
|
|
658
|
+
if (usedNames.has(constName)) {
|
|
659
|
+
// If name collision, append component ID suffix to ensure uniqueness
|
|
660
|
+
const idSuffix = camelCase(n.component.id);
|
|
661
|
+
constName = baseName.replace(/Buttons$/, '') + idSuffix + 'Buttons';
|
|
662
|
+
}
|
|
663
|
+
// If still not unique, add numeric suffix
|
|
664
|
+
while (usedNames.has(constName)) {
|
|
665
|
+
constName = baseName.replace(/Buttons$/, '') + suffix + 'Buttons';
|
|
666
|
+
suffix++;
|
|
667
|
+
}
|
|
668
|
+
usedNames.add(constName);
|
|
669
|
+
nameMap.set(n.component.id, constName);
|
|
670
|
+
const buttonArray = buttons
|
|
671
|
+
.map((b) => {
|
|
672
|
+
const props = [];
|
|
673
|
+
// Always include id - preserve from JSON or generate if missing
|
|
674
|
+
const buttonId = b.id || `option_${b.key}`;
|
|
675
|
+
props.push(`id: ${escapeStringForJS(buttonId)}`);
|
|
676
|
+
// Preserve key if present
|
|
677
|
+
if (b.key)
|
|
678
|
+
props.push(`key: ${escapeStringForJS(b.key)}`);
|
|
679
|
+
// Preserve text if present
|
|
680
|
+
if (b.text)
|
|
681
|
+
props.push(`text: ${escapeStringForJS(b.text)}`);
|
|
682
|
+
// Preserve other button properties from JSON (e.g., triggerEvent, description, imageUrl, etc.)
|
|
683
|
+
const buttonPropertyKeys = new Set(['id', 'key', 'text']);
|
|
684
|
+
for (const [key, value] of Object.entries(b)) {
|
|
685
|
+
if (buttonPropertyKeys.has(key))
|
|
686
|
+
continue;
|
|
687
|
+
if (value === null || value === undefined)
|
|
688
|
+
continue;
|
|
689
|
+
if (typeof value === 'string') {
|
|
690
|
+
props.push(`${key}: ${escapeStringForJS(value)}`);
|
|
691
|
+
}
|
|
692
|
+
else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
693
|
+
props.push(`${key}: ${value}`);
|
|
694
|
+
}
|
|
695
|
+
else if (Array.isArray(value) || typeof value === 'object') {
|
|
696
|
+
// Handle arrays (e.g., conditions) and objects
|
|
697
|
+
props.push(`${key}: ${JSON.stringify(value)}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return `{ ${props.join(', ')} }`;
|
|
701
|
+
})
|
|
702
|
+
.join(',\n ');
|
|
703
|
+
constants.push(` const ${constName}: OptionSelectorButton[] = [\n ${buttonArray}\n ];`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
for (const child of n.children) {
|
|
707
|
+
collectButtons(child);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const nodes = Array.isArray(node) ? node : [node];
|
|
711
|
+
for (const n of nodes) {
|
|
712
|
+
collectButtons(n);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
constants: constants.length > 0 ? constants.join('\n') + '\n' : '',
|
|
716
|
+
nameMap,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
function generateJSXFromTree(tree, indent = 4, pageKey, nameMap) {
|
|
720
|
+
if (tree === null) {
|
|
721
|
+
// Blank page - return empty fragment
|
|
722
|
+
const indentStr = ' '.repeat(indent);
|
|
723
|
+
return `${indentStr}<> </>`;
|
|
724
|
+
}
|
|
725
|
+
else if (Array.isArray(tree)) {
|
|
726
|
+
// Multiple root components - wrap in fragment
|
|
727
|
+
const childrenJSX = tree
|
|
728
|
+
.map((node) => generateJSX(node, indent + 2, pageKey, node.component.id, nameMap))
|
|
729
|
+
.join('\n');
|
|
730
|
+
const indentStr = ' '.repeat(indent);
|
|
731
|
+
return `${indentStr}<>\n${childrenJSX}\n${indentStr}</>`;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
// Single root component
|
|
735
|
+
return generateJSX(tree, indent, pageKey, tree.component.id, nameMap);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function generateJSX(node, indent = 4, pageKey, componentId, nameMap) {
|
|
739
|
+
const comp = node.component;
|
|
740
|
+
const type = comp.type;
|
|
741
|
+
const tagName = getComponentName(type);
|
|
742
|
+
// Note: We don't throw errors here, but if we did, we'd use the context
|
|
743
|
+
// The context is available for future error handling if needed
|
|
744
|
+
// Get valid props for this component type (default to base props if unknown)
|
|
745
|
+
const validProps = COMPONENT_SPECIFIC_PROPS[tagName] || COMPONENT_SPECIFIC_PROPS[type] || BASE_COMPONENT_PROPS;
|
|
746
|
+
// Validate props; get valid keys and any extra (non-schema) props to store in props={}
|
|
747
|
+
const { validPropKeys, extraProps } = validateComponentProps(comp, validProps);
|
|
748
|
+
const props = [];
|
|
749
|
+
// Always preserve component ID from JSON
|
|
750
|
+
props.push(`id=${escapeStringForJSX(comp.id)}`);
|
|
751
|
+
props.push(`key=${escapeStringForJSX(comp.key)}`);
|
|
752
|
+
// Add tags if present
|
|
753
|
+
if (comp.tags && Array.isArray(comp.tags) && comp.tags.length > 0) {
|
|
754
|
+
props.push(`tags={[${comp.tags.map((t) => `"${t}"`).join(', ')}]}`);
|
|
755
|
+
}
|
|
756
|
+
// Add other props (only include validated props, excluding id, key, tags, type, parent_id, buttons, _location, and ignored properties)
|
|
757
|
+
// _location is excluded because it's derived from the filename (e.g., before_page.location.tsx)
|
|
758
|
+
const excludedProps = new Set([
|
|
759
|
+
'id',
|
|
760
|
+
'key',
|
|
761
|
+
'tags',
|
|
762
|
+
'type',
|
|
763
|
+
'parent_id',
|
|
764
|
+
'buttons',
|
|
765
|
+
'_location',
|
|
766
|
+
...IGNORED_COMPONENT_PROPERTIES,
|
|
767
|
+
]);
|
|
768
|
+
for (const [key, value] of Object.entries(comp)) {
|
|
769
|
+
if (excludedProps.has(key))
|
|
770
|
+
continue;
|
|
771
|
+
// Only include props that were validated as valid
|
|
772
|
+
if (!validPropKeys.has(key))
|
|
773
|
+
continue;
|
|
774
|
+
if (value === null || value === undefined)
|
|
775
|
+
continue;
|
|
776
|
+
if (typeof value === 'string') {
|
|
777
|
+
// Handle style prop: convert CSS string to JSX style object
|
|
778
|
+
if (key === 'style') {
|
|
779
|
+
const styleObj = cssStringToStyleObject(value);
|
|
780
|
+
const styleStr = styleObjectToJSX(styleObj);
|
|
781
|
+
props.push(`style={${styleStr}}`);
|
|
782
|
+
}
|
|
783
|
+
else if (value.includes('{{')) {
|
|
784
|
+
// Handle template variables: convert {{variable}} strings to template literals
|
|
785
|
+
// e.g., "{{first_name}}'s plan" -> {`{{first_name}}'s plan`}
|
|
786
|
+
const templateLiteral = stringToTemplateLiteral(value);
|
|
787
|
+
props.push(`${key}={${templateLiteral}}`);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
props.push(`${key}=${escapeStringForJSX(value)}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
794
|
+
props.push(`${key}={${value}}`);
|
|
795
|
+
}
|
|
796
|
+
else if (Array.isArray(value)) {
|
|
797
|
+
// Handle arrays (e.g., conditions, conditional_tags, outputs_onmounted, etc.)
|
|
798
|
+
// buttons are excluded and handled separately below
|
|
799
|
+
const arrayExpression = valueToJSXExpression(value);
|
|
800
|
+
props.push(`${key}={${arrayExpression}}`);
|
|
801
|
+
}
|
|
802
|
+
else if (typeof value === 'object') {
|
|
803
|
+
// Handle objects (e.g., nested condition objects, etc.)
|
|
804
|
+
const objectExpression = valueToJSXExpression(value);
|
|
805
|
+
props.push(`${key}={${objectExpression}}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// Handle OptionSelector buttons - use the name from the map if available
|
|
809
|
+
if (type === 'OptionSelector' && comp.buttons) {
|
|
810
|
+
const buttons = comp.buttons;
|
|
811
|
+
if (Array.isArray(buttons) && buttons.length > 0) {
|
|
812
|
+
const constName = nameMap?.get(comp.id) || camelCase(comp.key || comp.id) + 'Buttons';
|
|
813
|
+
props.push(`buttons={${constName}}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// Store extra (non-schema) props in a props={...} JSON object for round-trip preservation
|
|
817
|
+
if (Object.keys(extraProps).length > 0) {
|
|
818
|
+
props.push(`props={${valueToJSXExpression(extraProps)}}`);
|
|
819
|
+
}
|
|
820
|
+
// Handle CustomHTML: convert text prop to JSX children
|
|
821
|
+
let customHTMLChildren = null;
|
|
822
|
+
if (type === 'CustomHTML' && comp.text && typeof comp.text === 'string') {
|
|
823
|
+
// Remove text from props (we'll use children instead)
|
|
824
|
+
const textIndex = props.findIndex((p) => p.startsWith('text='));
|
|
825
|
+
if (textIndex !== -1) {
|
|
826
|
+
props.splice(textIndex, 1);
|
|
827
|
+
}
|
|
828
|
+
// Convert HTML to JSX children
|
|
829
|
+
customHTMLChildren = htmlToJSXChildren(comp.text, indent + 2);
|
|
830
|
+
}
|
|
831
|
+
const indentStr = ' '.repeat(indent);
|
|
832
|
+
const childIndent = indent + 2;
|
|
833
|
+
// If CustomHTML has children from HTML conversion, use those
|
|
834
|
+
if (customHTMLChildren !== null) {
|
|
835
|
+
return `${indentStr}<${tagName} ${props.join(' ')}>\n${customHTMLChildren}\n${indentStr}</${tagName}>`;
|
|
836
|
+
}
|
|
837
|
+
if (node.children.length === 0) {
|
|
838
|
+
return `${indentStr}<${tagName} ${props.join(' ')} />`;
|
|
839
|
+
}
|
|
840
|
+
const childrenJSX = node.children
|
|
841
|
+
.map((child) => generateJSX(child, childIndent, pageKey, child.component.id, nameMap))
|
|
842
|
+
.join('\n');
|
|
843
|
+
return `${indentStr}<${tagName} ${props.join(' ')}>\n${childrenJSX}\n${indentStr}</${tagName}>`;
|
|
844
|
+
}
|
|
845
|
+
async function generateStylesFile(styles, embeddableId) {
|
|
846
|
+
try {
|
|
847
|
+
// Convert JSON back to CSS
|
|
848
|
+
// CSSJSON format: { children: { selector: { attributes: {...} } } }
|
|
849
|
+
const cssJson = {
|
|
850
|
+
children: {},
|
|
851
|
+
};
|
|
852
|
+
for (const [selector, attributes] of Object.entries(styles)) {
|
|
853
|
+
cssJson.children[selector] = {
|
|
854
|
+
attributes: attributes,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
const css = CSSJSON.toCSS(cssJson);
|
|
858
|
+
// Write to embeddables/<id>/styles/index.css
|
|
859
|
+
const filePath = path.join('embeddables', embeddableId, 'styles', 'index.css');
|
|
860
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
861
|
+
fs.writeFileSync(filePath, css, 'utf8');
|
|
862
|
+
console.log(`${pc.gray(`Generated ${filePath}`)}`);
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
if (error instanceof Error) {
|
|
866
|
+
throw new Error(`Styles: ${error.message}`);
|
|
867
|
+
}
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Helper functions
|
|
872
|
+
function capitalizeFirst(str) {
|
|
873
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
874
|
+
}
|
|
875
|
+
function camelCase(str) {
|
|
876
|
+
return str
|
|
877
|
+
.replace(/[^a-zA-Z0-9]/g, ' ')
|
|
878
|
+
.split(' ')
|
|
879
|
+
.map((word, index) => {
|
|
880
|
+
if (index === 0) {
|
|
881
|
+
return word.toLowerCase();
|
|
882
|
+
}
|
|
883
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
884
|
+
})
|
|
885
|
+
.join('')
|
|
886
|
+
.replace(/[^a-zA-Z0-9]/g, '');
|
|
887
|
+
}
|
|
888
|
+
function escapeString(str) {
|
|
889
|
+
// Escape backslashes first, then quotes, then all newline variants
|
|
890
|
+
return str
|
|
891
|
+
.replace(/\\/g, '\\\\')
|
|
892
|
+
.replace(/"/g, '\\"')
|
|
893
|
+
.replace(/\r\n/g, '\\n') // Windows line endings
|
|
894
|
+
.replace(/\r/g, '\\n') // Old Mac line endings
|
|
895
|
+
.replace(/\n/g, '\\n'); // Unix line endings
|
|
896
|
+
}
|
|
897
|
+
function escapeStringForJSX(str) {
|
|
898
|
+
// Check if string contains double quotes - if so, use template literals
|
|
899
|
+
const hasDoubleQuotes = str.includes('"');
|
|
900
|
+
if (hasDoubleQuotes) {
|
|
901
|
+
// Use template literals (backticks) - escape backticks, backslashes, and ${
|
|
902
|
+
let escaped = str
|
|
903
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
904
|
+
.replace(/`/g, '\\`') // Escape backticks
|
|
905
|
+
.replace(/\$\{/g, '\\${'); // Escape template literal expressions
|
|
906
|
+
// Escape newlines
|
|
907
|
+
escaped = escaped
|
|
908
|
+
.replace(/\r\n/g, '\\n') // Windows line endings
|
|
909
|
+
.replace(/\r/g, '\\n') // Old Mac line endings
|
|
910
|
+
.replace(/\n/g, '\\n'); // Unix line endings
|
|
911
|
+
return `{\`${escaped}\`}`;
|
|
912
|
+
}
|
|
913
|
+
// No double quotes - use regular quote selection
|
|
914
|
+
const doubleQuoteCount = (str.match(/"/g) || []).length;
|
|
915
|
+
const singleQuoteCount = (str.match(/'/g) || []).length;
|
|
916
|
+
// Escape backslashes first
|
|
917
|
+
let escaped = str.replace(/\\/g, '\\\\');
|
|
918
|
+
// Escape newlines
|
|
919
|
+
escaped = escaped
|
|
920
|
+
.replace(/\r\n/g, '\\n') // Windows line endings
|
|
921
|
+
.replace(/\r/g, '\\n') // Old Mac line endings
|
|
922
|
+
.replace(/\n/g, '\\n'); // Unix line endings
|
|
923
|
+
// Use single quotes if there are more double quotes, otherwise use double quotes
|
|
924
|
+
if (doubleQuoteCount > singleQuoteCount) {
|
|
925
|
+
// Use single quotes and escape single quotes
|
|
926
|
+
escaped = escaped.replace(/'/g, "\\'");
|
|
927
|
+
return `'${escaped}'`;
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
// Use double quotes and escape double quotes
|
|
931
|
+
escaped = escaped.replace(/"/g, '\\"');
|
|
932
|
+
return `"${escaped}"`;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Converts HTML string to JSX children code.
|
|
937
|
+
* This is a simplified parser that handles common HTML cases.
|
|
938
|
+
*/
|
|
939
|
+
function htmlToJSXChildren(html, indent) {
|
|
940
|
+
const indentStr = ' '.repeat(indent);
|
|
941
|
+
const result = [];
|
|
942
|
+
// Simple HTML parser using regex (handles common cases)
|
|
943
|
+
// This is a basic implementation - for production, consider using a proper HTML parser
|
|
944
|
+
let pos = 0;
|
|
945
|
+
const len = html.length;
|
|
946
|
+
while (pos < len) {
|
|
947
|
+
// Skip whitespace at the start
|
|
948
|
+
const whitespaceMatch = html.slice(pos).match(/^\s+/);
|
|
949
|
+
if (whitespaceMatch) {
|
|
950
|
+
pos += whitespaceMatch[0].length;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
// Check for HTML comment and skip it
|
|
954
|
+
if (html.slice(pos).startsWith('<!--')) {
|
|
955
|
+
const commentEnd = html.indexOf('-->', pos);
|
|
956
|
+
if (commentEnd !== -1) {
|
|
957
|
+
pos = commentEnd + 3; // Skip past -->
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
// Malformed comment - skip to end
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Check for HTML tag
|
|
966
|
+
const tagMatch = html.slice(pos).match(/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*)>/);
|
|
967
|
+
if (tagMatch) {
|
|
968
|
+
const isClosing = tagMatch[1] === '/';
|
|
969
|
+
const tagName = tagMatch[2];
|
|
970
|
+
const htmlAttrsStr = tagMatch[3];
|
|
971
|
+
const fullMatch = tagMatch[0];
|
|
972
|
+
if (isClosing) {
|
|
973
|
+
// Closing tag - we'll handle this in the opening tag logic
|
|
974
|
+
pos += fullMatch.length;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
// Check if it's a self-closing tag
|
|
978
|
+
const isSelfClosing = htmlAttrsStr.endsWith('/') ||
|
|
979
|
+
[
|
|
980
|
+
'img',
|
|
981
|
+
'br',
|
|
982
|
+
'hr',
|
|
983
|
+
'input',
|
|
984
|
+
'meta',
|
|
985
|
+
'link',
|
|
986
|
+
'area',
|
|
987
|
+
'base',
|
|
988
|
+
'col',
|
|
989
|
+
'embed',
|
|
990
|
+
'source',
|
|
991
|
+
'track',
|
|
992
|
+
'wbr',
|
|
993
|
+
].includes(tagName.toLowerCase());
|
|
994
|
+
// Parse attributes
|
|
995
|
+
const attrs = [];
|
|
996
|
+
// First, find all attribute names (with or without values)
|
|
997
|
+
// Match: attr, attr=value, attr="value", attr='value'
|
|
998
|
+
// We need to handle attributes without values (like <img alt />) and skip them
|
|
999
|
+
const attrRegex = /(\w+)(?:="([^"]*)"|='([^']*)'|=(.+?)(?=[\s>]|$))?/g;
|
|
1000
|
+
let attrMatch;
|
|
1001
|
+
const seenAttrs = new Set();
|
|
1002
|
+
while ((attrMatch = attrRegex.exec(htmlAttrsStr)) !== null) {
|
|
1003
|
+
const attrName = attrMatch[1];
|
|
1004
|
+
const attrValue = attrMatch[2] || attrMatch[3] || attrMatch[4] || '';
|
|
1005
|
+
// Skip if we've already seen this attribute
|
|
1006
|
+
if (seenAttrs.has(attrName))
|
|
1007
|
+
continue;
|
|
1008
|
+
seenAttrs.add(attrName);
|
|
1009
|
+
// Skip attributes without values (like <img alt />) - these should be removed
|
|
1010
|
+
if (!attrMatch[0].includes('=')) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
// Convert HTML attribute names to JSX (e.g., class -> className, for -> htmlFor)
|
|
1014
|
+
let jsxAttrName = attrName;
|
|
1015
|
+
if (attrName === 'class')
|
|
1016
|
+
jsxAttrName = 'className';
|
|
1017
|
+
else if (attrName === 'for')
|
|
1018
|
+
jsxAttrName = 'htmlFor';
|
|
1019
|
+
else if (attrName === 'tabindex')
|
|
1020
|
+
jsxAttrName = 'tabIndex';
|
|
1021
|
+
// Handle style attribute: convert CSS string to JSX object
|
|
1022
|
+
if (attrName === 'style' && attrValue) {
|
|
1023
|
+
const styleObj = cssStringToStyleObject(attrValue);
|
|
1024
|
+
const styleStr = styleObjectToJSX(styleObj);
|
|
1025
|
+
attrs.push(`style={${styleStr}}`);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
// Skip attributes with empty values (like alt="")
|
|
1029
|
+
if (attrValue === '') {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
// Escape the attribute value for JSX
|
|
1033
|
+
// JSX attribute strings don't support \" escaping like JS strings
|
|
1034
|
+
// So if the value contains double quotes, use single quotes for the outer delimiter
|
|
1035
|
+
const hasDoubleQuotes = attrValue.includes('"');
|
|
1036
|
+
const hasSingleQuotes = attrValue.includes("'");
|
|
1037
|
+
if (hasDoubleQuotes && !hasSingleQuotes) {
|
|
1038
|
+
// Use single quotes - only need to escape backslashes and newlines
|
|
1039
|
+
const escapedValue = attrValue
|
|
1040
|
+
.replace(/\\/g, '\\\\')
|
|
1041
|
+
.replace(/\n/g, '\\n')
|
|
1042
|
+
.replace(/\r/g, '\\r');
|
|
1043
|
+
attrs.push(`${jsxAttrName}='${escapedValue}'`);
|
|
1044
|
+
}
|
|
1045
|
+
else if (hasDoubleQuotes && hasSingleQuotes) {
|
|
1046
|
+
// Value has both quote types - use JSX expression with template literal
|
|
1047
|
+
const escapedValue = attrValue
|
|
1048
|
+
.replace(/\\/g, '\\\\')
|
|
1049
|
+
.replace(/`/g, '\\`')
|
|
1050
|
+
.replace(/\$\{/g, '\\${') // Escape template literal expressions
|
|
1051
|
+
.replace(/\n/g, '\\n')
|
|
1052
|
+
.replace(/\r/g, '\\r');
|
|
1053
|
+
attrs.push(`${jsxAttrName}={\`${escapedValue}\`}`);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
// No double quotes - use double quotes as before
|
|
1057
|
+
const escapedValue = attrValue
|
|
1058
|
+
.replace(/\\/g, '\\\\')
|
|
1059
|
+
.replace(/\n/g, '\\n')
|
|
1060
|
+
.replace(/\r/g, '\\r');
|
|
1061
|
+
attrs.push(`${jsxAttrName}="${escapedValue}"`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
pos += fullMatch.length;
|
|
1065
|
+
// Special handling for <style> and <script> tags: preserve content as raw text
|
|
1066
|
+
const isRawTextTag = tagName.toLowerCase() === 'style' || tagName.toLowerCase() === 'script';
|
|
1067
|
+
// Find the content and closing tag for non-self-closing tags
|
|
1068
|
+
if (!isSelfClosing) {
|
|
1069
|
+
// Find matching closing tag
|
|
1070
|
+
const closingTag = `</${tagName}>`;
|
|
1071
|
+
let depth = 1;
|
|
1072
|
+
let searchPos = pos;
|
|
1073
|
+
let contentEnd = -1;
|
|
1074
|
+
while (depth > 0 && searchPos < len) {
|
|
1075
|
+
const nextOpen = html.indexOf(`<${tagName}`, searchPos);
|
|
1076
|
+
const nextClose = html.indexOf(closingTag, searchPos);
|
|
1077
|
+
if (nextClose === -1) {
|
|
1078
|
+
// No closing tag found - treat as self-closing
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
1082
|
+
// Found nested opening tag
|
|
1083
|
+
depth++;
|
|
1084
|
+
searchPos = nextOpen + 1;
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
// Found closing tag
|
|
1088
|
+
depth--;
|
|
1089
|
+
if (depth === 0) {
|
|
1090
|
+
contentEnd = nextClose;
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
searchPos = nextClose + closingTag.length;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (contentEnd !== -1) {
|
|
1098
|
+
const content = html.slice(pos, contentEnd);
|
|
1099
|
+
const attrsStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
|
|
1100
|
+
if (isRawTextTag) {
|
|
1101
|
+
// For <style> and <script>, preserve content as raw text without processing
|
|
1102
|
+
// In JSX, this content must be wrapped in a template literal string expression
|
|
1103
|
+
const rawContent = content;
|
|
1104
|
+
if (rawContent.trim()) {
|
|
1105
|
+
// Escape backticks and ${ in the content to prevent template literal issues
|
|
1106
|
+
const escapedContent = rawContent
|
|
1107
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
1108
|
+
.replace(/`/g, '\\`') // Escape backticks
|
|
1109
|
+
.replace(/\${/g, '\\${'); // Escape ${ to prevent interpolation
|
|
1110
|
+
// Output as a template literal wrapped in curly braces for JSX
|
|
1111
|
+
result.push(`${indentStr}<${tagName}${attrsStr}>{`);
|
|
1112
|
+
result.push(`${indentStr} \`${escapedContent}\``);
|
|
1113
|
+
result.push(`${indentStr}}</${tagName}>`);
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
result.push(`${indentStr}<${tagName}${attrsStr} />`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
// For other tags, process children recursively
|
|
1121
|
+
const childrenJSX = htmlToJSXChildren(content, indent + 2);
|
|
1122
|
+
if (childrenJSX.trim()) {
|
|
1123
|
+
result.push(`${indentStr}<${tagName}${attrsStr}>`);
|
|
1124
|
+
result.push(childrenJSX);
|
|
1125
|
+
result.push(`${indentStr}</${tagName}>`);
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
result.push(`${indentStr}<${tagName}${attrsStr} />`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
pos = contentEnd + closingTag.length;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Self-closing tag or no closing tag found
|
|
1136
|
+
const attrsStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
|
|
1137
|
+
result.push(`${indentStr}<${tagName}${attrsStr} />`);
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
// Text content
|
|
1141
|
+
const textEnd = html.indexOf('<', pos);
|
|
1142
|
+
if (textEnd === -1) {
|
|
1143
|
+
// Rest is text
|
|
1144
|
+
const text = html.slice(pos).trim();
|
|
1145
|
+
if (text) {
|
|
1146
|
+
// Unescape HTML entities
|
|
1147
|
+
const unescapedText = text
|
|
1148
|
+
.replace(/&/g, '&')
|
|
1149
|
+
.replace(/</g, '<')
|
|
1150
|
+
.replace(/>/g, '>')
|
|
1151
|
+
.replace(/"/g, '"')
|
|
1152
|
+
.replace(/'/g, "'")
|
|
1153
|
+
.replace(/ /g, ' ');
|
|
1154
|
+
// Check if text contains template variables
|
|
1155
|
+
if (unescapedText.includes('{{')) {
|
|
1156
|
+
// Convert to template literal expression
|
|
1157
|
+
const templateLiteral = stringToTemplateLiteral(unescapedText);
|
|
1158
|
+
result.push(`${indentStr}{${templateLiteral}}`);
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
// Escape special characters for JSX text content
|
|
1162
|
+
const escapedText = escapeTextForJSX(unescapedText);
|
|
1163
|
+
result.push(`${indentStr}${escapedText}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
break;
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
const text = html.slice(pos, textEnd).trim();
|
|
1170
|
+
if (text) {
|
|
1171
|
+
// Unescape HTML entities
|
|
1172
|
+
const unescapedText = text
|
|
1173
|
+
.replace(/&/g, '&')
|
|
1174
|
+
.replace(/</g, '<')
|
|
1175
|
+
.replace(/>/g, '>')
|
|
1176
|
+
.replace(/"/g, '"')
|
|
1177
|
+
.replace(/'/g, "'")
|
|
1178
|
+
.replace(/ /g, ' ');
|
|
1179
|
+
// Check if text contains template variables
|
|
1180
|
+
if (unescapedText.includes('{{')) {
|
|
1181
|
+
// Convert to template literal expression
|
|
1182
|
+
const templateLiteral = stringToTemplateLiteral(unescapedText);
|
|
1183
|
+
result.push(`${indentStr}{${templateLiteral}}`);
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
// Escape special characters for JSX text content
|
|
1187
|
+
const escapedText = escapeTextForJSX(unescapedText);
|
|
1188
|
+
result.push(`${indentStr}${escapedText}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
pos = textEnd;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return result.join('\n');
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Converts a CSS string to a style object.
|
|
1198
|
+
* Example: "margin: 0; padding: 0; color: #171717;" -> { margin: 0, padding: 0, color: "#171717" }
|
|
1199
|
+
*/
|
|
1200
|
+
function cssStringToStyleObject(cssString) {
|
|
1201
|
+
const styleObj = {};
|
|
1202
|
+
// Normalize the CSS string: remove newlines, extra spaces, and trim
|
|
1203
|
+
const normalized = cssString
|
|
1204
|
+
.replace(/\r\n/g, ' ')
|
|
1205
|
+
.replace(/\r/g, ' ')
|
|
1206
|
+
.replace(/\n/g, ' ')
|
|
1207
|
+
.replace(/\s+/g, ' ')
|
|
1208
|
+
.trim();
|
|
1209
|
+
// Split by semicolon and process each declaration
|
|
1210
|
+
const declarations = normalized.split(';').filter((decl) => decl.trim().length > 0);
|
|
1211
|
+
for (const decl of declarations) {
|
|
1212
|
+
const colonIndex = decl.indexOf(':');
|
|
1213
|
+
if (colonIndex === -1)
|
|
1214
|
+
continue;
|
|
1215
|
+
const key = decl.slice(0, colonIndex).trim();
|
|
1216
|
+
const value = decl.slice(colonIndex + 1).trim();
|
|
1217
|
+
if (key && value) {
|
|
1218
|
+
// Convert kebab-case to camelCase (e.g., line-height -> lineHeight)
|
|
1219
|
+
const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1220
|
+
styleObj[camelKey] = value;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return styleObj;
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Converts a style object to JSX style expression string.
|
|
1227
|
+
* Example: { margin: 0, padding: 0, color: "#171717" } -> "{ margin: 0, padding: 0, color: \"#171717\" }"
|
|
1228
|
+
*/
|
|
1229
|
+
function styleObjectToJSX(styleObj) {
|
|
1230
|
+
const entries = [];
|
|
1231
|
+
for (const [key, value] of Object.entries(styleObj)) {
|
|
1232
|
+
// Convert camelCase to camelCase (already in camelCase from cssStringToStyleObject)
|
|
1233
|
+
// Value might be a number string or a string that needs quotes
|
|
1234
|
+
let jsxValue;
|
|
1235
|
+
if (/^-?\d+(\.\d+)?$/.test(value.trim())) {
|
|
1236
|
+
// It's a number (including decimals)
|
|
1237
|
+
jsxValue = value.trim();
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
// It's a string, wrap in quotes and escape
|
|
1241
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1242
|
+
jsxValue = `"${escaped}"`;
|
|
1243
|
+
}
|
|
1244
|
+
entries.push(`${key}: ${jsxValue}`);
|
|
1245
|
+
}
|
|
1246
|
+
return `{ ${entries.join(', ')} }`;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Escapes HTML/JSX special characters in text content for JSX.
|
|
1250
|
+
* This prevents characters like <, >, and & from being interpreted as JSX tags or entities.
|
|
1251
|
+
* Example: "< 18.5" -> "< 18.5"
|
|
1252
|
+
*/
|
|
1253
|
+
function escapeTextForJSX(text) {
|
|
1254
|
+
// Escape special characters that would break JSX/HTML
|
|
1255
|
+
// Order matters: escape & first to avoid double-escaping
|
|
1256
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Converts a value to a JSX expression string.
|
|
1260
|
+
* Handles arrays, objects, primitives, etc.
|
|
1261
|
+
*/
|
|
1262
|
+
function valueToJSXExpression(value) {
|
|
1263
|
+
if (value === null || value === undefined) {
|
|
1264
|
+
return 'null';
|
|
1265
|
+
}
|
|
1266
|
+
if (typeof value === 'string') {
|
|
1267
|
+
return escapeStringForJS(value);
|
|
1268
|
+
}
|
|
1269
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1270
|
+
return String(value);
|
|
1271
|
+
}
|
|
1272
|
+
if (Array.isArray(value)) {
|
|
1273
|
+
if (value.length === 0) {
|
|
1274
|
+
return '[]';
|
|
1275
|
+
}
|
|
1276
|
+
const items = value.map((item) => valueToJSXExpression(item)).join(', ');
|
|
1277
|
+
return `[${items}]`;
|
|
1278
|
+
}
|
|
1279
|
+
if (typeof value === 'object') {
|
|
1280
|
+
const entries = [];
|
|
1281
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1282
|
+
// Skip null/undefined values
|
|
1283
|
+
if (val === null || val === undefined)
|
|
1284
|
+
continue;
|
|
1285
|
+
// Convert key to valid JS identifier or string literal
|
|
1286
|
+
const jsKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : escapeStringForJS(key);
|
|
1287
|
+
entries.push(`${jsKey}: ${valueToJSXExpression(val)}`);
|
|
1288
|
+
}
|
|
1289
|
+
return `{ ${entries.join(', ')} }`;
|
|
1290
|
+
}
|
|
1291
|
+
// Fallback: convert to string
|
|
1292
|
+
return escapeStringForJS(String(value));
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Converts a string with {{variable}} syntax to a template literal expression.
|
|
1296
|
+
* Example: "{{first_name}}'s plan" -> "`{{first_name}}'s plan`"
|
|
1297
|
+
*/
|
|
1298
|
+
function stringToTemplateLiteral(str) {
|
|
1299
|
+
// Escape backticks and backslashes for template literal
|
|
1300
|
+
const escaped = str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${'); // Escape ${ to prevent interpolation
|
|
1301
|
+
return '`' + escaped + '`';
|
|
1302
|
+
}
|
|
1303
|
+
function escapeStringForJS(str) {
|
|
1304
|
+
// Check if string contains double quotes - if so, use template literals
|
|
1305
|
+
const hasDoubleQuotes = str.includes('"');
|
|
1306
|
+
if (hasDoubleQuotes) {
|
|
1307
|
+
// Use template literals (backticks) - escape backticks and backslashes
|
|
1308
|
+
let escaped = str
|
|
1309
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
1310
|
+
.replace(/`/g, '\\`'); // Escape backticks
|
|
1311
|
+
// Escape newlines
|
|
1312
|
+
escaped = escaped
|
|
1313
|
+
.replace(/\r\n/g, '\\n') // Windows line endings
|
|
1314
|
+
.replace(/\r/g, '\\n') // Old Mac line endings
|
|
1315
|
+
.replace(/\n/g, '\\n'); // Unix line endings
|
|
1316
|
+
return `\`${escaped}\``;
|
|
1317
|
+
}
|
|
1318
|
+
// No double quotes - use regular quote selection
|
|
1319
|
+
const doubleQuoteCount = (str.match(/"/g) || []).length;
|
|
1320
|
+
const singleQuoteCount = (str.match(/'/g) || []).length;
|
|
1321
|
+
// Escape backslashes first
|
|
1322
|
+
let escaped = str.replace(/\\/g, '\\\\');
|
|
1323
|
+
// Escape newlines
|
|
1324
|
+
escaped = escaped
|
|
1325
|
+
.replace(/\r\n/g, '\\n') // Windows line endings
|
|
1326
|
+
.replace(/\r/g, '\\n') // Old Mac line endings
|
|
1327
|
+
.replace(/\n/g, '\\n'); // Unix line endings
|
|
1328
|
+
// Use single quotes if there are more double quotes, otherwise use double quotes
|
|
1329
|
+
if (doubleQuoteCount > singleQuoteCount) {
|
|
1330
|
+
// Use single quotes and escape single quotes
|
|
1331
|
+
escaped = escaped.replace(/'/g, "\\'");
|
|
1332
|
+
return `'${escaped}'`;
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
// Use double quotes and escape double quotes
|
|
1336
|
+
escaped = escaped.replace(/"/g, '\\"');
|
|
1337
|
+
return `"${escaped}"`;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Generates config.json file from embeddable metadata.
|
|
1342
|
+
* This file controls page ordering and stores embeddable-level metadata.
|
|
1343
|
+
* Preserves the order of top-level properties from embeddable.json.
|
|
1344
|
+
*/
|
|
1345
|
+
async function generateConfigFile(embeddable, embeddableId) {
|
|
1346
|
+
try {
|
|
1347
|
+
const configPath = path.join('embeddables', embeddableId, 'config.json');
|
|
1348
|
+
// Preserve the order of top-level properties from embeddable
|
|
1349
|
+
const embeddableKeys = Object.keys(embeddable);
|
|
1350
|
+
// Extract page metadata and order (excluding components which are in TSX files)
|
|
1351
|
+
const pages = embeddable.pages.map((page) => {
|
|
1352
|
+
const pageMetadata = {
|
|
1353
|
+
key: page.key,
|
|
1354
|
+
};
|
|
1355
|
+
// Include page-level metadata (excluding components which are in TSX files)
|
|
1356
|
+
// Preserve property order from the page object recursively
|
|
1357
|
+
const pageKeys = Object.keys(page);
|
|
1358
|
+
const excludePageProps = new Set(['components']);
|
|
1359
|
+
for (const key of pageKeys) {
|
|
1360
|
+
if (!excludePageProps.has(key) && key !== 'key') {
|
|
1361
|
+
const value = page[key];
|
|
1362
|
+
if (value !== undefined && value !== null) {
|
|
1363
|
+
pageMetadata[key] = deepClonePreservingOrder(value);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return pageMetadata;
|
|
1368
|
+
});
|
|
1369
|
+
// Extract computedFields metadata (without code, as code is in JS files)
|
|
1370
|
+
// Preserve order recursively in metadata objects
|
|
1371
|
+
const computedFieldsMetadata = [];
|
|
1372
|
+
if (embeddable.computedFields && Array.isArray(embeddable.computedFields)) {
|
|
1373
|
+
for (const field of embeddable.computedFields) {
|
|
1374
|
+
const cloned = deepClonePreservingOrder(field);
|
|
1375
|
+
const { code, ...metadata } = cloned;
|
|
1376
|
+
computedFieldsMetadata.push(metadata);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
// Extract dataOutputs metadata (without code, as code is in JS files)
|
|
1380
|
+
// Preserve order recursively in metadata objects
|
|
1381
|
+
const dataOutputsMetadata = [];
|
|
1382
|
+
if (embeddable.dataOutputs && Array.isArray(embeddable.dataOutputs)) {
|
|
1383
|
+
for (const action of embeddable.dataOutputs) {
|
|
1384
|
+
const cloned = deepClonePreservingOrder(action);
|
|
1385
|
+
const { code, ...metadata } = cloned;
|
|
1386
|
+
dataOutputsMetadata.push(metadata);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
// Build config object preserving the order from embeddable
|
|
1390
|
+
const config = {};
|
|
1391
|
+
const excludeProps = new Set(['pages', 'styles', 'computedFields', 'dataOutputs', 'components']);
|
|
1392
|
+
// Iterate through embeddable keys in order to preserve property order
|
|
1393
|
+
for (const key of embeddableKeys) {
|
|
1394
|
+
if (key === 'pages') {
|
|
1395
|
+
// Replace pages with page metadata (without components)
|
|
1396
|
+
config.pages = pages;
|
|
1397
|
+
}
|
|
1398
|
+
else if (key === 'styles') {
|
|
1399
|
+
// Skip styles (they're in CSS files)
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
else if (key === 'computedFields') {
|
|
1403
|
+
// Replace computedFields with metadata (without code)
|
|
1404
|
+
if (computedFieldsMetadata.length > 0) {
|
|
1405
|
+
config.computedFields = computedFieldsMetadata;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
else if (key === 'dataOutputs') {
|
|
1409
|
+
// Replace dataOutputs with metadata (without code)
|
|
1410
|
+
if (dataOutputsMetadata.length > 0) {
|
|
1411
|
+
config.dataOutputs = dataOutputsMetadata;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
else if (key === 'components') {
|
|
1415
|
+
// Skip components (they're in TSX files in global-components/)
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
// Regular metadata property - preserve as-is, with order preserved recursively
|
|
1420
|
+
const value = embeddable[key];
|
|
1421
|
+
if (value !== undefined && value !== null) {
|
|
1422
|
+
config[key] = deepClonePreservingOrder(value);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
// Ensure id is set if it wasn't already
|
|
1427
|
+
if (!config.id) {
|
|
1428
|
+
config.id = embeddableId;
|
|
1429
|
+
}
|
|
1430
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1431
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
1432
|
+
console.log(`${pc.gray(`Generated ${configPath}`)}`);
|
|
1433
|
+
}
|
|
1434
|
+
catch (error) {
|
|
1435
|
+
if (error instanceof Error) {
|
|
1436
|
+
throw new Error(`Config: ${error.message}`);
|
|
1437
|
+
}
|
|
1438
|
+
throw error;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Extracts computedFields to JS files in computed-fields/ folder.
|
|
1443
|
+
* Each computedField with code is saved as a separate JS file.
|
|
1444
|
+
*/
|
|
1445
|
+
async function extractComputedFields(computedFields, embeddableId) {
|
|
1446
|
+
const computedFieldsDir = path.join('embeddables', embeddableId, 'computed-fields');
|
|
1447
|
+
fs.mkdirSync(computedFieldsDir, { recursive: true });
|
|
1448
|
+
for (const field of computedFields) {
|
|
1449
|
+
if (!field.code) {
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
// Try using key or id as identifier
|
|
1453
|
+
const identifier = field.key || field.id;
|
|
1454
|
+
if (!identifier) {
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
const fileName = `${sanitizeFileName(identifier)}.js`;
|
|
1458
|
+
const filePath = path.join(computedFieldsDir, fileName);
|
|
1459
|
+
// Write the code as-is (it should be valid JavaScript)
|
|
1460
|
+
fs.writeFileSync(filePath, field.code, 'utf8');
|
|
1461
|
+
console.log(`${pc.gray(`Generated ${filePath}`)}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Extracts dataOutputs (actions) to JS files in actions/ folder.
|
|
1466
|
+
* Each action with code is saved as a separate JS file.
|
|
1467
|
+
*/
|
|
1468
|
+
async function extractDataOutputs(dataOutputs, embeddableId) {
|
|
1469
|
+
const actionsDir = path.join('embeddables', embeddableId, 'actions');
|
|
1470
|
+
fs.mkdirSync(actionsDir, { recursive: true });
|
|
1471
|
+
for (const action of dataOutputs) {
|
|
1472
|
+
if (!action.code) {
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
// Use name as the filename, fallback to id if name doesn't exist
|
|
1476
|
+
const identifier = action.name || action.id;
|
|
1477
|
+
if (!identifier) {
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
const fileName = `${sanitizeFileName(identifier)}.js`;
|
|
1481
|
+
const filePath = path.join(actionsDir, fileName);
|
|
1482
|
+
// Write the code as-is (it should be valid JavaScript)
|
|
1483
|
+
fs.writeFileSync(filePath, action.code, 'utf8');
|
|
1484
|
+
console.log(`${pc.gray(`Generated ${filePath}`)}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Helper function to recursively find the _location of a component.
|
|
1489
|
+
* If the component has a parent_id, it checks the parent's location recursively.
|
|
1490
|
+
* Returns undefined if no location is found in the component or any parent.
|
|
1491
|
+
*/
|
|
1492
|
+
function findComponentLocation(component, componentMap) {
|
|
1493
|
+
if (component._location) {
|
|
1494
|
+
return component._location;
|
|
1495
|
+
}
|
|
1496
|
+
if (component.parent_id) {
|
|
1497
|
+
const parent = componentMap.get(component.parent_id);
|
|
1498
|
+
if (parent) {
|
|
1499
|
+
return findComponentLocation(parent, componentMap);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return undefined;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Extracts global components to TSX files in global-components/ folder.
|
|
1506
|
+
* Components are grouped by their _location (for root components without parent_id)
|
|
1507
|
+
* or by their parent's location (for child components).
|
|
1508
|
+
* File naming: <location>.location.tsx (e.g., before_page.location.tsx)
|
|
1509
|
+
*/
|
|
1510
|
+
async function extractGlobalComponents(components, embeddableId, fix) {
|
|
1511
|
+
const globalComponentsDir = path.join('embeddables', embeddableId, 'global-components');
|
|
1512
|
+
fs.mkdirSync(globalComponentsDir, { recursive: true });
|
|
1513
|
+
if (components.length === 0) {
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
// Create a map of id -> component for location resolution
|
|
1517
|
+
const componentMap = new Map();
|
|
1518
|
+
for (const comp of components) {
|
|
1519
|
+
componentMap.set(comp.id, comp);
|
|
1520
|
+
}
|
|
1521
|
+
// Group components by location
|
|
1522
|
+
// Components without parent_id must have _location and go in their location file
|
|
1523
|
+
// Components with parent_id inherit location from parent and go in parent's location file
|
|
1524
|
+
const componentsByLocation = new Map();
|
|
1525
|
+
for (const component of components) {
|
|
1526
|
+
const location = findComponentLocation(component, componentMap);
|
|
1527
|
+
// Components without parent_id must have _location
|
|
1528
|
+
if (!component.parent_id && !location) {
|
|
1529
|
+
if (fix) {
|
|
1530
|
+
console.warn(`Removed global component "${component.id}" (key: "${component.key}") – missing _location (no parent_id).`);
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
throw new Error(`Global component "${component.id}" (key: "${component.key}") must have _location since it has no parent_id.`);
|
|
1534
|
+
}
|
|
1535
|
+
if (location) {
|
|
1536
|
+
if (!componentsByLocation.has(location)) {
|
|
1537
|
+
componentsByLocation.set(location, []);
|
|
1538
|
+
}
|
|
1539
|
+
componentsByLocation.get(location).push(component);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
// Generate TSX file for each location
|
|
1543
|
+
for (const [location, locationComponents] of componentsByLocation) {
|
|
1544
|
+
const fileName = `${location}.location.tsx`;
|
|
1545
|
+
const filePath = path.join(globalComponentsDir, fileName);
|
|
1546
|
+
// Build tree from flat component list (similar to buildTree for pages)
|
|
1547
|
+
const tree = buildTreeForGlobalComponents(locationComponents, fix);
|
|
1548
|
+
// Generate TSX code
|
|
1549
|
+
const tsx = generateGlobalComponentTSX(locationComponents, tree);
|
|
1550
|
+
// Write to file
|
|
1551
|
+
fs.writeFileSync(filePath, tsx, 'utf8');
|
|
1552
|
+
console.log(`${pc.gray(`Generated ${filePath}`)}`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Builds a component tree from a flat list of components (similar to buildTree for pages).
|
|
1557
|
+
*/
|
|
1558
|
+
function buildTreeForGlobalComponents(components, fix) {
|
|
1559
|
+
// Filter out components without type and ignored component types
|
|
1560
|
+
let filteredComponents = components.filter((comp) => {
|
|
1561
|
+
if (!comp.type) {
|
|
1562
|
+
if (fix) {
|
|
1563
|
+
console.warn(`Removed global component (id: ${comp.id}, key: ${comp.key}) – missing type property.`);
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
throw new Error(`Global component (id: ${comp.id}, key: ${comp.key}) is missing required type property.`);
|
|
1567
|
+
}
|
|
1568
|
+
return !IGNORED_COMPONENT_TYPES.has(comp.type);
|
|
1569
|
+
});
|
|
1570
|
+
// Filter out components missing required props
|
|
1571
|
+
filteredComponents = filteredComponents.filter((comp) => {
|
|
1572
|
+
return hasRequiredProps(comp, fix);
|
|
1573
|
+
});
|
|
1574
|
+
// Create a map of id -> component
|
|
1575
|
+
const componentMap = new Map();
|
|
1576
|
+
for (const comp of filteredComponents) {
|
|
1577
|
+
componentMap.set(comp.id, comp);
|
|
1578
|
+
}
|
|
1579
|
+
// Find root components (no parent_id)
|
|
1580
|
+
const roots = [];
|
|
1581
|
+
for (const comp of filteredComponents) {
|
|
1582
|
+
if (!comp.parent_id) {
|
|
1583
|
+
roots.push(comp);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// Allow empty locations - return null if no components
|
|
1587
|
+
if (roots.length === 0) {
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
// Build tree recursively for each root
|
|
1591
|
+
const rootNodes = roots.map((root) => buildNode(root, componentMap, '', fix));
|
|
1592
|
+
// Return single node or array of nodes
|
|
1593
|
+
return rootNodes.length === 1 ? rootNodes[0] : rootNodes;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Generates TSX code for global components (similar to generateTSX for pages).
|
|
1597
|
+
*/
|
|
1598
|
+
function generateGlobalComponentTSX(components, tree) {
|
|
1599
|
+
const imports = tree ? collectImports(tree) : [];
|
|
1600
|
+
const hasButtons = tree ? hasButtonConstants(tree) : false;
|
|
1601
|
+
const { constants, nameMap } = tree
|
|
1602
|
+
? generateButtonConstants(tree)
|
|
1603
|
+
: { constants: '', nameMap: new Map() };
|
|
1604
|
+
const jsx = generateJSXFromTree(tree, 2, undefined, nameMap);
|
|
1605
|
+
return `"use client";
|
|
1606
|
+
|
|
1607
|
+
import React from "react";
|
|
1608
|
+
${imports
|
|
1609
|
+
.map((imp) => `import { ${imp} } from "../../../src/components/primitives/${imp}";`)
|
|
1610
|
+
.join('\n')}
|
|
1611
|
+
${hasButtons
|
|
1612
|
+
? `
|
|
1613
|
+
import type { OptionSelectorButton } from "../../../src/types-builder";`
|
|
1614
|
+
: ''}
|
|
1615
|
+
|
|
1616
|
+
export default function GlobalComponents() {
|
|
1617
|
+
${constants}
|
|
1618
|
+
return (
|
|
1619
|
+
${jsx}
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
`;
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Sanitizes a string to be safe for use as a filename.
|
|
1626
|
+
*/
|
|
1627
|
+
export function sanitizeFileName(str) {
|
|
1628
|
+
return str
|
|
1629
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1630
|
+
.replace(/^_+|_+$/g, '')
|
|
1631
|
+
.replace(/_{2,}/g, '_');
|
|
1632
|
+
}
|