@embeddables/cli 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,7 +77,7 @@ All component types are derived from `src/types-builder.ts`. The relevant typing
77
77
  **Key Base Properties** (all components have these):
78
78
 
79
79
  - `id: string` - Unique identifier - this must be unique across the entire Embeddable JSON. Always snake_case.
80
- - `key: string` - Unique identifier, used in React as key prop - unique in general but duplicate keys can be used in certain cases (e.g. two `email` fields, each hidden by conditions). Always snake_case.
80
+ - `key: string` - Unique identifier, used in React as key prop - unique in general but duplicate keys can be used in certain cases (e.g. two `email` fields, each hidden by conditions). Always snake_case. **Keys must not start with a digit** (invalid in JS/JSON). When deriving a key from text that would start with a number (e.g. "1 to 2 weeks" → "1_to_2_weeks"), use a semantic prefix instead: e.g. `range_1_to_2_weeks`, `option_1_to_2_weeks`, or the component key like `delivery_time_1_to_2_weeks`.
81
81
  - `type: ComponentType` - Component type
82
82
  - `tags?: string[]` - Used for CSS styling
83
83
  - `parent_id?: string` - For nested components
@@ -98,7 +98,7 @@ All component types are derived from `src/types-builder.ts`. The relevant typing
98
98
  - `outputs_onchange`
99
99
  - etc.
100
100
  - `OptionSelector`
101
- - `buttons`
101
+ - `buttons` - each button has a `key` (and optional `text`, `description`, etc.). **Button keys must not start with a digit.** If the option label would slug to a key starting with a number (e.g. "1 to 2 weeks" → "1_to_2_weeks"), use a prefix such as the component key (e.g. `delivery_range_1_to_2_weeks`) or a short semantic prefix (e.g. `range_1_to_2_weeks`, `option_1_to_2_weeks`).
102
102
  - `multiple` - whether to allow multi-select
103
103
  - `dropdown`
104
104
  - `checkbox` - whether to add a (square/round) visual checkbox in each button (the checkbox will be added for you, no need to add it manually)
package/dist/cli.js CHANGED
@@ -33,6 +33,7 @@ program
33
33
  .option('-i, --id <id>', 'Embeddable ID (will prompt if not provided)')
34
34
  .option('-p, --pages <glob>', 'Pages glob')
35
35
  .option('-o, --out <path>', 'Output json path')
36
+ .option('--fix', 'Apply lint fixes (duplicate IDs, keys/IDs starting with a number)')
36
37
  .option('--pageKeyFrom <mode>', 'filename|export', 'filename')
37
38
  .action(async (opts) => {
38
39
  await runBuild(opts);
@@ -42,6 +43,7 @@ program
42
43
  .option('-i, --id <id>', 'Embeddable ID (will prompt if not provided)')
43
44
  .option('-p, --pages <glob>', 'Pages glob')
44
45
  .option('-o, --out <path>', 'Output json path')
46
+ .option('--fix', 'Apply lint fixes (duplicate IDs, keys/IDs starting with a number)')
45
47
  .option('-L, --local', 'Use local engine (http://localhost:8787)')
46
48
  .option('-e, --engine <url>', 'Engine origin', 'https://engine.embeddables.com')
47
49
  .option('--port <n>', 'Dev proxy port', '3000')
@@ -2,6 +2,7 @@ export declare function runBuild(opts: {
2
2
  id?: string;
3
3
  pages?: string;
4
4
  out?: string;
5
+ fix?: boolean;
5
6
  pageKeyFrom: 'filename' | 'export';
6
7
  }): Promise<void>;
7
8
  //# sourceMappingURL=build.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/commands/build.ts"],"names":[],"mappings":"AAKA,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBA4BA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/commands/build.ts"],"names":[],"mappings":"AAKA,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBA6BA"}
@@ -23,6 +23,7 @@ export async function runBuild(opts) {
23
23
  pageKeyFrom: opts.pageKeyFrom,
24
24
  stylesDir,
25
25
  embeddableId,
26
+ fixLint: opts.fix ? true : 'prompt',
26
27
  });
27
28
  }
