@ikas/component-cli 1.4.0-beta.23 → 1.4.0-beta.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/build-tools.d.ts +37 -0
  2. package/dist/build-tools.d.ts.map +1 -0
  3. package/dist/build-tools.js +36 -0
  4. package/dist/build-tools.js.map +1 -0
  5. package/dist/commands/add-sections-to-page.d.ts +3 -0
  6. package/dist/commands/add-sections-to-page.d.ts.map +1 -0
  7. package/dist/commands/add-sections-to-page.js +39 -0
  8. package/dist/commands/add-sections-to-page.js.map +1 -0
  9. package/dist/commands/add-to-page.js +1 -1
  10. package/dist/commands/add-to-page.js.map +1 -1
  11. package/dist/commands/add.d.ts.map +1 -1
  12. package/dist/commands/add.js +11 -2
  13. package/dist/commands/add.js.map +1 -1
  14. package/dist/commands/build.d.ts.map +1 -1
  15. package/dist/commands/build.js +23 -170
  16. package/dist/commands/build.js.map +1 -1
  17. package/dist/commands/config-texts.d.ts +39 -0
  18. package/dist/commands/config-texts.d.ts.map +1 -0
  19. package/dist/commands/config-texts.js +192 -0
  20. package/dist/commands/config-texts.js.map +1 -0
  21. package/dist/commands/config.d.ts +45 -0
  22. package/dist/commands/config.d.ts.map +1 -1
  23. package/dist/commands/config.js +1205 -154
  24. package/dist/commands/config.js.map +1 -1
  25. package/dist/commands/create-design-tokens.d.ts +24 -0
  26. package/dist/commands/create-design-tokens.d.ts.map +1 -0
  27. package/dist/commands/create-design-tokens.js +388 -0
  28. package/dist/commands/create-design-tokens.js.map +1 -0
  29. package/dist/commands/create-global-variable.d.ts +3 -0
  30. package/dist/commands/create-global-variable.d.ts.map +1 -0
  31. package/dist/commands/create-global-variable.js +73 -0
  32. package/dist/commands/create-global-variable.js.map +1 -0
  33. package/dist/commands/create-page.d.ts +3 -0
  34. package/dist/commands/create-page.d.ts.map +1 -0
  35. package/dist/commands/create-page.js +31 -0
  36. package/dist/commands/create-page.js.map +1 -0
  37. package/dist/commands/delete-page.d.ts +3 -0
  38. package/dist/commands/delete-page.d.ts.map +1 -0
  39. package/dist/commands/delete-page.js +28 -0
  40. package/dist/commands/delete-page.js.map +1 -0
  41. package/dist/commands/delete-theme-globals.d.ts +4 -0
  42. package/dist/commands/delete-theme-globals.d.ts.map +1 -0
  43. package/dist/commands/delete-theme-globals.js +50 -0
  44. package/dist/commands/delete-theme-globals.js.map +1 -0
  45. package/dist/commands/dev.d.ts.map +1 -1
  46. package/dist/commands/dev.js +328 -39
  47. package/dist/commands/dev.js.map +1 -1
  48. package/dist/commands/enable-builtin-pages.d.ts +3 -0
  49. package/dist/commands/enable-builtin-pages.d.ts.map +1 -0
  50. package/dist/commands/enable-builtin-pages.js +28 -0
  51. package/dist/commands/enable-builtin-pages.js.map +1 -0
  52. package/dist/commands/get-component-props.d.ts +3 -0
  53. package/dist/commands/get-component-props.d.ts.map +1 -0
  54. package/dist/commands/get-component-props.js +32 -0
  55. package/dist/commands/get-component-props.js.map +1 -0
  56. package/dist/commands/get-page-by-type.d.ts +3 -0
  57. package/dist/commands/get-page-by-type.d.ts.map +1 -0
  58. package/dist/commands/get-page-by-type.js +25 -0
  59. package/dist/commands/get-page-by-type.js.map +1 -0
  60. package/dist/commands/get-section-values.d.ts +3 -0
  61. package/dist/commands/get-section-values.d.ts.map +1 -0
  62. package/dist/commands/get-section-values.js +43 -0
  63. package/dist/commands/get-section-values.js.map +1 -0
  64. package/dist/commands/import.js +1 -1
  65. package/dist/commands/import.js.map +1 -1
  66. package/dist/commands/list-builtin-pages.d.ts +3 -0
  67. package/dist/commands/list-builtin-pages.d.ts.map +1 -0
  68. package/dist/commands/list-builtin-pages.js +28 -0
  69. package/dist/commands/list-builtin-pages.js.map +1 -0
  70. package/dist/commands/list-entities.d.ts +3 -0
  71. package/dist/commands/list-entities.d.ts.map +1 -0
  72. package/dist/commands/list-entities.js +32 -0
  73. package/dist/commands/list-entities.js.map +1 -0
  74. package/dist/commands/list-imported.js +1 -1
  75. package/dist/commands/list-imported.js.map +1 -1
  76. package/dist/commands/list-page-sections.d.ts +3 -0
  77. package/dist/commands/list-page-sections.d.ts.map +1 -0
  78. package/dist/commands/list-page-sections.js +31 -0
  79. package/dist/commands/list-page-sections.js.map +1 -0
  80. package/dist/commands/list-pages.js +1 -1
  81. package/dist/commands/list-pages.js.map +1 -1
  82. package/dist/commands/list-theme-globals.d.ts +3 -0
  83. package/dist/commands/list-theme-globals.d.ts.map +1 -0
  84. package/dist/commands/list-theme-globals.js +22 -0
  85. package/dist/commands/list-theme-globals.js.map +1 -0
  86. package/dist/commands/list-translations.d.ts +3 -0
  87. package/dist/commands/list-translations.d.ts.map +1 -0
  88. package/dist/commands/list-translations.js +33 -0
  89. package/dist/commands/list-translations.js.map +1 -0
  90. package/dist/commands/move-page-section.d.ts +3 -0
  91. package/dist/commands/move-page-section.d.ts.map +1 -0
  92. package/dist/commands/move-page-section.js +62 -0
  93. package/dist/commands/move-page-section.js.map +1 -0
  94. package/dist/commands/publish-theme.d.ts +3 -0
  95. package/dist/commands/publish-theme.d.ts.map +1 -0
  96. package/dist/commands/publish-theme.js +29 -0
  97. package/dist/commands/publish-theme.js.map +1 -0
  98. package/dist/commands/publish.d.ts.map +1 -1
  99. package/dist/commands/publish.js +72 -3
  100. package/dist/commands/publish.js.map +1 -1
  101. package/dist/commands/remove-page-section.d.ts +3 -0
  102. package/dist/commands/remove-page-section.d.ts.map +1 -0
  103. package/dist/commands/remove-page-section.js +40 -0
  104. package/dist/commands/remove-page-section.js.map +1 -0
  105. package/dist/commands/search-products.d.ts +3 -0
  106. package/dist/commands/search-products.d.ts.map +1 -0
  107. package/dist/commands/search-products.js +40 -0
  108. package/dist/commands/search-products.js.map +1 -0
  109. package/dist/commands/translation-languages.d.ts +3 -0
  110. package/dist/commands/translation-languages.d.ts.map +1 -0
  111. package/dist/commands/translation-languages.js +43 -0
  112. package/dist/commands/translation-languages.js.map +1 -0
  113. package/dist/commands/update-builtin-page-prop.d.ts +3 -0
  114. package/dist/commands/update-builtin-page-prop.d.ts.map +1 -0
  115. package/dist/commands/update-builtin-page-prop.js +49 -0
  116. package/dist/commands/update-builtin-page-prop.js.map +1 -0
  117. package/dist/commands/update-global-variable.d.ts +3 -0
  118. package/dist/commands/update-global-variable.d.ts.map +1 -0
  119. package/dist/commands/update-global-variable.js +70 -0
  120. package/dist/commands/update-global-variable.js.map +1 -0
  121. package/dist/commands/update-page-sections.d.ts +3 -0
  122. package/dist/commands/update-page-sections.d.ts.map +1 -0
  123. package/dist/commands/update-page-sections.js +42 -0
  124. package/dist/commands/update-page-sections.js.map +1 -0
  125. package/dist/commands/update-section-prop.d.ts +3 -0
  126. package/dist/commands/update-section-prop.d.ts.map +1 -0
  127. package/dist/commands/update-section-prop.js +81 -0
  128. package/dist/commands/update-section-prop.js.map +1 -0
  129. package/dist/commands/update-translations.d.ts +3 -0
  130. package/dist/commands/update-translations.d.ts.map +1 -0
  131. package/dist/commands/update-translations.js +45 -0
  132. package/dist/commands/update-translations.js.map +1 -0
  133. package/dist/commands/upload-image.d.ts +3 -0
  134. package/dist/commands/upload-image.d.ts.map +1 -0
  135. package/dist/commands/upload-image.js +38 -0
  136. package/dist/commands/upload-image.js.map +1 -0
  137. package/dist/commands/upload-images.d.ts +3 -0
  138. package/dist/commands/upload-images.d.ts.map +1 -0
  139. package/dist/commands/upload-images.js +48 -0
  140. package/dist/commands/upload-images.js.map +1 -0
  141. package/dist/index.d.ts.map +1 -1
  142. package/dist/index.js +66 -1
  143. package/dist/index.js.map +1 -1
  144. package/dist/types.d.ts +77 -0
  145. package/dist/types.d.ts.map +1 -1
  146. package/dist/utils/archive-project.d.ts +50 -0
  147. package/dist/utils/archive-project.d.ts.map +1 -0
  148. package/dist/utils/archive-project.js +178 -0
  149. package/dist/utils/archive-project.js.map +1 -0
  150. package/dist/utils/compile.d.ts +17 -2
  151. package/dist/utils/compile.d.ts.map +1 -1
  152. package/dist/utils/compile.js +742 -109
  153. package/dist/utils/compile.js.map +1 -1
  154. package/dist/utils/component-helpers.d.ts +236 -4
  155. package/dist/utils/component-helpers.d.ts.map +1 -1
  156. package/dist/utils/component-helpers.js +656 -37
  157. package/dist/utils/component-helpers.js.map +1 -1
  158. package/dist/utils/css-import-resolver.d.ts +4 -1
  159. package/dist/utils/css-import-resolver.d.ts.map +1 -1
  160. package/dist/utils/css-import-resolver.js +9 -1
  161. package/dist/utils/css-import-resolver.js.map +1 -1
  162. package/dist/utils/dev-server-auth.d.ts +28 -0
  163. package/dist/utils/dev-server-auth.d.ts.map +1 -0
  164. package/dist/utils/dev-server-auth.js +79 -0
  165. package/dist/utils/dev-server-auth.js.map +1 -0
  166. package/dist/utils/editor-action-client.d.ts +20 -3
  167. package/dist/utils/editor-action-client.d.ts.map +1 -1
  168. package/dist/utils/editor-action-client.js +81 -19
  169. package/dist/utils/editor-action-client.js.map +1 -1
  170. package/dist/utils/esm-transform.d.ts +30 -0
  171. package/dist/utils/esm-transform.d.ts.map +1 -1
  172. package/dist/utils/esm-transform.js +169 -2
  173. package/dist/utils/esm-transform.js.map +1 -1
  174. package/dist/utils/find-component-for-file.d.ts +12 -0
  175. package/dist/utils/find-component-for-file.d.ts.map +1 -0
  176. package/dist/utils/find-component-for-file.js +24 -0
  177. package/dist/utils/find-component-for-file.js.map +1 -0
  178. package/dist/utils/forbid-deep-imports.d.ts +3 -0
  179. package/dist/utils/forbid-deep-imports.d.ts.map +1 -0
  180. package/dist/utils/forbid-deep-imports.js +54 -0
  181. package/dist/utils/forbid-deep-imports.js.map +1 -0
  182. package/dist/utils/forbid-external-packages.d.ts +26 -0
  183. package/dist/utils/forbid-external-packages.d.ts.map +1 -0
  184. package/dist/utils/forbid-external-packages.js +99 -0
  185. package/dist/utils/forbid-external-packages.js.map +1 -0
  186. package/dist/utils/i18n.d.ts +98 -0
  187. package/dist/utils/i18n.d.ts.map +1 -0
  188. package/dist/utils/i18n.js +408 -0
  189. package/dist/utils/i18n.js.map +1 -0
  190. package/dist/utils/load-image.d.ts +16 -0
  191. package/dist/utils/load-image.d.ts.map +1 -0
  192. package/dist/utils/load-image.js +50 -0
  193. package/dist/utils/load-image.js.map +1 -0
  194. package/dist/utils/storefront-t.d.ts +17 -0
  195. package/dist/utils/storefront-t.d.ts.map +1 -0
  196. package/dist/utils/storefront-t.js +187 -0
  197. package/dist/utils/storefront-t.js.map +1 -0
  198. package/dist/utils/vendor-storefront.d.ts +13 -0
  199. package/dist/utils/vendor-storefront.d.ts.map +1 -0
  200. package/dist/utils/vendor-storefront.js +91 -0
  201. package/dist/utils/vendor-storefront.js.map +1 -0
  202. package/dist/utils/websocket-server.d.ts +165 -7
  203. package/dist/utils/websocket-server.d.ts.map +1 -1
  204. package/dist/utils/websocket-server.js +326 -45
  205. package/dist/utils/websocket-server.js.map +1 -1
  206. package/package.json +19 -2
  207. package/templates/add/section.css +11 -0
  208. package/dist/commands/create.d.ts +0 -4
  209. package/dist/commands/create.d.ts.map +0 -1
  210. package/dist/commands/create.js +0 -176
  211. package/dist/commands/create.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
