@embeddables/cli 0.5.1 → 0.6.1

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.
@@ -6,6 +6,7 @@ import { CompileError } from './errors.js';
6
6
  import CSSJSON from 'cssjson';
7
7
  import { sanitizeFileName } from './reverse.js';
8
8
  import { generateRandomIdByType } from './helpers/duplicateIds.js';
9
+ import { normalizeKeyIfStartsWithDigit, keyOrIdStartsWithDigit, } from './helpers/numericLeadingKeys.js';
9
10
  import { parse } from '@babel/parser';
10
11
  import traverseImport from '@babel/traverse';
11
12
  import * as generator from '@babel/generator';
@@ -103,16 +104,13 @@ export async function compileAllPages(opts) {
103
104
  if (files.length === 0) {
104
105
  throw new CompileError(`No pages found for glob: ${opts.pagesGlob}`);
105
106
  }
106
- // First pass: collect all IDs from pages and detect duplicates
107
- // Note: We check pages first, then global components are checked separately in loadGlobalComponents
107
+ // First pass: parse and collect lint issues (duplicate IDs, keys/IDs starting with a digit)
108
108
  const idOccurrences = new Map();
109
109
  const filePages = [];
110
- // Parse all page files first to collect IDs
111
110
  for (const file of files) {
112
111
  const code = fs.readFileSync(file, 'utf8');
113
112
  const pageKey = derivePageKey(file, opts.pageKeyFrom);
114
113
  const page = parsePageFromFile({ code, filePath: file, pageKey });
115
- // Collect component and button IDs with their locations
116
114
  for (let compIndex = 0; compIndex < page.components.length; compIndex++) {
117
115
  const component = page.components[compIndex];
118
116
  const compId = component.id;
@@ -126,7 +124,6 @@ export async function compileAllPages(opts) {
126
124
  componentIndex: compIndex,
127
125
  id: compId,
128
126
  });
129
- // Collect button IDs from OptionSelector components
130
127
  if (component.type === 'OptionSelector' && Array.isArray(component.buttons)) {
131
128
  for (let btnIndex = 0; btnIndex < component.buttons.length; btnIndex++) {
132
129
  const button = component.buttons[btnIndex];
@@ -149,20 +146,16 @@ export async function compileAllPages(opts) {
149
146
  }
150
147
  filePages.push({ file, pageKey, page });
151
148
  }
152
- // Detect duplicates and generate fixes
153
- const idFixes = new Map(); // Maps "file:componentIndex" or "file:componentIndex:buttonIndex" -> newId
149
+ // Collect duplicate ID fixes (do not apply yet)
150
+ const idFixes = new Map();
154
151
  const allIds = new Set();
155
- // Collect all existing IDs
156
152
  for (const occurrences of idOccurrences.values()) {
157
153
  for (const occ of occurrences) {
158
154
  allIds.add(occ.id);
159
155
  }
160
156
  }
161
- // Find duplicates and generate unique replacements
162
157
  for (const [id, occurrences] of idOccurrences.entries()) {
163
158
  if (occurrences.length > 1) {
164
- console.warn(`Found duplicate ID "${id}" used ${occurrences.length} times. Fixing...`);
165
- // Keep first occurrence, fix the rest (type-based: plaintext_xxx, button_xxx, option_xxx)
166
159
  for (let i = 1; i < occurrences.length; i++) {
167
160
  const occ = occurrences[i];
168
161
  const fixKey = occ.buttonIndex !== undefined
@@ -172,65 +165,154 @@ export async function compileAllPages(opts) {
172
165
  const newId = generateRandomIdByType(occ.componentType, isOptionButton, allIds, idFixes.values());
173
166
  allIds.add(newId);
174
167
  idFixes.set(fixKey, newId);
175
- console.warn(` → Fixing duplicate ID "${id}" to "${newId}" in ${occ.file} (component ${occ.componentIndex}${occ.buttonIndex !== undefined ? `, button ${occ.buttonIndex}` : ''})`);
176
168
  }
177
169
  }
178
170
  }
179
- // Apply fixes to source files if any duplicates were found
180
- if (idFixes.size > 0) {
181
- // Fix page files
171
+ // Collect numeric-leading key/id fixes (component key, component id, button key, button id)
172
+ const numericLeadingFixes = [];
173
+ for (const { file, pageKey, page } of filePages) {
174
+ for (let compIndex = 0; compIndex < page.components.length; compIndex++) {
175
+ const comp = page.components[compIndex];
176
+ if (keyOrIdStartsWithDigit(comp.key)) {
177
+ const newKey = normalizeKeyIfStartsWithDigit(comp.key, pageKey);
178
+ if (newKey)
179
+ numericLeadingFixes.push({
180
+ file,
181
+ componentIndex: compIndex,
182
+ kind: 'componentKey',
183
+ oldValue: comp.key,
184
+ newValue: newKey,
185
+ });
186
+ }
187
+ if (keyOrIdStartsWithDigit(comp.id)) {
188
+ const newId = normalizeKeyIfStartsWithDigit(comp.id, 'comp');
189
+ if (newId)
190
+ numericLeadingFixes.push({
191
+ file,
192
+ componentIndex: compIndex,
193
+ kind: 'componentId',
194
+ oldValue: comp.id,
195
+ newValue: newId,
196
+ });
197
+ }
198
+ if (comp.type === 'OptionSelector' && Array.isArray(comp.buttons)) {
199
+ const prefix = comp.key ?? 'option';
200
+ for (let btnIndex = 0; btnIndex < comp.buttons.length; btnIndex++) {
201
+ const btn = comp.buttons[btnIndex];
202
+ if (btn && keyOrIdStartsWithDigit(btn.key)) {
203
+ const newKey = normalizeKeyIfStartsWithDigit(btn.key, prefix);
204
+ if (newKey)
205
+ numericLeadingFixes.push({
206
+ file,
207
+ componentIndex: compIndex,
208
+ buttonIndex: btnIndex,
209
+ kind: 'buttonKey',
210
+ oldValue: btn.key,
211
+ newValue: newKey,
212
+ });
213
+ }
214
+ if (btn && keyOrIdStartsWithDigit(btn.id)) {
215
+ const safeKey = normalizeKeyIfStartsWithDigit(btn.key, prefix);
216
+ const newId = btn.key != null && btn.id === `option_${btn.key}`
217
+ ? safeKey != null
218
+ ? `option_${safeKey}`
219
+ : normalizeKeyIfStartsWithDigit(btn.id, 'option')
220
+ : normalizeKeyIfStartsWithDigit(btn.id, 'option');
221
+ if (newId)
222
+ numericLeadingFixes.push({
223
+ file,
224
+ componentIndex: compIndex,
225
+ buttonIndex: btnIndex,
226
+ kind: 'buttonId',
227
+ oldValue: btn.id,
228
+ newValue: newId,
229
+ });
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }
235
+ const totalLintIssues = idFixes.size + numericLeadingFixes.length;
236
+ let shouldApplyFixes = false;
237
+ if (totalLintIssues > 0) {
238
+ // Report all issues
239
+ if (idFixes.size > 0) {
240
+ console.warn(`Found ${idFixes.size} duplicate ID(s). Keys and IDs must be unique across the embeddable.`);
241
+ for (const [fixKey, newId] of idFixes) {
242
+ const parts = fixKey.split(':');
243
+ const file = parts[0];
244
+ const compIdx = parts[1];
245
+ const btnIdx = parts[2];
246
+ const loc = btnIdx !== undefined ? `component ${compIdx}, button ${btnIdx}` : `component ${compIdx}`;
247
+ console.warn(` → ${file}: ${loc} → "${newId}"`);
248
+ }
249
+ }
250
+ if (numericLeadingFixes.length > 0) {
251
+ console.warn(`Found ${numericLeadingFixes.length} key(s)/ID(s) that start with a number. Use a prefix (e.g. range_1_to_2_weeks).`);
252
+ for (const f of numericLeadingFixes) {
253
+ const loc = f.buttonIndex !== undefined
254
+ ? `component ${f.componentIndex}, button ${f.buttonIndex} (${f.kind})`
255
+ : `component ${f.componentIndex} (${f.kind})`;
256
+ console.warn(` → ${f.file}: "${f.oldValue}" → "${f.newValue}" (${loc})`);
257
+ }
258
+ }
259
+ if (opts.fixLint === true) {
260
+ shouldApplyFixes = true;
261
+ }
262
+ else if (opts.fixLint === 'prompt') {
263
+ const { default: prompts } = await import('prompts');
264
+ const res = await prompts({
265
+ type: 'confirm',
266
+ name: 'apply',
267
+ message: `Apply ${totalLintIssues} fix(es) to source files?`,
268
+ initial: true,
269
+ });
270
+ shouldApplyFixes = res.apply === true;
271
+ }
272
+ if (!shouldApplyFixes) {
273
+ throw new CompileError(`Build failed: ${totalLintIssues} lint issue(s) (duplicate IDs and/or keys/IDs starting with a number). Run with --fix or answer 'y' when prompted to fix.`, { file: files[0] });
274
+ }
275
+ // Apply fixes
182
276
  for (const { file } of filePages) {
183
- const fixesForFile = Array.from(idFixes.entries()).filter(([key]) => key.startsWith(file + ':'));
184
- if (fixesForFile.length > 0) {
185
- const code = fs.readFileSync(file, 'utf8');
186
- const updatedCode = fixIdsInSourceFile(code, file, fixesForFile);
187
- fs.writeFileSync(file, updatedCode, 'utf8');
188
- console.log(` ✓ Updated ${file} with fixed IDs`);
277
+ const idFixesForFile = Array.from(idFixes.entries()).filter(([key]) => key.startsWith(file + ':'));
278
+ const numericForFile = numericLeadingFixes.filter((f) => f.file === file);
279
+ if (idFixesForFile.length > 0 || numericForFile.length > 0) {
280
+ let code = fs.readFileSync(file, 'utf8');
281
+ if (idFixesForFile.length > 0) {
282
+ code = fixIdsInSourceFile(code, file, idFixesForFile);
283
+ }
284
+ if (numericForFile.length > 0) {
285
+ code = fixNumericLeadingInSourceFile(code, file, numericForFile);
286
+ }
287
+ fs.writeFileSync(file, code, 'utf8');
288
+ console.log(` ✓ Updated ${file}`);
189
289
  }
190
290
  }
291
+ // Re-parse after fixes
292
+ filePages.length = 0;
293
+ for (const file of files) {
294
+ const code = fs.readFileSync(file, 'utf8');
295
+ const pageKey = derivePageKey(file, opts.pageKeyFrom);
296
+ const page = parsePageFromFile({ code, filePath: file, pageKey });
297
+ filePages.push({ file, pageKey, page });
298
+ }
191
299
  }
192
- // Second pass: re-parse all files (now with fixed IDs) and compile
193
- // If no duplicates were found, reuse the already-parsed pages
194
300
  const pages = [];
195
301
  const seenIds = new Map();
196
- // Map of pageKey -> PageJson for applying metadata later
197
302
  const pageMap = new Map();
198
- if (idFixes.size > 0) {
199
- // Re-parse files if we fixed any duplicates
200
- for (const { file, pageKey } of filePages) {
201
- const code = fs.readFileSync(file, 'utf8');
202
- const page = parsePageFromFile({ code, filePath: file, pageKey });
203
- // Global ID uniqueness: components + option ids
204
- for (const c of page.components) {
205
- checkUniqueId(seenIds, c.id, { file, pageKey });
206
- if (c.type === 'OptionSelector' && Array.isArray(c.buttons)) {
207
- for (const b of c.buttons) {
208
- if (b?.id)
209
- checkUniqueId(seenIds, b.id, { file, pageKey });
210
- }
211
- }
212
- }
213
- pages.push(page);
214
- pageMap.set(pageKey, page);
215
- }
216
- }
217
- else {
218
- // No duplicates found, reuse already-parsed pages
219
- for (const filePage of filePages) {
220
- const { page, pageKey, file } = filePage;
221
- // Global ID uniqueness: components + option ids
222
- for (const c of page.components) {
223
- checkUniqueId(seenIds, c.id, { file, pageKey });
224
- if (c.type === 'OptionSelector' && Array.isArray(c.buttons)) {
225
- for (const b of c.buttons) {
226
- if (b?.id)
227
- checkUniqueId(seenIds, b.id, { file, pageKey });
228
- }
303
+ for (const filePage of filePages) {
304
+ const { page, pageKey, file } = filePage;
305
+ for (const c of page.components) {
306
+ checkUniqueId(seenIds, c.id, { file, pageKey });
307
+ if (c.type === 'OptionSelector' && Array.isArray(c.buttons)) {
308
+ for (const b of c.buttons) {
309
+ if (b?.id)
310
+ checkUniqueId(seenIds, b.id, { file, pageKey });
229
311
  }
230
312
  }
231
- pages.push(page);
232
- pageMap.set(pageKey, page);
233
313
  }
314
+ pages.push(page);
315
+ pageMap.set(pageKey, page);
234
316
  }
235
317
  // Read config.json if it exists
236
318
  let config = null;
@@ -333,6 +415,10 @@ export async function compileAllPages(opts) {
333
415
  // Skip _version - this is CLI metadata (tracked version number), not part of the embeddable
334
416
  continue;
335
417
  }
418
+ else if (key === '_branch_id' || key === '_branch_name') {
419
+ // Skip _branch_id / _branch_name - CLI metadata (current branch), not part of the embeddable
420
+ continue;
421
+ }
336
422
  else if (key === 'computedFields') {
337
423
  // Replace computedFields with loaded computedFields (which include code)
338
424
  if (computedFields.length > 0) {
@@ -555,6 +641,172 @@ function fixIdsInSourceFile(code, filePath, fixes) {
555
641
  const result = generate(ast, {}, code);
556
642
  return result.code;
557
643
  }
644
+ function fixNumericLeadingInSourceFile(code, filePath, fixes) {
645
+ let ast;
646
+ try {
647
+ ast = parse(code, {
648
+ sourceType: 'module',
649
+ plugins: ['typescript', 'jsx'],
650
+ sourceFilename: filePath,
651
+ });
652
+ }
653
+ catch (error) {
654
+ throw new CompileError(`Failed to parse file for key/id fixing: ${error.message}`, {
655
+ file: filePath,
656
+ });
657
+ }
658
+ const componentKeyMap = new Map();
659
+ const componentIdMap = new Map();
660
+ const buttonKeyMap = new Map();
661
+ const buttonIdMap = new Map();
662
+ for (const f of fixes) {
663
+ if (f.buttonIndex !== undefined) {
664
+ const k = `${f.componentIndex}:${f.buttonIndex}`;
665
+ if (f.kind === 'buttonKey')
666
+ buttonKeyMap.set(k, f.newValue);
667
+ else if (f.kind === 'buttonId')
668
+ buttonIdMap.set(k, f.newValue);
669
+ }
670
+ else {
671
+ if (f.kind === 'componentKey')
672
+ componentKeyMap.set(f.componentIndex, f.newValue);
673
+ else if (f.kind === 'componentId')
674
+ componentIdMap.set(f.componentIndex, f.newValue);
675
+ }
676
+ }
677
+ // Map from buttons variable name (e.g. "genderButtons") -> OptionSelector key (e.g. "gender") for fixing const arrays
678
+ const buttonsConstNameToPrefix = new Map();
679
+ // Collect (start, end, newText) replacements so we can patch the source without reformatting (preserves line breaks, etc.)
680
+ const replacements = [];
681
+ const pushReplacement = (node, newValue) => {
682
+ if (node.start == null || node.end == null)
683
+ return;
684
+ replacements.push({ start: node.start, end: node.end, newText: JSON.stringify(newValue) });
685
+ };
686
+ let componentIndex = -1;
687
+ traverse(ast, {
688
+ JSXElement(path) {
689
+ const opening = path.node.openingElement;
690
+ const tagName = opening.name.type === 'JSXIdentifier' ? opening.name.name : null;
691
+ if (!tagName || !ALLOWED_PRIMITIVES.has(tagName))
692
+ return;
693
+ const hasIdAttr = opening.attributes.some((a) => a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === 'id');
694
+ const hasKeyAttr = opening.attributes.some((a) => a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === 'key');
695
+ if (hasIdAttr || hasKeyAttr) {
696
+ componentIndex++;
697
+ }
698
+ const recordAttrReplacement = (name, newValue) => {
699
+ for (const attr of opening.attributes) {
700
+ if (attr.type === 'JSXAttribute' &&
701
+ attr.name.type === 'JSXIdentifier' &&
702
+ attr.name.name === name) {
703
+ const valueNode = attr.value?.type === 'JSXExpressionContainer'
704
+ ? attr.value.expression
705
+ : attr.value;
706
+ if (valueNode?.type === 'StringLiteral') {
707
+ pushReplacement(valueNode, newValue);
708
+ }
709
+ break;
710
+ }
711
+ }
712
+ };
713
+ if (componentKeyMap.has(componentIndex)) {
714
+ recordAttrReplacement('key', componentKeyMap.get(componentIndex));
715
+ }
716
+ if (componentIdMap.has(componentIndex)) {
717
+ recordAttrReplacement('id', componentIdMap.get(componentIndex));
718
+ }
719
+ if (tagName === 'OptionSelector') {
720
+ let componentKey;
721
+ for (const attr of opening.attributes) {
722
+ if (attr.type === 'JSXAttribute' &&
723
+ attr.name.type === 'JSXIdentifier' &&
724
+ attr.name.name === 'key' &&
725
+ attr.value?.type === 'StringLiteral') {
726
+ componentKey = attr.value.value;
727
+ break;
728
+ }
729
+ }
730
+ const prefix = componentKey ?? 'option';
731
+ for (const attr of opening.attributes) {
732
+ if (attr.type === 'JSXAttribute' &&
733
+ attr.name.type === 'JSXIdentifier' &&
734
+ attr.name.name === 'buttons' &&
735
+ attr.value?.type === 'JSXExpressionContainer') {
736
+ const expr = attr.value.expression;
737
+ if (expr.type === 'ArrayExpression') {
738
+ let buttonIndex = 0;
739
+ for (const element of expr.elements) {
740
+ if (element && element.type === 'ObjectExpression') {
741
+ const key = `${componentIndex}:${buttonIndex}`;
742
+ for (const prop of element.properties) {
743
+ if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') {
744
+ if (prop.key.name === 'key' && buttonKeyMap.has(key)) {
745
+ pushReplacement(prop.value, buttonKeyMap.get(key));
746
+ }
747
+ else if (prop.key.name === 'id' && buttonIdMap.has(key)) {
748
+ pushReplacement(prop.value, buttonIdMap.get(key));
749
+ }
750
+ }
751
+ }
752
+ }
753
+ buttonIndex++;
754
+ }
755
+ }
756
+ else if (expr.type === 'Identifier') {
757
+ if (!buttonsConstNameToPrefix.has(expr.name)) {
758
+ buttonsConstNameToPrefix.set(expr.name, prefix);
759
+ }
760
+ }
761
+ break;
762
+ }
763
+ }
764
+ }
765
+ },
766
+ });
767
+ // Fix button key/id in const arrays (e.g. const genderButtons = [{ id: "1_...", key: "..." }])
768
+ traverse(ast, {
769
+ VariableDeclarator(path) {
770
+ const id = path.node.id;
771
+ const init = path.node.init;
772
+ if (id.type !== 'Identifier' || init?.type !== 'ArrayExpression')
773
+ return;
774
+ const constName = id.name;
775
+ const prefix = buttonsConstNameToPrefix.get(constName) ?? 'option';
776
+ for (const element of init.elements) {
777
+ if (!element || element.type !== 'ObjectExpression')
778
+ continue;
779
+ for (const prop of element.properties) {
780
+ if (prop.type !== 'ObjectProperty' || prop.key.type !== 'Identifier')
781
+ continue;
782
+ if (prop.key.name !== 'key' && prop.key.name !== 'id')
783
+ continue;
784
+ const val = prop.value;
785
+ let current = null;
786
+ if (val.type === 'StringLiteral') {
787
+ current = val.value;
788
+ }
789
+ else if (val.type === 'TemplateLiteral' && val.quasis.length === 1 && !val.quasis[0].value.raw.includes('$')) {
790
+ current = val.quasis[0].value.raw;
791
+ }
792
+ if (current === null || !keyOrIdStartsWithDigit(current))
793
+ continue;
794
+ const newValue = normalizeKeyIfStartsWithDigit(current, prefix);
795
+ if (newValue === null)
796
+ continue;
797
+ pushReplacement(val, newValue);
798
+ }
799
+ }
800
+ },
801
+ });
802
+ // Apply replacements from end to start so indices stay valid; preserves all other formatting
803
+ replacements.sort((a, b) => b.start - a.start);
804
+ let result = code;
805
+ for (const { start, end, newText } of replacements) {
806
+ result = result.slice(0, start) + newText + result.slice(end);
807
+ }
808
+ return result;
809
+ }
558
810
  function checkUniqueId(seen, id, loc) {
559
811
  const prev = seen.get(id);
560
812
  if (prev) {
@@ -9,6 +9,11 @@ export declare function reverseCompile(embeddable: {
9
9
  [key: string]: any;
10
10
  }, embeddableId: string, opts?: {
11
11
  fix?: boolean;
12
+ pullMetadata?: {
13
+ version?: number;
14
+ branchId?: string;
15
+ branchName?: string;
16
+ };
12
17
  }): Promise<void>;
13
18
  /**
14
19
  * Sanitizes a string to be safe for use as a filename.
@@ -1 +1 @@
1
- {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/compiler/reverse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAihBzD,wBAAsB,cAAc,CAClC,UAAU,EAAE;IACV,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,cAAc,CAAC,EAAE,GAAG,EAAE,CAAA;IACtB,WAAW,CAAC,EAAE,GAAG,EAAE,CAAA;IACnB,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,EACD,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,OAAO,CAAA;CAAE,iBAmDzB;AA2yCD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKpD"}
1
+ {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/compiler/reverse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAqlBzD,wBAAsB,cAAc,CAClC,UAAU,EAAE;IACV,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,cAAc,CAAC,EAAE,GAAG,EAAE,CAAA;IACtB,WAAW,CAAC,EAAE,GAAG,EAAE,CAAA;IACnB,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,EACD,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,YAAY,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5E,iBAmFF;AA+0CD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKpD"}
@@ -284,6 +284,7 @@ function validateComponentProps(component, validProps) {
284
284
  'tags',
285
285
  'type',
286
286
  'parent_id',
287
+ 'parent_key',
287
288
  'buttons',
288
289
  ...IGNORED_COMPONENT_PROPERTIES,
289
290
  ]);
@@ -424,6 +425,61 @@ function checkAndFixDuplicateIds(pages, fix) {
424
425
  }
425
426
  return idMapping;
426
427
  }
428
+ /**
429
+ * Fixes deprecated parent_key: resolves to parent_id using first component with matching key,
430
+ * removes parent_key, and removes components that still have no parent_id after resolution.
431
+ * Only runs when fix is enabled.
432
+ */
433
+ function fixParentKeyDeprecation(components, fix) {
434
+ if (!fix || components.length === 0)
435
+ return;
436
+ // Build key -> id map (first occurrence wins, exclude ignored types)
437
+ const keyToId = new Map();
438
+ for (const comp of components) {
439
+ if (comp.key &&
440
+ comp.id &&
441
+ !keyToId.has(comp.key) &&
442
+ comp.type &&
443
+ !IGNORED_COMPONENT_TYPES.has(comp.type)) {
444
+ keyToId.set(comp.key, comp.id);
445
+ }
446
+ }
447
+ // Resolve parent_key -> parent_id, remove parent_key, track components to remove
448
+ const toRemove = new Map(); // id -> parent_key for warning
449
+ for (const comp of components) {
450
+ const parentKey = comp.parent_key;
451
+ if (!parentKey)
452
+ continue;
453
+ if (!comp.parent_id) {
454
+ const resolvedId = keyToId.get(parentKey);
455
+ if (resolvedId) {
456
+ comp.parent_id = resolvedId;
457
+ console.warn(`Fixed parent_key on component (id: ${comp.id}, key: ${comp.key}) – resolved parent_key "${parentKey}" to parent_id.`);
458
+ }
459
+ else {
460
+ toRemove.set(comp.id, parentKey);
461
+ }
462
+ }
463
+ else {
464
+ console.warn(`Fixed parent_key on component (id: ${comp.id}, key: ${comp.key}) – removed deprecated parent_key (already has parent_id).`);
465
+ }
466
+ delete comp.parent_key;
467
+ }
468
+ // Remove components that couldn't resolve parent
469
+ if (toRemove.size > 0) {
470
+ let i = 0;
471
+ while (i < components.length) {
472
+ const comp = components[i];
473
+ if (toRemove.has(comp.id)) {
474
+ console.warn(`Removed component (id: ${comp.id}, key: ${comp.key}) – parent_key "${toRemove.get(comp.id)}" not found, no parent_id.`);
475
+ components.splice(i, 1);
476
+ }
477
+ else {
478
+ i++;
479
+ }
480
+ }
481
+ }
482
+ }
427
483
  /**
428
484
  * Applies the ID mapping to all pages, updating component IDs and button IDs.
429
485
  * The mapping uses occurrence keys (pageKey:componentIndex or pageKey:componentIndex:buttonIndex).
@@ -461,14 +517,43 @@ function applyIdMapping(pages, idMapping) {
461
517
  }
462
518
  }
463
519
  }
520
+ function hasDeprecatedParentKey(components) {
521
+ return components.some((comp) => comp.parent_key != null);
522
+ }
464
523
  export async function reverseCompile(embeddable, embeddableId, opts) {
465
524
  const fix = opts?.fix ?? false;
525
+ const pullMetadata = opts?.pullMetadata;
526
+ // When fix is disabled, throw on deprecated parent_key so user gets interactive retry prompt
527
+ if (!fix) {
528
+ for (const page of embeddable.pages) {
529
+ if (hasDeprecatedParentKey(page.components)) {
530
+ const count = page.components.filter((c) => c.parent_key != null).length;
531
+ throw new Error(`Found deprecated parent_key on ${count} component(s) in page "${page.key}". Run with --fix to resolve.`);
532
+ }
533
+ }
534
+ if (embeddable.components && Array.isArray(embeddable.components)) {
535
+ if (hasDeprecatedParentKey(embeddable.components)) {
536
+ const count = embeddable.components.filter((c) => c.parent_key != null).length;
537
+ throw new Error(`Found deprecated parent_key on ${count} global component(s). Run with --fix to resolve.`);
538
+ }
539
+ }
540
+ }
466
541
  // Check for duplicate IDs across all pages and create a mapping
467
542
  const idMapping = checkAndFixDuplicateIds(embeddable.pages, fix);
468
543
  // Apply ID mapping to all pages
469
544
  if (idMapping.size > 0) {
470
545
  applyIdMapping(embeddable.pages, idMapping);
471
546
  }
547
+ // Fix deprecated parent_key (resolve to parent_id, remove parent_key, drop orphans).
548
+ // Must run before extractGlobalComponents so resolved parent_id avoids "must have _location since it has no parent_id" errors.
549
+ if (fix) {
550
+ for (const page of embeddable.pages) {
551
+ fixParentKeyDeprecation(page.components, fix);
552
+ }
553
+ if (embeddable.components && Array.isArray(embeddable.components)) {
554
+ fixParentKeyDeprecation(embeddable.components, fix);
555
+ }
556
+ }
472
557
  // Generate TSX pages
473
558
  for (const page of embeddable.pages) {
474
559
  await generatePageFile(page, embeddableId, fix);
@@ -478,7 +563,7 @@ export async function reverseCompile(embeddable, embeddableId, opts) {
478
563
  await generateStylesFile(embeddable.styles, embeddableId);
479
564
  }
480
565
  // Generate config.json
481
- await generateConfigFile(embeddable, embeddableId);
566
+ await generateConfigFile(embeddable, embeddableId, pullMetadata);
482
567
  // Extract computedFields to JS files
483
568
  if (embeddable.computedFields && embeddable.computedFields.length > 0) {
484
569
  await extractComputedFields(embeddable.computedFields, embeddableId);
@@ -758,6 +843,7 @@ function generateJSX(node, indent = 4, pageKey, componentId, nameMap) {
758
843
  'tags',
759
844
  'type',
760
845
  'parent_id',
846
+ 'parent_key',
761
847
  'buttons',
762
848
  '_location',
763
849
  ...IGNORED_COMPONENT_PROPERTIES,
@@ -1347,9 +1433,27 @@ function escapeStringForJS(str) {
1347
1433
  * This file controls page ordering and stores embeddable-level metadata.
1348
1434
  * Preserves the order of top-level properties from embeddable.json.
1349
1435
  */
1350
- async function generateConfigFile(embeddable, embeddableId) {
1436
+ async function generateConfigFile(embeddable, embeddableId, pullMetadata) {
1351
1437
  try {
1352
1438
  const configPath = path.join('embeddables', embeddableId, 'config.json');
1439
+ // CLI-only fields: from pull (when branching/saving) or from existing config file.
1440
+ let preservedVersion;
1441
+ let preservedBranchId;
1442
+ let preservedBranchName;
1443
+ if (fs.existsSync(configPath)) {
1444
+ try {
1445
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1446
+ if (typeof existing._version === 'number')
1447
+ preservedVersion = existing._version;
1448
+ if (typeof existing._branch_id === 'string' && existing._branch_id)
1449
+ preservedBranchId = existing._branch_id;
1450
+ if (typeof existing._branch_name === 'string' && existing._branch_name)
1451
+ preservedBranchName = existing._branch_name;
1452
+ }
1453
+ catch {
1454
+ /* ignore */
1455
+ }
1456
+ }
1353
1457
  // Preserve the order of top-level properties from embeddable
1354
1458
  const embeddableKeys = Object.keys(embeddable);
1355
1459
  // Extract page metadata and order (excluding components which are in TSX files)
@@ -1432,6 +1536,29 @@ async function generateConfigFile(embeddable, embeddableId) {
1432
1536
  if (!config.id) {
1433
1537
  config.id = embeddableId;
1434
1538
  }
1539
+ // Restore CLI-only fields: prefer pullMetadata (passed by pull/branch) so branch is never lost.
1540
+ const versionToWrite = pullMetadata?.version ?? preservedVersion;
1541
+ if (versionToWrite !== undefined)
1542
+ config._version = versionToWrite;
1543
+ if (pullMetadata !== undefined) {
1544
+ if (pullMetadata.branchId !== undefined) {
1545
+ config._branch_id = pullMetadata.branchId;
1546
+ if (pullMetadata.branchName !== undefined)
1547
+ config._branch_name = pullMetadata.branchName;
1548
+ else
1549
+ delete config._branch_name;
1550
+ }
1551
+ else {
1552
+ delete config._branch_id;
1553
+ delete config._branch_name;
1554
+ }
1555
+ }
1556
+ else {
1557
+ if (preservedBranchId !== undefined)
1558
+ config._branch_id = preservedBranchId;
1559
+ if (preservedBranchName !== undefined)
1560
+ config._branch_name = preservedBranchName;
1561
+ }
1435
1562
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
1436
1563
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
1437
1564
  console.log(`${pc.gray(`Generated ${configPath}`)}`);
@@ -551,7 +551,7 @@ export interface OptionSelectorButton {
551
551
  }
552
552
  type OptionSelectorButtonTriggerEvent = 'no-action' | 'next-page' | 'open-url';
553
553
  export interface ProgressBar extends Base {
554
- progress_type?: 'circle' | 'simple' | 'custom';
554
+ progress_type: 'circle' | 'simple' | 'custom';
555
555
  allow_nav?: boolean;
556
556
  fill_connectors?: boolean;
557
557
  node_icon?: string;