28
29
  catch (e) {
@@ -2,6 +2,7 @@ export declare function runDev(opts: {
2
2
  id?: string;
3
3
  pages?: string;
4
4
  out?: string;
5
+ fix?: boolean;
5
6
  local?: boolean;
6
7
  engine: string;
7
8
  port: string;
@@ -1 +1 @@
1
- {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/commands/dev.ts"],"names":[],"mappings":"AAmHA,wBAAsB,MAAM,CAAC,IAAI,EAAE;IACjC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBA4GA"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/commands/dev.ts"],"names":[],"mappings":"AAmHA,wBAAsB,MAAM,CAAC,IAAI,EAAE;IACjC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBA+GA"}
@@ -108,6 +108,7 @@ export async function runDev(opts) {
108
108
  const globalComponentsGlob = `embeddables/${embeddableId}/global-components/**/*.location.tsx`;
109
109
  const computedFieldsGlob = `embeddables/${embeddableId}/computed-fields/**/*.js`;
110
110
  const actionsGlob = `embeddables/${embeddableId}/actions/**/*.js`;
111
+ const fixLint = opts.fix ? true : 'prompt';
111
112
  // Initial build
112
113
  try {
113
114
  await compileAllPages({
@@ -117,6 +118,7 @@ export async function runDev(opts) {
117
118
  stylesDir,
118
119
  embeddableId,
119
120
  configPath,
121
+ fixLint,
120
122
  });
121
123
  }
122
124
  catch (e) {
@@ -158,6 +160,7 @@ export async function runDev(opts) {
158
160
  stylesDir,
159
161
  embeddableId,
160
162
  configPath,
163
+ fixLint,
161
164
  });
162
165
  proxy.broadcastReload();
163
166
  }
@@ -1 +1 @@
1
- {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"AAyEA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,GAAG,CAAC,EAAE,OAAO,CAAA;CACd,CAAA;AAED,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,iBAkPjD"}
1
+ {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"AA+GA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,GAAG,CAAC,EAAE,OAAO,CAAA;CACd,CAAA;AAED,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,iBAqPjD"}
@@ -6,6 +6,44 @@ import { reverseCompile } from '../compiler/reverse.js';
6
6
  import { getAccessToken, isLoggedIn } from '../auth/index.js';
7
7
  import { getProjectId, writeProjectConfig } from '../config/index.js';
8
8
  import { promptForProject, promptForEmbeddable, fetchEmbeddableMetadata } from '../prompts/index.js';
9
+ /**
10
+ * Normalize custom_validation_function strings so literal "\\n" (backslash-n) becomes real newline.
11
+ * The API sometimes returns these double-escaped; dataOutputs/computedFields code is written
12
+ * as raw file content so they don't get double-escaped when written to embeddable.json.
13
+ */
14
+ function normalizeCustomValidationFunctionsInFlow(flow) {
15
+ function walkComponent(comp) {
16
+ const v = comp.custom_validation_function;
17
+ if (typeof v === 'string') {
18
+ comp.custom_validation_function = v.replace(/\\n/g, '\n');
19
+ }
20
+ const children = comp.components ?? comp.buttons;
21
+ if (Array.isArray(children)) {
22
+ for (const c of children) {
23
+ if (c && typeof c === 'object')
24
+ walkComponent(c);
25
+ }
26
+ }
27
+ }
28
+ const pages = flow.pages;
29
+ if (Array.isArray(pages)) {
30
+ for (const page of pages) {
31
+ if (page && typeof page === 'object' && Array.isArray(page.components)) {
32
+ for (const comp of page.components) {
33
+ if (comp && typeof comp === 'object')
34
+ walkComponent(comp);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ const components = flow.components;
40
+ if (Array.isArray(components)) {
41
+ for (const comp of components) {
42
+ if (comp && typeof comp === 'object')
43
+ walkComponent(comp);
44
+ }
45
+ }
46
+ }
9
47
  /** Slug for branch name/id for use in filenames (e.g. "my branch" -> "my_branch"). */
10
48
  function slugForBranch(nameOrId) {
11
49
  return String(nameOrId).replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/_+/g, '_') || 'main';
@@ -145,6 +183,8 @@ export async function runPull(opts) {
145
183
  if (!flow) {
146
184
  throw new Error("Response does not contain a 'flow' property");
147
185
  }
186
+ // Normalize custom_validation_function: literal \n (backslash-n) -> real newline (so embeddable.json is correct)
187
+ normalizeCustomValidationFunctionsInFlow(flow);
148
188
  // Save to embeddables/<id>/.generated/embeddable.json
149
189
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
150
190
  const flowJson = JSON.stringify(flow, null, 2);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Keys and IDs must not start with a digit (invalid in JS/JSON).
3
+ * This helper produces a safe key by prefixing when needed.
4
+ */
5
+ /** Returns a key safe for use (prefix applied if key starts with a digit). */
6
+ export declare function normalizeKeyIfStartsWithDigit(key: string | null, prefix: string): string | null;
7
+ export declare function keyOrIdStartsWithDigit(value: string | null | undefined): boolean;
8
+ //# sourceMappingURL=numericLeadingKeys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"numericLeadingKeys.d.ts","sourceRoot":"","sources":["../../../src/compiler/helpers/numericLeadingKeys.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,8EAA8E;AAC9E,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI/F;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAIhF"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Keys and IDs must not start with a digit (invalid in JS/JSON).
3
+ * This helper produces a safe key by prefixing when needed.
4
+ */
5
+ /** Returns a key safe for use (prefix applied if key starts with a digit). */
6
+ export function normalizeKeyIfStartsWithDigit(key, prefix) {
7
+ if (key === null || typeof key !== 'string' || key === '')
8
+ return key;
9
+ if (!/^\d/.test(key))
10
+ return key;
11
+ return `${prefix}_${key}`;
12
+ }
13
+ export function keyOrIdStartsWithDigit(value) {
14
+ if (value === null || value === undefined || typeof value !== 'string' || value === '')
15
+ return false;
16
+ return /^\d/.test(value);
17
+ }
@@ -12,5 +12,7 @@ export declare function compileAllPages(opts: {
12
12
  stylesDir?: string;
13
13
  embeddableId?: string;
14
14
  configPath?: string;
15
+ /** When true, apply lint fixes (duplicate IDs, keys/IDs starting with a digit). When 'prompt', ask user to fix. */
16
+ fixLint?: true | 'prompt';
15
17
  }): Promise<void>;
16
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/compiler/index.ts"],"names":[],"mappings":"AA+CA;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CA2DlF;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,iBAoXA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/compiler/index.ts"],"names":[],"mappings":"AAmDA;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CA2DlF;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mHAAmH;IACnH,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAA;CAC1B,iBA4cA"}
@@ -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;
@@ -559,6 +641,172 @@ function fixIdsInSourceFile(code, filePath, fixes) {
559
641
  const result = generate(ast, {}, code);
560
642
  return result.code;
561
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
+ }
562
810
  function checkUniqueId(seen, id, loc) {
563
811
  const prev = seen.get(id);
564
812
  if (prev) {
@@ -1 +1 @@
1
- {"version":3,"file":"parsePage.d.ts","sourceRoot":"","sources":["../../src/compiler/parsePage.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAezD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,GAAG,QAAQ,CA+GX;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;CACjB,GAAG,aAAa,EAAE,CAmGlB"}
1
+ {"version":3,"file":"parsePage.d.ts","sourceRoot":"","sources":["../../src/compiler/parsePage.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAezD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,GAAG,QAAQ,CAuHX;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;CACjB,GAAG,aAAa,EAAE,CAqGlB"}