+ import { isLanguageCode } from "./i18n.js";
4
5
  import { readTemplate } from "./template.js";
5
6
  /**
6
7
  * Generate an 8-character random alphanumeric project ID (lowercase + digits).
@@ -35,6 +36,95 @@ export function generateUniqueId() {
35
36
  export function generateComponentId(projectId) {
36
37
  return `${projectId}-${generateUniqueId()}`;
37
38
  }
39
+ /**
40
+ * The editor's publish-id machinery (rewrite-asset-ids.ts, MIN_ROOT_ID_LENGTH = 6)
41
+ * treats a leading run of >=6 alphanumeric characters before a `-`/`_` separator as
42
+ * a strippable project prefix, and republish stability (insert-vs-version matching
43
+ * by source suffix) depends on every projectId satisfying that shape. A projectId
44
+ * that is shorter, or that contains a separator/non-alphanumeric character, shifts
45
+ * the prefix/suffix boundary and silently re-opens the duplicate-element bug.
46
+ * `generateProjectId()` always satisfies this; the check exists because
47
+ * ikas.config.json is hand-editable and parsed without a schema.
48
+ */
49
+ export const PROJECT_ID_PATTERN = /^[A-Za-z0-9]{6,}$/;
50
+ export function isValidProjectId(projectId) {
51
+ return PROJECT_ID_PATTERN.test(projectId);
52
+ }
53
+ /**
54
+ * A component id is publishable when the editor's prefix-strip machinery parses it
55
+ * unambiguously: either a legacy atomic id (alphanumeric only, no separators) or a
56
+ * `<projectId>-<suffix>` form whose PREFIX meets the same >=6 floor as PROJECT_ID_PATTERN.
57
+ * The prefix must stay >=6 alphanumeric with NO separator (the editor strips exactly one
58
+ * leading `>=MIN_ROOT_ID_LENGTH` run before the first `-`/`_`); a short/separator-bearing
59
+ * prefix shifts that boundary and re-opens the duplicate-element bug.
60
+ *
61
+ * The SUFFIX, however, may be multi-segment (`<projectId>-hero-slider`,
62
+ * `<projectId>-product-detail-add-to-cart`): older CLI configs used human-readable
63
+ * suffixes, and those ids are already placed on published themes so they cannot be
64
+ * renamed. The editor's `normalizeSourceElementId` canonicalizes every suffix separator
65
+ * to `_`, so a hyphenated local suffix and its underscore-sterilized published form
66
+ * normalize to the same republish-invariant key — multi-segment suffixes match stably.
67
+ * Each segment must still be a non-empty alphanumeric run (no trailing/doubled separators).
68
+ */
69
+ export const COMPONENT_ID_PATTERN = /^(?:[A-Za-z0-9]+|[A-Za-z0-9]{6,}(?:[-_][A-Za-z0-9]+)+)$/;
70
+ /**
71
+ * Ids from the pre-projectId CLI (`<kebab-name>-<Date.now()>`) predate the prefix
72
+ * contract and are already placed on themes, so the prefix check below skips them.
73
+ */
74
+ const LEGACY_TIMESTAMP_ID_PATTERN = /-\d{13}$/;
75
+ /**
76
+ * Besides the shape check, a separator-bearing id must carry THIS project's prefix.
77
+ * An id with another prefix was copied from somewhere else — typically the published
78
+ * id the editor reports for a component installed from a design asset. Syncing it
79
+ * creates a second copy of the published component in the partner theme and corrupts
80
+ * the asset's local→published id map on the next publish.
81
+ */
82
+ export function findInvalidComponentIds(components, projectId) {
83
+ return components.flatMap((c) => {
84
+ if (!COMPONENT_ID_PATTERN.test(c.id)) {
85
+ return [{ id: c.id, name: c.name, reason: "malformed id" }];
86
+ }
87
+ const prefix = c.id.split(/[-_]/)[0];
88
+ const hasSeparator = prefix !== c.id;
89
+ if (projectId &&
90
+ hasSeparator &&
91
+ prefix !== projectId &&
92
+ !LEGACY_TIMESTAMP_ID_PATTERN.test(c.id)) {
93
+ return [
94
+ {
95
+ id: c.id,
96
+ name: c.name,
97
+ reason: `does not start with this project's id "${projectId}"`,
98
+ },
99
+ ];
100
+ }
101
+ return [];
102
+ });
103
+ }
104
+ /**
105
+ * Identity edits a running dev server must not take from a config reload: a changed
106
+ * projectId, or a component that kept its entry file but came back under another id.
107
+ * The editor reads an id that disappears mid-session as "component deleted" and removes
108
+ * the imported component, its wrapper section and its page placements from the theme.
109
+ * A component whose entry is gone from the config is a real removal and is not reported.
110
+ */
111
+ export function findIdentityChanges(previous, reloaded) {
112
+ const changes = [];
113
+ if (previous.projectId && reloaded.projectId !== previous.projectId) {
114
+ changes.push(`projectId "${previous.projectId}" -> "${reloaded.projectId}"`);
115
+ }
116
+ const previousIdByEntry = new Map((previous.components ?? []).map((c) => [c.entry, c.id]));
117
+ for (const c of reloaded.components ?? []) {
118
+ const previousId = previousIdByEntry.get(c.entry);
119
+ if (previousId && previousId !== c.id) {
120
+ changes.push(`${c.name}: "${previousId}" -> "${c.id}"`);
121
+ }
122
+ }
123
+ return changes;
124
+ }
125
+ /** Shared hint printed under an invalid-component-id error. */
126
+ export const COMPONENT_ID_HINT = "Component ids are generated by the CLI — restore the original values. Never hand-edit them, " +
127
+ "never copy an id shown by the editor into ikas.config.json, and do not change projectId to match.";
38
128
  /**
39
129
  * Validate that every entry in `input` is the id of a component currently in `components`.
40
130
  * Returns the list of unknown entries (empty array means all valid).
@@ -96,8 +186,8 @@ export const PROP_TYPE_TO_TS = {
96
186
  COLOR: "string",
97
187
  PRODUCT: "IkasProduct | null",
98
188
  PRODUCT_LIST: "IkasProductList",
99
- PRODUCT_ATTRIBUTE: "IkasProductAttributeValue | null",
100
- PRODUCT_ATTRIBUTE_LIST: "IkasProductAttributeValue[]",
189
+ PRODUCT_ATTRIBUTE: "IkasProductAttributeDetail | null",
190
+ PRODUCT_ATTRIBUTE_LIST: "IkasProductAttributeList",
101
191
  CATEGORY: "IkasCategory | null",
102
192
  CATEGORY_LIST: "IkasCategoryList",
103
193
  BRAND: "IkasBrand | null",
@@ -111,6 +201,69 @@ export const PROP_TYPE_TO_TS = {
111
201
  COMPONENT: "any",
112
202
  COMPONENT_LIST: "any",
113
203
  };
204
+ /**
205
+ * Pattern-element ("Font Style" / typography) enum sentinel. The editor builds
206
+ * this synthetic enum at theme time (createPatternElementEnum). Mirror of
207
+ * PATTERN_ELEMENT_ENUM_ID in @ikas/editor-models — kept local to avoid an
208
+ * editor-models dependency in the CLI.
209
+ */
210
+ export const PATTERN_ELEMENT_ENUM_ID = "__patternElementEnum__";
211
+ /**
212
+ * Built-in enum type ids do not need to exist in config.customTypes: @ikas/*
213
+ * enums ship with the storefront models, and the pattern-element sentinel is
214
+ * resolved by the editor at theme time. Author-defined enums are NOT built-in.
215
+ */
216
+ export function isBuiltInEnumTypeId(enumTypeId) {
217
+ return (enumTypeId.startsWith("@ikas/") || enumTypeId === PATTERN_ELEMENT_ENUM_ID);
218
+ }
219
+ const ENUM_OPTIONS_EXAMPLE = `'{"Small":"sm","Medium":"md"}' or '[{"name":"Small","value":"sm"}]'`;
220
+ /**
221
+ * The parsed `--options` of `config add-enum` / `config update-enum`, as the `{ "Label": "value" }`
222
+ * record an enum stores. The object form passes through unchanged. The array form
223
+ * `[{ "name": "Label", "value": "value" }]` (`label` is accepted for `name`) — which older project
224
+ * docs showed and agents still produce — is converted, with values stringified like every enum value.
225
+ */
226
+ export function normalizeEnumOptionsInput(input) {
227
+ if (Array.isArray(input)) {
228
+ const options = {};
229
+ for (const item of input) {
230
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
231
+ return {
232
+ error: `Each option in the array must be an object { "name", "value" }, e.g. ${ENUM_OPTIONS_EXAMPLE}.`,
233
+ };
234
+ }
235
+ const { name, label, value } = item;
236
+ const optionLabel = name ?? label;
237
+ if (typeof optionLabel !== "string" || !optionLabel.trim()) {
238
+ return {
239
+ error: `Every option needs a non-empty string "name", e.g. ${ENUM_OPTIONS_EXAMPLE}.`,
240
+ };
241
+ }
242
+ if ((typeof value !== "string" && typeof value !== "number") ||
243
+ String(value).trim() === "") {
244
+ return {
245
+ error: `Option "${optionLabel}" needs a non-empty string "value", e.g. ${ENUM_OPTIONS_EXAMPLE}.`,
246
+ };
247
+ }
248
+ if (Object.prototype.hasOwnProperty.call(options, optionLabel)) {
249
+ return { error: `Duplicate option name "${optionLabel}".` };
250
+ }
251
+ options[optionLabel] = String(value);
252
+ }
253
+ if (Object.keys(options).length === 0) {
254
+ return {
255
+ error: `Options must not be empty, e.g. ${ENUM_OPTIONS_EXAMPLE}.`,
256
+ };
257
+ }
258
+ return { options };
259
+ }
260
+ if (input && typeof input === "object" && Object.keys(input).length > 0) {
261
+ return { options: input };
262
+ }
263
+ return {
264
+ error: `Options must be a non-empty JSON object or array, e.g. ${ENUM_OPTIONS_EXAMPLE}.`,
265
+ };
266
+ }
114
267
  export function toKebabCase(str) {
115
268
  return str
116
269
  .replace(/([a-z])([A-Z])/g, "$1-$2")
@@ -149,14 +302,16 @@ function resolveTypeId(typeId) {
149
302
  */
150
303
  function resolveCustomEnumTypeName(enumTypeId, editorTypes, customTypes) {
151
304
  if (editorTypes) {
152
- const editorType = editorTypes.find(t => t.id === enumTypeId);
153
- if (editorType?.enumOptions && Object.keys(editorType.enumOptions).length > 0) {
305
+ const editorType = editorTypes.find((t) => t.id === enumTypeId);
306
+ if (editorType?.enumOptions &&
307
+ Object.keys(editorType.enumOptions).length > 0) {
154
308
  return toPascalCase(editorType.name);
155
309
  }
156
310
  }
157
311
  if (customTypes) {
158
- const customType = customTypes.find(t => t.id === enumTypeId);
159
- if (customType?.enumOptions && Object.keys(customType.enumOptions).length > 0) {
312
+ const customType = customTypes.find((t) => t.id === enumTypeId);
313
+ if (customType?.enumOptions &&
314
+ Object.keys(customType.enumOptions).length > 0) {
160
315
  return toPascalCase(customType.name);
161
316
  }
162
317
  }
@@ -176,7 +331,9 @@ export function generateTypesFile(componentName, props, type, editorTypes, custo
176
331
  typeIdResolved.set(prop.name, resolved.isArray ? `${resolved.typeName}[]` : resolved.typeName);
177
332
  }
178
333
  }
179
- else if (prop.type === "ENUM" && prop.enumTypeId) {
334
+ else if (prop.type === "ENUM" &&
335
+ prop.enumTypeId &&
336
+ prop.enumTypeId !== PATTERN_ELEMENT_ENUM_ID) {
180
337
  const resolved = resolveTypeId(prop.enumTypeId);
181
338
  if (resolved) {
182
339
  // Built-in enum — import from @ikas/bp-storefront
@@ -193,6 +350,9 @@ export function generateTypesFile(componentName, props, type, editorTypes, custo
193
350
  }
194
351
  }
195
352
  else {
353
+ // Non-ENUM props, plus the font-style (pattern-element) sentinel whose
354
+ // runtime value is a plain pattern-element id string → PROP_TYPE_TO_TS
355
+ // (PROP_TYPE_TO_TS["ENUM"] === "string"), no import emitted.
196
356
  const tsType = PROP_TYPE_TO_TS[prop.type];
197
357
  if (tsType?.includes("Ikas")) {
198
358
  const matches = tsType.match(/Ikas\w+/g);
@@ -298,6 +458,96 @@ export function collectUsedCustomTypeIds(customTypes, components) {
298
458
  }
299
459
  return used;
300
460
  }
461
+ /**
462
+ * Enum ids referenced by ENUM theme settings. Their union types must be emitted into
463
+ * `global-types.ts` even when no component prop uses the enum, so the setting's value-map
464
+ * entry (and `themeValue()`) can narrow to it.
465
+ */
466
+ export function collectThemeSettingEnumIds(themeSettings) {
467
+ const ids = new Set();
468
+ for (const s of themeSettings || []) {
469
+ if (s.type === "ENUM" && s.enumTypeId)
470
+ ids.add(s.enumTypeId);
471
+ }
472
+ return ids;
473
+ }
474
+ /**
475
+ * Map a global-variable kind to a TS type. Primitives narrowed; complex kinds stay `any`. An
476
+ * ENUM setting narrows to the union type THIS file declares for its enum: `resolveEnumTypeName`
477
+ * only knows the enums emitted above the theme block, so the map can never name an undeclared
478
+ * identifier. An ENUM whose enum is not emitted here — a deleted type, or a built-in `@ikas/*`
479
+ * enum (which the editor does not accept for a theme global, and whose import from
480
+ * `@ikas/bp-storefront` this generator cannot verify) — degrades to `string`.
481
+ */
482
+ function themeSettingTsType(setting, resolveEnumTypeName) {
483
+ switch (setting.type) {
484
+ case "NUMBER":
485
+ return "number";
486
+ case "BOOLEAN":
487
+ return "boolean";
488
+ case "TEXT":
489
+ case "RICH_TEXT":
490
+ case "COLOR":
491
+ return "string";
492
+ case "ENUM":
493
+ return ((setting.enumTypeId && resolveEnumTypeName(setting.enumTypeId)) ||
494
+ "string");
495
+ default:
496
+ return "any"; // IMAGE, BORDER, SHADOW — object shapes can drift from runtime
497
+ }
498
+ }
499
+ /**
500
+ * Import line for the typed theme-globals wrapper. Must sit at the top of the generated
501
+ * file (after the header). Emitted only when there are theme globals.
502
+ *
503
+ * A module augmentation can't deliver key-safety here: the runtime `getThemeSettingValue`
504
+ * is declared `(name: string): any`, so an added narrower overload never wins resolution —
505
+ * unknown keys still match `string` and return `any`. A generated wrapper whose parameter
506
+ * is `ThemeSettingName` (no `string` overload) does reject unknown keys and narrow the value.
507
+ */
508
+ const THEME_GLOBALS_IMPORT = `import { getThemeSettingValue as _getThemeSettingValueRaw } from "@ikas/bp-storefront";\n`;
509
+ /**
510
+ * Sentinel that marks a generated `global-types.ts` as containing the theme-globals block.
511
+ * This is the exported symbol authors import (stable public API), and it must stay a substring
512
+ * of what {@link generateThemeGlobalsBlock} emits — keep the two in sync if that output changes.
513
+ */
514
+ const THEME_GLOBALS_MARKER = "export type ThemeSettingName";
515
+ /**
516
+ * Build the theme-globals block for `src/global-types.ts`: the key union, a value-type map,
517
+ * and a type-safe `themeValue` wrapper (authors import it from `./global-types`). The wrapper
518
+ * is deliberately named differently from the SDK's `getThemeSettingValue` so authors can't
519
+ * accidentally import the untyped SDK version and lose key-safety (no shadowing).
520
+ * Returns "" when there are no globals (an empty union would resolve to `never`). The import
521
+ * the wrapper needs is emitted separately via {@link THEME_GLOBALS_IMPORT}.
522
+ */
523
+ function generateThemeGlobalsBlock(themeSettings, resolveEnumTypeName = () => null) {
524
+ if (!themeSettings || themeSettings.length === 0)
525
+ return "";
526
+ const sorted = [...themeSettings].sort((a, b) => a.name.localeCompare(b.name));
527
+ const unionLines = sorted
528
+ .map((s) => {
529
+ // Escape the JSDoc comment terminator to avoid prematurely closing the
530
+ // generated `/** ... */` block when a displayName contains `*/`.
531
+ const label = String(s.displayName).replace(/\*\//g, "*\\/");
532
+ return ` /** ${label} (${s.type}) */\n | ${JSON.stringify(s.name)}`;
533
+ })
534
+ .join("\n");
535
+ const mapLines = sorted
536
+ .map((s) => ` ${JSON.stringify(s.name)}: ${themeSettingTsType(s, resolveEnumTypeName)};`)
537
+ .join("\n");
538
+ return (`\n/** Stable keys of theme global variables defined in the editor's "Styles" panel. */\n` +
539
+ `export type ThemeSettingName =\n${unionLines};\n\n` +
540
+ `/** Narrowed value type per global key (primitives and ENUM unions; complex kinds are \`any\`). */\n` +
541
+ `type ThemeSettingValueMap = {\n${mapLines}\n};\n\n` +
542
+ `/**\n` +
543
+ ` * Type-safe theme global accessor — only accepts keys declared in the editor's Styles panel.\n` +
544
+ ` * Import this from \`./global-types\`, not \`getThemeSettingValue\` from \`@ikas/bp-storefront\`\n` +
545
+ ` * (that one is untyped: it accepts any string and returns \`any\`).\n` +
546
+ ` */\n` +
547
+ `export function themeValue<K extends ThemeSettingName>(name: K): ThemeSettingValueMap[K] {\n` +
548
+ ` return _getThemeSettingValueRaw(name) as ThemeSettingValueMap[K];\n` +
549
+ `}\n`);
550
+ }
301
551
  /**
302
552
  * Generate the content for `src/global-types.ts`.
303
553
  * Collects all custom enum types from editorTypes (preferred) or customTypes (fallback)
@@ -311,8 +561,11 @@ export function generateGlobalTypesFile(editorTypes, customTypes,
311
561
  * Callers should compute this from `config.components[*].props[*].enumTypeId`
312
562
  * so unused enums are excluded from `global-types.ts`.
313
563
  */
314
- usedEnumIds) {
564
+ usedEnumIds, themeSettings) {
315
565
  const header = `// Auto-generated global type definitions for ikas code components.\n// This file is regenerated automatically — do not edit manually.\n`;
566
+ // Enums an ENUM theme setting selects from are emitted regardless of `usedEnumIds`: the
567
+ // setting's value-map entry names them, so they must exist even if no prop uses them.
568
+ const forcedEnumIds = collectThemeSettingEnumIds(themeSettings);
316
569
  // Collect all custom enum types with their options
317
570
  // Use a Map keyed by type ID to deduplicate
318
571
  const enumTypes = new Map();
@@ -320,10 +573,10 @@ usedEnumIds) {
320
573
  if (editorTypes) {
321
574
  for (const t of editorTypes) {
322
575
  if (t.enumOptions && Object.keys(t.enumOptions).length > 0) {
323
- // Skip built-in types
324
- if (t.id.startsWith("@ikas/"))
576
+ // Skip built-in types (@ikas/* and the pattern-element font-style sentinel)
577
+ if (isBuiltInEnumTypeId(t.id))
325
578
  continue;
326
- if (usedEnumIds && !usedEnumIds.has(t.id))
579
+ if (usedEnumIds && !usedEnumIds.has(t.id) && !forcedEnumIds.has(t.id))
327
580
  continue;
328
581
  enumTypes.set(t.id, {
329
582
  name: toPascalCase(t.name),
@@ -339,9 +592,9 @@ usedEnumIds) {
339
592
  const id = t.id;
340
593
  if (enumTypes.has(id))
341
594
  continue;
342
- if (id.startsWith("@ikas/"))
595
+ if (isBuiltInEnumTypeId(id))
343
596
  continue;
344
- if (usedEnumIds && !usedEnumIds.has(id))
597
+ if (usedEnumIds && !usedEnumIds.has(id) && !forcedEnumIds.has(id))
345
598
  continue;
346
599
  if (t.enumOptions && Object.keys(t.enumOptions).length > 0) {
347
600
  enumTypes.set(id, {
@@ -352,17 +605,56 @@ usedEnumIds) {
352
605
  }
353
606
  }
354
607
  }
608
+ // The theme block may only name enum types declared in THIS file (see themeSettingTsType).
609
+ const resolveEnumTypeName = (enumTypeId) => enumTypes.get(enumTypeId)?.name ?? null;
610
+ const themeBlock = generateThemeGlobalsBlock(themeSettings, resolveEnumTypeName);
611
+ // The wrapper's runtime import must sit at the top of the file (after the header).
612
+ const themeImport = themeBlock ? THEME_GLOBALS_IMPORT : "";
355
613
  if (enumTypes.size === 0) {
356
- return `${header}// Custom enum types from the editor will appear here.\n\nexport {};\n`;
614
+ if (!themeBlock) {
615
+ return `${header}// Custom enum types from the editor will appear here.\n\nexport {};\n`;
616
+ }
617
+ return `${header}${themeImport}${themeBlock}`;
357
618
  }
358
619
  // Sort by type name for stable output
359
620
  const sorted = Array.from(enumTypes.values()).sort((a, b) => a.name.localeCompare(b.name));
360
- let content = `${header}\n`;
621
+ let content = `${header}${themeImport}\n`;
361
622
  for (const enumType of sorted) {
362
- const unionType = enumType.options.map(v => `"${v}"`).join(" | ");
623
+ const unionType = enumType.options.map((v) => `"${v}"`).join(" | ");
363
624
  content += `/** Enum type: ${enumType.id} */\nexport type ${enumType.name} = ${unionType};\n`;
364
625
  }
365
- return content;
626
+ return `${content}${themeBlock}`;
627
+ }
628
+ /**
629
+ * Decide whether freshly generated `global-types.ts` content should overwrite
630
+ * the existing file on disk.
631
+ *
632
+ * Guards the dev-server startup race: before the editor connects, the theme
633
+ * settings cache is empty, so the generated content omits the theme-globals
634
+ * block (`themeValue`, `ThemeSettingName`). Writing it would clobber the rich
635
+ * file left from the previous session and break every component that imports
636
+ * those exports until the editor reconnects and re-syncs. While theme settings
637
+ * are not yet cached, preserve an existing theme-globals block instead of
638
+ * stubbing it out — the editor's `onSyncTypes` is the authoritative writer once
639
+ * connected.
640
+ *
641
+ * @param existing current file content, or null if the file is absent
642
+ * @param next freshly generated content
643
+ * @param hasThemeSettingsCached whether the editor has synced theme settings yet
644
+ */
645
+ export function shouldWriteGlobalTypes(existing, next, hasThemeSettingsCached) {
646
+ // No file yet — always write so imports resolve.
647
+ if (existing === null)
648
+ return true;
649
+ // Identical content — skip to avoid file-watcher loops.
650
+ if (existing === next)
651
+ return false;
652
+ // Editor has synced theme settings — it's authoritative, write freely.
653
+ if (hasThemeSettingsCached)
654
+ return true;
655
+ // Not synced yet: write unless we'd drop an existing theme-globals block for a stub.
656
+ return (!existing.includes(THEME_GLOBALS_MARKER) ||
657
+ next.includes(THEME_GLOBALS_MARKER));
366
658
  }
367
659
  export function generateComponentFile(componentName, props, type) {
368
660
  const propsDestructure = props.map((p) => p.name).join(", ");
@@ -380,18 +672,233 @@ export function generateStylesFile(componentName, type) {
380
672
  return readTemplate(templateName, { KEBAB_NAME: kebabName });
381
673
  }
382
674
  /**
383
- * Update the barrel export file (src/components/index.ts) to include all components
675
+ * Update the barrel export file (src/components/index.ts) to include all components. Each is exported by its
676
+ * entry's folder, which is named after the function it exports when created; its name may change later.
384
677
  */
385
- export function updateBarrelExport(projectRoot, componentNames) {
678
+ export function updateBarrelExport(projectRoot, components) {
386
679
  const indexPath = path.resolve(projectRoot, "src/components/index.ts");
387
- const content = componentNames
388
- .map((name) => {
389
- const pascal = toPascalCase(name);
390
- return `export { ${pascal} } from "./${pascal}/index";`;
680
+ const content = components
681
+ .map((c) => {
682
+ const folder = path.basename(path.dirname(c.entry));
683
+ return `export { ${folder} } from "./${folder}/index";`;
391
684
  })
392
685
  .join("\n") + "\n";
393
686
  fs.writeFileSync(indexPath, content);
394
687
  }
688
+ /**
689
+ * A config visibility gate in wire form, or nothing at all — spread it.
690
+ *
691
+ * Config names the governing prop; the editor needs its id, and ids are minted here from position.
692
+ * A gate naming a prop this component does not have is dropped rather than passed on: the editor
693
+ * would show the control anyway, and a dangling id in the theme is worse than none.
694
+ */
695
+ export function resolveCondition(gate, propIdByName) {
696
+ if (!gate?.requiredProp)
697
+ return {};
698
+ const requiredPropId = propIdByName.get(gate.requiredProp);
699
+ if (!requiredPropId)
700
+ return {};
701
+ // An ENUM gate carries its option and no boolean state; a BOOLEAN one the reverse. Emitting only
702
+ // the half that applies keeps a stale option from a re-typed governing prop out of the theme.
703
+ if (gate.requiredEnumOptionValue !== undefined) {
704
+ return {
705
+ requiredPropId,
706
+ requiredEnumOptionId: gate.requiredEnumOptionId,
707
+ requiredEnumOptionValue: gate.requiredEnumOptionValue,
708
+ };
709
+ }
710
+ return { requiredPropId, requiredPropValue: gate.requiredPropValue ?? true };
711
+ }
712
+ /**
713
+ * Config prop name to the id this component's props are broadcast under.
714
+ *
715
+ * The formula is duplicated at each mapping site (dev broadcast, config rebuild, publish); this
716
+ * reads it back off whatever that site produced rather than restating it a fourth time.
717
+ */
718
+ export function propIdsByName(props) {
719
+ return new Map(props.map((prop) => [prop.name, prop.id]));
720
+ }
721
+ /**
722
+ * Sets a field, or removes it when emptied (a blank text, a map without entries, so the key leaves
723
+ * the config); a required field keeps its old value instead.
724
+ */
725
+ function setField(target, field, value, required = false) {
726
+ const fields = target;
727
+ const empty = !value || (typeof value === "object" && Object.keys(value).length === 0);
728
+ if (!empty)
729
+ fields[field] = value;
730
+ else if (!required)
731
+ delete fields[field];
732
+ }
733
+ /**
734
+ * Writes the technical texts the editor saved back onto a config component: the base fields and the
735
+ * editor-language `translations`. A store language's `propValues` on the component stay as they are,
736
+ * unless the editor sent its store content too (a component's, not a section's).
737
+ */
738
+ export function applyTechnicalTexts(component, texts) {
739
+ setField(component, "name", texts.name, true);
740
+ const locales = new Set([
741
+ ...Object.keys(component.translations || {}),
742
+ ...Object.keys(texts.translations),
743
+ ...Object.keys(texts.propValues || {}),
744
+ ]);
745
+ const translations = {};
746
+ locales.forEach((locale) => {
747
+ const { name: _name, propValues: configValues, ...rest } = component.translations?.[locale] || {};
748
+ const name = texts.translations[locale]?.name;
749
+ const propValues = texts.propValues
750
+ ? texts.propValues[locale]
751
+ : configValues;
752
+ const record = {
753
+ ...(name ? { name } : {}),
754
+ ...rest,
755
+ ...(propValues && Object.keys(propValues).length ? { propValues } : {}),
756
+ };
757
+ if (Object.keys(record).length > 0)
758
+ translations[locale] = record;
759
+ });
760
+ setField(component, "translations", translations);
761
+ const defaults = texts.defaults;
762
+ if (defaults)
763
+ component.props.forEach((prop) => {
764
+ if (!(prop.name in defaults))
765
+ return;
766
+ const value = defaults[prop.name];
767
+ if (value === null || value === undefined)
768
+ delete prop.defaultValue;
769
+ else
770
+ prop.defaultValue = value;
771
+ });
772
+ component.props.forEach((prop) => {
773
+ const text = texts.props[prop.name];
774
+ if (!text)
775
+ return;
776
+ setField(prop, "displayName", text.displayName, true);
777
+ setField(prop, "description", text.description);
778
+ setField(prop, "translations", text.translations);
779
+ });
780
+ // The editor knows a group by its global id; prop wrappers carry no texts of their own
781
+ (component.propGroups || [])
782
+ .flatMap((group) => [group, ...(group.children || [])])
783
+ .forEach((group) => {
784
+ const text = texts.propGroups[propGroupGlobalId(component.id, group.id)];
785
+ if (!text)
786
+ return;
787
+ setField(group, "name", text.name, true);
788
+ setField(group, "description", text.description);
789
+ setField(group, "translations", text.translations);
790
+ });
791
+ }
792
+ /**
793
+ * A config translation map (`{ en: { displayName: "Heading" } }`) as the editor's record list
794
+ * (`[{ locale: "en", displayName: "Heading" }]`); nothing for an empty or missing map, so the
795
+ * wire payload carries the key only when there is something to carry. Spread the result.
796
+ */
797
+ export function translationRecords(translations) {
798
+ const records = Object.entries(translations || {})
799
+ .filter(([, fields]) => fields && Object.keys(fields).length > 0)
800
+ .map(([locale, fields]) => ({ locale, ...fields }));
801
+ return records.length > 0 ? { translations: records } : {};
802
+ }
803
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
804
+ const _omit = (record, keys) => Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
805
+ /**
806
+ * What is wrong with a language's value of a prop as the config holds it (a text bare or `{ value }`); only the
807
+ * translations page's types (TEXT, RICH_TEXT, IMAGE, IMAGE_LIST, VIDEO) have one.
808
+ */
809
+ function translatedValueError(type, value) {
810
+ const isImage = (image) => isObject(image) &&
811
+ (typeof image.id === "string" || typeof image.altText === "string");
812
+ switch (type) {
813
+ case "TEXT":
814
+ case "RICH_TEXT": {
815
+ const text = isObject(value) ? value.value : value;
816
+ return typeof text === "string" ? null : "must be a text";
817
+ }
818
+ case "IMAGE":
819
+ return isImage(value) ? null : 'must be { "id", "altText"? }';
820
+ case "IMAGE_LIST":
821
+ return isObject(value) &&
822
+ Array.isArray(value.images) &&
823
+ value.images.every(isImage)
824
+ ? null
825
+ : 'must be { "images": [{ "id", "altText"? }] }';
826
+ case "VIDEO":
827
+ return isObject(value) && isObject(value.video)
828
+ ? null
829
+ : 'must be { "video": { "id" }, "thumbnailImage"? }';
830
+ default:
831
+ return `is a ${type} prop, which has no translations`;
832
+ }
833
+ }
834
+ /** Checks a `translations` map (or an older config's record list): its languages, `fields` and their values. */
835
+ function checkTranslations(problems, where, translations, fields, check = (field, value, at) => {
836
+ if (typeof value !== "string")
837
+ problems.errors.push(`${at}: "${field}" must be a text`);
838
+ }) {
839
+ if (translations === undefined)
840
+ return;
841
+ // An older config keeps the editor's records (`[{ id?, locale, ... }]`), which `translationRecords` takes too
842
+ const entries = Array.isArray(translations)
843
+ ? translations.map((record, index) => isObject(record)
844
+ ? [String(record.locale), _omit(record, ["id", "locale"])]
845
+ : [String(index), record])
846
+ : isObject(translations)
847
+ ? Object.entries(translations)
848
+ : null;
849
+ if (!entries) {
850
+ problems.errors.push(`${where}: translations must be an object of languages`);
851
+ return;
852
+ }
853
+ entries.forEach(([locale, record]) => {
854
+ const at = `${where} translations.${locale}`;
855
+ if (!isLanguageCode(locale))
856
+ return problems.warnings.push(`${at}: "${locale}" is not a language code (en, de, pt-BR); left aside`);
857
+ if (!isObject(record))
858
+ return problems.errors.push(`${at}: must be an object`);
859
+ Object.entries(record).forEach(([field, value]) => {
860
+ if (!fields.includes(field))
861
+ return problems.warnings.push(`${at}: unknown field "${field}" (${fields.join(", ")}); left aside`);
862
+ check(field, value, `${at}.${field}`);
863
+ });
864
+ });
865
+ }
866
+ /**
867
+ * What the translations a config holds have wrong: a component's (its name and a language's prop values), its
868
+ * props' and its prop groups' (their labels). A prop value is checked against its prop's type.
869
+ */
870
+ export function checkConfigTranslations(components) {
871
+ const problems = { errors: [], warnings: [] };
872
+ const groups = (list = []) => list.flatMap((group) => [group, ...groups(group.children)]);
873
+ components.forEach((component) => {
874
+ const at = `${component.name}`;
875
+ checkTranslations(problems, at, component.translations, ["name", "propValues"], (field, value, where) => {
876
+ if (field === "name") {
877
+ if (typeof value !== "string")
878
+ problems.errors.push(`${where}: must be a text`);
879
+ return;
880
+ }
881
+ if (!isObject(value))
882
+ return problems.errors.push(`${where}: must be an object of prop values`);
883
+ Object.entries(value).forEach(([name, propValue]) => {
884
+ const prop = component.props.find((p) => p.name === name);
885
+ if (!prop)
886
+ return problems.warnings.push(`${where}: "${name}" is no prop of the component; left aside`);
887
+ const error = translatedValueError(prop.type, propValue);
888
+ if (error)
889
+ problems.errors.push(`${where}: "${name}" ${error}`);
890
+ });
891
+ });
892
+ component.props.forEach((prop) => checkTranslations(problems, `${at}.${prop.name}`, prop.translations, [
893
+ "displayName",
894
+ "description",
895
+ ]));
896
+ groups(component.propGroups).forEach((group) => checkTranslations(problems, `${at} group ${group.id}`, group.translations, ["name", "description"]));
897
+ });
898
+ return problems;
899
+ }
900
+ /** The editor's id of a config prop group: the component's id prefixed, so it is unique across components. */
901
+ export const propGroupGlobalId = (componentId, groupId) => `${componentId}_pg_${groupId}`;
395
902
  /**
396
903
  * Convert flat config-format prop groups into an IThemePropGroup[] hierarchy.
397
904
  *
@@ -405,11 +912,14 @@ export function updateBarrelExport(projectRoot, componentNames) {
405
912
  */
406
913
  export function buildPropGroupHierarchy(componentId, propGroups, props) {
407
914
  const result = [];
915
+ // A group names its governing prop; the leaf wrappers below carry no gate of their own, because a
916
+ // prop's gate travels on the prop itself (see `resolveCondition` at each prop mapping site).
917
+ const propIdByName = propIdsByName(props);
408
918
  for (const group of propGroups) {
409
- const groupGlobalId = `${componentId}_pg_${group.id}`;
919
+ const groupGlobalId = propGroupGlobalId(componentId, group.id);
410
920
  // Collect props assigned to this group
411
- const groupProps = props.filter(p => p.groupId === group.id);
412
- const propChildren = groupProps.map(p => ({
921
+ const groupProps = props.filter((p) => p.groupId === group.id);
922
+ const propChildren = groupProps.map((p) => ({
413
923
  id: `${componentId}_pgp_${p.id}`,
414
924
  propId: p.id,
415
925
  }));
@@ -417,10 +927,10 @@ export function buildPropGroupHierarchy(componentId, propGroups, props) {
417
927
  let nestedChildren = [];
418
928
  if (group.children) {
419
929
  for (const child of group.children) {
420
- const childGlobalId = `${componentId}_pg_${child.id}`;
930
+ const childGlobalId = propGroupGlobalId(componentId, child.id);
421
931
  // Collect props assigned to this child group
422
- const childProps = props.filter(p => p.groupId === child.id);
423
- const childPropChildren = childProps.map(p => ({
932
+ const childProps = props.filter((p) => p.groupId === child.id);
933
+ const childPropChildren = childProps.map((p) => ({
424
934
  id: `${componentId}_pgp_${p.id}`,
425
935
  propId: p.id,
426
936
  }));
@@ -428,7 +938,11 @@ export function buildPropGroupHierarchy(componentId, propGroups, props) {
428
938
  id: childGlobalId,
429
939
  name: child.name,
430
940
  ...(child.description ? { description: child.description } : {}),
431
- ...(childPropChildren.length > 0 ? { children: childPropChildren } : {}),
941
+ ...translationRecords(child.translations),
942
+ ...resolveCondition(child, propIdByName),
943
+ ...(childPropChildren.length > 0
944
+ ? { children: childPropChildren }
945
+ : {}),
432
946
  });
433
947
  }
434
948
  }
@@ -437,19 +951,118 @@ export function buildPropGroupHierarchy(componentId, propGroups, props) {
437
951
  id: groupGlobalId,
438
952
  name: group.name,
439
953
  ...(group.description ? { description: group.description } : {}),
954
+ ...translationRecords(group.translations),
955
+ ...resolveCondition(group, propIdByName),
440
956
  ...(allChildren.length > 0 ? { children: allChildren } : {}),
441
957
  });
442
958
  }
443
959
  return result;
444
960
  }
961
+ /** Every group id under `group`, itself included — the groups a prop assigned "inside" it can sit in. */
962
+ export function groupIdsUnder(group) {
963
+ return new Set([
964
+ group.id,
965
+ ...(group.children || []).map((child) => child.id),
966
+ ]);
967
+ }
968
+ /**
969
+ * Validate a visibility condition — the `requiredProp` family on a prop or a prop group — against
970
+ * the component it lives on. Returns an error message, or null when it is sound (or absent).
971
+ *
972
+ * What is rejected, and why each one would otherwise strand a control with no way to reach it:
973
+ *
974
+ * - a name no prop on this component has, or a prop that is not BOOLEAN — nothing to read a
975
+ * true/false off, so the gate could never resolve;
976
+ * - a prop gating itself, or a chain of props that comes back around to it — every prop in the
977
+ * cycle stays hidden forever, including the toggles that would let someone out of it;
978
+ * - an ENUM gate with no option to wait for, or an option value hung on a BOOLEAN prop;
979
+ * - a group gated by a toggle that lives inside it — turning that toggle off takes the toggle away
980
+ * with the rest of the group.
981
+ */
982
+ export function visibilityGateError(component, gate, where, opts = {}) {
983
+ const { requiredProp, requiredPropValue, requiredEnumOptionValue } = gate;
984
+ if (requiredProp === undefined || requiredProp === null) {
985
+ return requiredPropValue === undefined &&
986
+ requiredEnumOptionValue === undefined
987
+ ? null
988
+ : `${where} declares a visibility state with no requiredProp to read it off. Name the prop that gates it, or drop both.`;
989
+ }
990
+ if (typeof requiredProp !== "string" || !requiredProp) {
991
+ return `requiredProp on ${where} must be the name of a BOOLEAN or ENUM prop on this component.`;
992
+ }
993
+ if (requiredPropValue !== undefined &&
994
+ typeof requiredPropValue !== "boolean") {
995
+ return `requiredPropValue on ${where} must be true or false (it defaults to true).`;
996
+ }
997
+ if (opts.selfPropName && requiredProp === opts.selfPropName) {
998
+ return `${where} cannot gate itself on "${requiredProp}".`;
999
+ }
1000
+ const governing = component.props.find((prop) => prop.name === requiredProp);
1001
+ if (!governing) {
1002
+ const names = component.props.map((prop) => prop.name).join(", ");
1003
+ return `requiredProp "${requiredProp}" on ${where} is not a prop of component "${component.name}". Available props: ${names || "(none)"}.`;
1004
+ }
1005
+ if (governing.type !== "BOOLEAN" && governing.type !== "ENUM") {
1006
+ return `requiredProp "${requiredProp}" on ${where} is a ${governing.type} prop; only a BOOLEAN or ENUM prop can gate visibility.`;
1007
+ }
1008
+ // An ENUM gate is meaningless without the option it waits for: nothing would ever match it, so
1009
+ // the node would never appear at all.
1010
+ if (governing.type === "ENUM" &&
1011
+ typeof requiredEnumOptionValue !== "string") {
1012
+ return `requiredProp "${requiredProp}" on ${where} is an ENUM prop, so requiredEnumOptionValue must name the option value that shows it.`;
1013
+ }
1014
+ if (governing.type === "BOOLEAN" && requiredEnumOptionValue !== undefined) {
1015
+ return `requiredEnumOptionValue on ${where} has no meaning against the BOOLEAN prop "${requiredProp}" — use requiredPropValue instead.`;
1016
+ }
1017
+ if (opts.group &&
1018
+ governing.groupId &&
1019
+ groupIdsUnder(opts.group).has(governing.groupId)) {
1020
+ return `requiredProp "${requiredProp}" on ${where} sits inside that group — hiding the group would take that control with it, leaving nothing to bring it back.`;
1021
+ }
1022
+ // Walk the chain the governing prop hangs on. It cannot come back to this prop, and it cannot
1023
+ // close on itself either — a cycle anywhere in it hides everything on it.
1024
+ const byName = new Map(component.props.map((prop) => [prop.name, prop]));
1025
+ const seen = new Set([requiredProp]);
1026
+ let current = governing.requiredProp;
1027
+ while (current) {
1028
+ if (opts.selfPropName && current === opts.selfPropName) {
1029
+ return `requiredProp "${requiredProp}" on ${where} depends on it in turn — the two would hide each other permanently.`;
1030
+ }
1031
+ if (seen.has(current)) {
1032
+ return `requiredProp "${requiredProp}" on ${where} sits in a visibility cycle through "${current}" — everything on it would stay hidden.`;
1033
+ }
1034
+ seen.add(current);
1035
+ current = byName.get(current)?.requiredProp;
1036
+ }
1037
+ return null;
1038
+ }
445
1039
  /**
446
1040
  * Validate prop groups configuration for a component.
447
1041
  * Returns an array of error messages (empty = valid).
448
1042
  */
449
1043
  export function validatePropGroups(component) {
450
1044
  const errors = [];
451
- const propGroups = component.propGroups;
452
- if (!propGroups || propGroups.length === 0)
1045
+ const propGroups = component.propGroups || [];
1046
+ // A prop can name a visibility gate whether or not this component has groups at all, so gates are
1047
+ // checked before the group walk bails out below.
1048
+ for (const prop of component.props) {
1049
+ const error = visibilityGateError(component, prop, `prop "${prop.name}"`, {
1050
+ selfPropName: prop.name,
1051
+ });
1052
+ if (error)
1053
+ errors.push(error);
1054
+ }
1055
+ for (const group of [
1056
+ ...propGroups,
1057
+ ...propGroups.flatMap((group) => group.children || []),
1058
+ ]) {
1059
+ const error = visibilityGateError(component, group, `group "${group.id}"`, {
1060
+ group,
1061
+ });
1062
+ if (error)
1063
+ errors.push(error);
1064
+ }
1065
+ if (propGroups.length === 0)
453
1066
  return errors;
454
1067
  // Collect all group IDs and check uniqueness
455
1068
  const allIds = new Set();
@@ -528,7 +1141,9 @@ export function movePropGroupInTree(propGroups, propGroupId, parentGroupId, inde
528
1141
  const found = findPropGroup(propGroups, propGroupId);
529
1142
  if (!found)
530
1143
  throw new Error(`Prop group "${propGroupId}" not found`);
531
- if (parentGroupId && found.group.children && found.group.children.length > 0) {
1144
+ if (parentGroupId &&
1145
+ found.group.children &&
1146
+ found.group.children.length > 0) {
532
1147
  throw new Error(`Cannot nest "${propGroupId}": it already has child groups`);
533
1148
  }
534
1149
  if (parentGroupId) {
@@ -542,10 +1157,10 @@ export function movePropGroupInTree(propGroups, propGroupId, parentGroupId, inde
542
1157
  }
543
1158
  let roots = propGroups;
544
1159
  if (found.parent) {
545
- found.parent.children = (found.parent.children || []).filter(c => c.id !== propGroupId);
1160
+ found.parent.children = (found.parent.children || []).filter((c) => c.id !== propGroupId);
546
1161
  }
547
1162
  else {
548
- roots = roots.filter(g => g.id !== propGroupId);
1163
+ roots = roots.filter((g) => g.id !== propGroupId);
549
1164
  }
550
1165
  if (parentGroupId) {
551
1166
  const target = findPropGroup(roots, parentGroupId);
@@ -553,13 +1168,17 @@ export function movePropGroupInTree(propGroups, propGroupId, parentGroupId, inde
553
1168
  throw new Error(`Target parent group "${parentGroupId}" not found after removal`);
554
1169
  if (!target.group.children)
555
1170
  target.group.children = [];
556
- const insertAt = typeof index === "number" && index >= 0 && index <= target.group.children.length
1171
+ const insertAt = typeof index === "number" &&
1172
+ index >= 0 &&
1173
+ index <= target.group.children.length
557
1174
  ? index
558
1175
  : target.group.children.length;
559
1176
  target.group.children.splice(insertAt, 0, found.group);
560
1177
  }
561
1178
  else {
562
- const insertAt = typeof index === "number" && index >= 0 && index <= roots.length ? index : roots.length;
1179
+ const insertAt = typeof index === "number" && index >= 0 && index <= roots.length
1180
+ ? index
1181
+ : roots.length;
563
1182
  roots.splice(insertAt, 0, found.group);
564
1183
  }
565
1184
  return roots;