storyblok 4.20.0-alpha.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -698,6 +698,8 @@ const API_ACTIONS = {
698
698
  pull_component_internal_tags: "Failed to pull component internal tags",
699
699
  push_component: "Failed to push component",
700
700
  push_component_group: "Failed to push component group",
701
+ push_component_folder: "Failed to create component folder",
702
+ delete_component_folder: "Failed to delete component folder",
701
703
  push_component_preset: "Failed to push component preset",
702
704
  push_component_internal_tag: "Failed to push component internal tag",
703
705
  update_component: "Failed to update component",
@@ -806,7 +808,11 @@ class APIError extends Error {
806
808
  if (this.code === 422) {
807
809
  const responseData = this.response?.data;
808
810
  if (responseData?.name?.[0] === "has already been taken") {
809
- this.message = "A component with this name already exists";
811
+ if (action === "push_component_folder") {
812
+ this.message = "A component folder with this name already exists";
813
+ } else if (action === "push_component" || action === "update_component") {
814
+ this.message = "A component with this name already exists";
815
+ }
810
816
  }
811
817
  Object.entries(responseData || {}).forEach(([key, errors]) => {
812
818
  if (Array.isArray(errors)) {
@@ -5826,10 +5832,8 @@ const getPropertyTypeAnnotation = (property, prefix, suffix) => {
5826
5832
  return { type: property.type };
5827
5833
  }
5828
5834
  let type = "unknown";
5829
- const options = property.options && property.options.length > 0 ? property.options.map((item) => item.value) : [];
5830
- if (options.length > 0 && property.exclude_empty_option !== true) {
5831
- options.unshift("");
5832
- }
5835
+ const optionValues = property.options && property.options.length > 0 ? property.options.map((item) => item.value).filter((value) => value !== "") : [];
5836
+ const options = optionValues.length > 0 && !property.required ? ["", ...optionValues] : optionValues;
5833
5837
  if (property.source === "internal_stories") {
5834
5838
  if (property.filter_content_type) {
5835
5839
  return {
@@ -6182,11 +6186,18 @@ const generateStoryblokTypes = async (options = {}) => {
6182
6186
  const pushDatasource = async (spaceId, datasource) => {
6183
6187
  try {
6184
6188
  const client = getMapiClient();
6189
+ const { dimensions, ...datasourceFields } = datasource;
6190
+ const dimensionsAttributes = (dimensions ?? []).filter((dimension) => dimension.entry_value).map((dimension) => ({ name: dimension.name ?? dimension.entry_value ?? "", entry_value: dimension.entry_value ?? "" }));
6185
6191
  const { data } = await client.datasources.create({
6186
6192
  path: {
6187
6193
  space_id: Number(spaceId)
6188
6194
  },
6189
- body: { datasource },
6195
+ body: {
6196
+ datasource: {
6197
+ ...datasourceFields,
6198
+ ...dimensionsAttributes.length > 0 && { dimensions_attributes: dimensionsAttributes }
6199
+ }
6200
+ },
6190
6201
  throwOnError: true
6191
6202
  });
6192
6203
  return data.datasource;
@@ -6221,13 +6232,14 @@ const upsertDatasource = async (space, datasource, existingId) => {
6221
6232
  const pushDatasourceEntry = async (spaceId, datasourceId, entry, position) => {
6222
6233
  try {
6223
6234
  const client = getMapiClient();
6235
+ const { dimension_values, ...entryFields } = entry;
6224
6236
  const { data } = await client.datasourceEntries.create({
6225
6237
  path: {
6226
6238
  space_id: Number(spaceId)
6227
6239
  },
6228
6240
  body: {
6229
6241
  datasource_entry: {
6230
- ...entry,
6242
+ ...entryFields,
6231
6243
  value: entry.value ?? "",
6232
6244
  datasource_id: datasourceId,
6233
6245
  ...position != null && { position }
@@ -6243,13 +6255,14 @@ const pushDatasourceEntry = async (spaceId, datasourceId, entry, position) => {
6243
6255
  const updateDatasourceEntry = async (spaceId, entryId, entry, position) => {
6244
6256
  try {
6245
6257
  const client = getMapiClient();
6258
+ const { dimension_values, ...entryFields } = entry;
6246
6259
  await client.datasourceEntries.update(entryId, {
6247
6260
  path: {
6248
6261
  space_id: Number(spaceId)
6249
6262
  },
6250
6263
  body: {
6251
6264
  datasource_entry: {
6252
- ...entry,
6265
+ ...entryFields,
6253
6266
  ...position != null && { position }
6254
6267
  }
6255
6268
  },
@@ -6259,6 +6272,27 @@ const updateDatasourceEntry = async (spaceId, entryId, entry, position) => {
6259
6272
  handleAPIError("update_datasource", error, `Failed to update datasource entry ${entry.name}`);
6260
6273
  }
6261
6274
  };
6275
+ const updateDatasourceEntryDimension = async (spaceId, entryId, entry, dimensionId, dimensionValue) => {
6276
+ try {
6277
+ const client = getMapiClient();
6278
+ await client.datasourceEntries.update(entryId, {
6279
+ path: {
6280
+ space_id: Number(spaceId)
6281
+ },
6282
+ query: { dimension_id: dimensionId },
6283
+ body: {
6284
+ datasource_entry: {
6285
+ name: entry.name,
6286
+ value: entry.value ?? "",
6287
+ dimension_value: dimensionValue
6288
+ }
6289
+ },
6290
+ throwOnError: true
6291
+ });
6292
+ } catch (error) {
6293
+ handleAPIError("update_datasource", error, `Failed to update datasource entry ${entry.name} for dimension ${dimensionId}`);
6294
+ }
6295
+ };
6262
6296
  const upsertDatasourceEntry = async (space, datasourceId, entry, existingId, position) => {
6263
6297
  if (existingId) {
6264
6298
  await updateDatasourceEntry(space, existingId, entry, position);
@@ -6418,7 +6452,7 @@ const datasourcesCommand = program$7.command(commands.DATASOURCES).alias("ds").d
6418
6452
 
6419
6453
  const DEFAULT_DATASOURCES_FILENAME = "datasources";
6420
6454
 
6421
- const fetchDatasourceEntries = async (spaceId, datasourceId) => {
6455
+ const fetchDatasourceEntries = async (spaceId, datasourceId, dimensionId) => {
6422
6456
  try {
6423
6457
  const client = getMapiClient();
6424
6458
  return await fetchAllPages(
@@ -6428,7 +6462,9 @@ const fetchDatasourceEntries = async (spaceId, datasourceId) => {
6428
6462
  },
6429
6463
  query: {
6430
6464
  datasource_id: datasourceId,
6431
- page
6465
+ page,
6466
+ // MAPI matches `dimension` against the numeric dimension id.
6467
+ ...dimensionId != null && { dimension: dimensionId }
6432
6468
  },
6433
6469
  throwOnError: true
6434
6470
  }),
@@ -6438,6 +6474,34 @@ const fetchDatasourceEntries = async (spaceId, datasourceId) => {
6438
6474
  handleAPIError("pull_datasources", error);
6439
6475
  }
6440
6476
  };
6477
+ const fetchDatasourceEntriesWithDimensions = async (spaceId, datasource) => {
6478
+ const defaultEntries = await fetchDatasourceEntries(spaceId, datasource.id) ?? [];
6479
+ const entries = defaultEntries.map(({ dimension_value, ...rest }) => rest);
6480
+ const dimensions = datasource.dimensions ?? [];
6481
+ if (!dimensions.length) {
6482
+ return entries;
6483
+ }
6484
+ const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
6485
+ for (const dimension of dimensions) {
6486
+ if (dimension.id == null || !dimension.entry_value) {
6487
+ continue;
6488
+ }
6489
+ const dimensionEntries = await fetchDatasourceEntries(spaceId, datasource.id, dimension.id) ?? [];
6490
+ for (const dimensionEntry of dimensionEntries) {
6491
+ const value = dimensionEntry.dimension_value;
6492
+ if (!value) {
6493
+ continue;
6494
+ }
6495
+ const entry = entriesById.get(dimensionEntry.id);
6496
+ if (!entry) {
6497
+ continue;
6498
+ }
6499
+ entry.dimension_values ??= {};
6500
+ entry.dimension_values[dimension.entry_value] = value;
6501
+ }
6502
+ }
6503
+ return entries;
6504
+ };
6441
6505
  const fetchDatasources = async (spaceId) => {
6442
6506
  try {
6443
6507
  const client = getMapiClient();
@@ -6458,7 +6522,7 @@ const fetchDatasources = async (spaceId) => {
6458
6522
  if (!ds.id) {
6459
6523
  return { ...ds, entries: [] };
6460
6524
  }
6461
- const entries = await fetchDatasourceEntries(spaceId, ds.id);
6525
+ const entries = await fetchDatasourceEntriesWithDimensions(spaceId, ds);
6462
6526
  return { ...ds, entries };
6463
6527
  })
6464
6528
  );
@@ -6487,8 +6551,8 @@ const fetchDatasource = async (spaceId, datasourceName) => {
6487
6551
  if (!found) {
6488
6552
  return void 0;
6489
6553
  }
6490
- const entries = await fetchDatasourceEntries(spaceId, found.id);
6491
- return { ...found, entries: entries || [] };
6554
+ const entries = await fetchDatasourceEntriesWithDimensions(spaceId, found);
6555
+ return { ...found, entries };
6492
6556
  } catch (error) {
6493
6557
  handleAPIError("pull_datasources", error, `Failed to fetch datasource ${datasourceName}`);
6494
6558
  }
@@ -6586,6 +6650,14 @@ pullCmd$2.action(async (datasourceName, options, command) => {
6586
6650
  }
6587
6651
  });
6588
6652
 
6653
+ function dimensionValuesEqual(a, b) {
6654
+ const aKeys = Object.keys(a ?? {});
6655
+ const bKeys = Object.keys(b ?? {});
6656
+ if (aKeys.length !== bKeys.length) {
6657
+ return false;
6658
+ }
6659
+ return aKeys.every((key) => a?.[key] === b?.[key]);
6660
+ }
6589
6661
  const pushCmd$2 = datasourcesCommand.command("push [datasourceName]").description(`Push your space's datasources schema as json`).option("-f, --from <from>", "source space id").option("--fi, --filter <filter>", "glob filter to apply to the datasources before pushing").option("--sf, --separate-files", "Read from separate files instead of consolidated files").option("--su, --suffix <suffix>", "Load only files matching *.<suffix>.json (e.g. datasources.dev.json)").option("-s, --space <space>", "space ID");
6590
6662
  pushCmd$2.action(async (datasourceName, options, command) => {
6591
6663
  konsola.title(`${commands.DATASOURCES}`, colorPalette.DATASOURCES, datasourceName ? `Pushing datasource ${datasourceName}...` : "Pushing datasources...");
@@ -6657,19 +6729,39 @@ pushCmd$2.action(async (datasourceName, options, command) => {
6657
6729
  results.successful.push(datasource.name);
6658
6730
  const localEntries = entries ?? [];
6659
6731
  const existingEntries = existingDatasource?.entries ?? [];
6732
+ const dimensionWarnings = [];
6660
6733
  const existingEntryMap = new Map(existingEntries.map((e, idx) => [e.name, { entry: e, position: idx + 1 }]));
6661
6734
  for (let i = 0; i < localEntries.length; i++) {
6662
6735
  const entry = localEntries[i];
6663
6736
  const existing = existingEntryMap.get(entry.name);
6664
6737
  const existingEntryId = existing?.entry.id;
6665
6738
  const targetPosition = i + 1;
6666
- if (existing && existing.entry.value === entry.value && existing.entry.dimension_value === entry.dimension_value && existing.position === targetPosition) {
6739
+ if (existing && existing.entry.value === entry.value && dimensionValuesEqual(existing.entry.dimension_values, entry.dimension_values) && existing.position === targetPosition) {
6667
6740
  logger.info("Skipped datasource entry (unchanged)", { datasource: datasource.name, entry: entry.name, position: targetPosition });
6668
6741
  continue;
6669
6742
  }
6670
6743
  try {
6671
- await upsertDatasourceEntry(space, result.id, entry, existingEntryId, i + 1);
6744
+ const upserted = await upsertDatasourceEntry(space, result.id, entry, existingEntryId, i + 1);
6745
+ const entryId = existingEntryId ?? upserted?.id;
6672
6746
  logger.info(existingEntryId ? "Updated datasource entry" : "Created datasource entry", { datasource: datasource.name, entry: entry.name, position: i + 1 });
6747
+ if (entryId != null && entry.dimension_values !== void 0) {
6748
+ const localDimensionValues = entry.dimension_values;
6749
+ const targetDimensionValues = existing?.entry.dimension_values ?? {};
6750
+ const dimensionCodes = /* @__PURE__ */ new Set([...Object.keys(localDimensionValues), ...Object.keys(targetDimensionValues)]);
6751
+ for (const code of dimensionCodes) {
6752
+ const localValue = localDimensionValues[code] ?? "";
6753
+ const targetValue = targetDimensionValues[code] ?? "";
6754
+ if (localValue === targetValue) {
6755
+ continue;
6756
+ }
6757
+ const dimension = result.dimensions?.find((d) => d.entry_value === code);
6758
+ if (!dimension?.id) {
6759
+ dimensionWarnings.push(`Skipping dimension "${code}" for entry "${entry.name}": no matching dimension in target space ${space}`);
6760
+ continue;
6761
+ }
6762
+ await updateDatasourceEntryDimension(space, entryId, entry, dimension.id, localValue);
6763
+ }
6764
+ }
6673
6765
  } catch (entryError) {
6674
6766
  results.failed.push({ name: datasource.name, error: entryError });
6675
6767
  spinner.failed(`${chalk.hex(colorPalette.DATASOURCES)(datasource.name)} - Failed in ${spinner.elapsedTime.toFixed(2)}ms`);
@@ -6687,6 +6779,7 @@ pushCmd$2.action(async (datasourceName, options, command) => {
6687
6779
  }
6688
6780
  }
6689
6781
  spinner.succeed(`${chalk.hex(colorPalette.DATASOURCES)(datasource.name)} - Completed in ${spinner.elapsedTime.toFixed(2)}ms`);
6782
+ dimensionWarnings.forEach((warning) => konsola.warn(warning));
6690
6783
  } else {
6691
6784
  results.failed.push({ name: datasource.name, error: result });
6692
6785
  spinner.failed(`${chalk.hex(colorPalette.DATASOURCES)(datasource.name)} - Failed in ${spinner.elapsedTime.toFixed(2)}ms`);
@@ -7820,7 +7913,7 @@ const extractAssetSizeFromFilename = (filename) => {
7820
7913
  }
7821
7914
  try {
7822
7915
  const segments = new URL(filename).pathname.split("/");
7823
- return segments.find((segment) => /^\d+x\d+$/.test(segment));
7916
+ return segments.find((segment) => /^\d+x\d+$/i.test(segment));
7824
7917
  } catch {
7825
7918
  return void 0;
7826
7919
  }
@@ -10698,6 +10791,11 @@ function applyDefaults(entity, defaults) {
10698
10791
  return result;
10699
10792
  }
10700
10793
  const INDENT = " ";
10794
+ class RawCode {
10795
+ constructor(code) {
10796
+ this.code = code;
10797
+ }
10798
+ }
10701
10799
  function quoteString(value) {
10702
10800
  const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
10703
10801
  return `'${escaped}'`;
@@ -10708,6 +10806,9 @@ function formatValue(value, depth) {
10708
10806
  if (value === null || value === void 0) {
10709
10807
  return String(value);
10710
10808
  }
10809
+ if (value instanceof RawCode) {
10810
+ return value.code;
10811
+ }
10711
10812
  if (typeof value === "string") {
10712
10813
  return quoteString(value);
10713
10814
  }
@@ -10753,10 +10854,69 @@ function stripKeys(obj, keysToStrip) {
10753
10854
  return result;
10754
10855
  }
10755
10856
 
10857
+ function buildGroupPathByUuid(folders) {
10858
+ const byUuid = new Map(folders.map((folder) => [folder.uuid, folder]));
10859
+ const cache = /* @__PURE__ */ new Map();
10860
+ function pathFor(uuid, visited) {
10861
+ if (!uuid) {
10862
+ return [];
10863
+ }
10864
+ const cached = cache.get(uuid);
10865
+ if (cached) {
10866
+ return cached;
10867
+ }
10868
+ const folder = byUuid.get(uuid);
10869
+ if (!folder) {
10870
+ return [];
10871
+ }
10872
+ if (visited.has(uuid)) {
10873
+ return [];
10874
+ }
10875
+ visited.add(uuid);
10876
+ const path = [...pathFor(folder.parent_uuid, visited), slugify(folder.name)];
10877
+ cache.set(uuid, path);
10878
+ return path;
10879
+ }
10880
+ for (const folder of folders) {
10881
+ pathFor(folder.uuid, /* @__PURE__ */ new Set());
10882
+ }
10883
+ return cache;
10884
+ }
10885
+ function slugifyPath(displayPath) {
10886
+ return displayPath.split("/").map((segment) => slugify(segment)).filter(Boolean).join("/");
10887
+ }
10888
+ function expandFolderPath(displayPath) {
10889
+ const segments = displayPath.split("/").map((segment) => ({ name: segment, slug: slugify(segment) })).filter((segment) => segment.slug !== "");
10890
+ const result = [];
10891
+ let parentPath = null;
10892
+ for (const { name, slug } of segments) {
10893
+ const path = parentPath ? `${parentPath}/${slug}` : slug;
10894
+ result.push({ name, path, parentPath });
10895
+ parentPath = path;
10896
+ }
10897
+ return result;
10898
+ }
10899
+
10756
10900
  function mapFieldToWire(field) {
10757
10901
  const { name, allow, datasource, ...rest } = field;
10758
10902
  const value = { ...rest };
10759
- if (allow !== void 0) {
10903
+ if (allow !== void 0 && Array.isArray(allow)) {
10904
+ const folderPaths = allow.filter((entry) => isRecord(entry) && typeof entry.folder === "string").map((entry) => slugifyPath(entry.folder));
10905
+ const blockNames = allow.filter((entry) => typeof entry === "string");
10906
+ if (folderPaths.length > 0) {
10907
+ value.component_group_whitelist = folderPaths;
10908
+ if (rest.type === "bloks" || rest.type === "richtext") {
10909
+ value.restrict_components = true;
10910
+ value.restrict_type = "groups";
10911
+ }
10912
+ } else {
10913
+ value.component_whitelist = blockNames;
10914
+ if (rest.type === "bloks") {
10915
+ value.restrict_components = true;
10916
+ value.restrict_type = "";
10917
+ }
10918
+ }
10919
+ } else if (allow !== void 0) {
10760
10920
  value.component_whitelist = allow;
10761
10921
  if (rest.type === "bloks") {
10762
10922
  value.restrict_components = true;
@@ -10769,7 +10929,7 @@ function mapFieldToWire(field) {
10769
10929
  return { name: typeof name === "string" ? name : "", value };
10770
10930
  }
10771
10931
  function mapBlockToWire(block) {
10772
- const { fields, ...rest } = block;
10932
+ const { fields, folder, ...rest } = block;
10773
10933
  const schema = {};
10774
10934
  if (Array.isArray(fields)) {
10775
10935
  for (const field of fields) {
@@ -10782,7 +10942,11 @@ function mapBlockToWire(block) {
10782
10942
  }
10783
10943
  }
10784
10944
  }
10785
- return { ...rest, schema };
10945
+ return {
10946
+ ...rest,
10947
+ ...folder !== void 0 && { folder: typeof folder === "string" ? slugifyPath(folder) : null },
10948
+ schema
10949
+ };
10786
10950
  }
10787
10951
  function mapDatasourceToWire(datasource) {
10788
10952
  return datasource;
@@ -10794,23 +10958,88 @@ function isComponent(value) {
10794
10958
  function isDatasource(value) {
10795
10959
  return isRecord(value) && typeof value.name === "string" && typeof value.slug === "string" && !Array.isArray(value.fields);
10796
10960
  }
10961
+ function isFolder(value) {
10962
+ return isRecord(value) && typeof value.name === "string" && typeof value.path === "string" && !Array.isArray(value.fields) && !("slug" in value);
10963
+ }
10964
+ function buildLocalFolders(registered, derived) {
10965
+ const byPath = /* @__PURE__ */ new Map();
10966
+ const add = (displayPath, registeredLeaf) => {
10967
+ const expanded = expandFolderPath(displayPath);
10968
+ expanded.forEach((entry, i) => {
10969
+ const isLeaf = i === expanded.length - 1;
10970
+ const isRegisteredEntry = registeredLeaf && isLeaf;
10971
+ const existing = byPath.get(entry.path);
10972
+ if (!existing) {
10973
+ byPath.set(entry.path, { folder: entry, isRegistered: isRegisteredEntry });
10974
+ return;
10975
+ }
10976
+ if (isRegisteredEntry && existing.isRegistered && existing.folder.name !== entry.name) {
10977
+ throw new CommandError(`Conflicting folder names for path "${entry.path}": "${existing.folder.name}" vs "${entry.name}"`);
10978
+ }
10979
+ if (isRegisteredEntry && !existing.isRegistered) {
10980
+ byPath.set(entry.path, { folder: entry, isRegistered: true });
10981
+ }
10982
+ });
10983
+ };
10984
+ for (const path of registered) {
10985
+ add(path, true);
10986
+ }
10987
+ for (const path of derived) {
10988
+ add(path, false);
10989
+ }
10990
+ const folders = [...byPath.values()].map((v) => v.folder).sort((a, b) => a.path.split("/").length - b.path.split("/").length || a.path.localeCompare(b.path));
10991
+ const leafToPath = /* @__PURE__ */ new Map();
10992
+ for (const folder of folders) {
10993
+ const leaf = folder.path.split("/").pop() ?? folder.path;
10994
+ const existing = leafToPath.get(leaf);
10995
+ if (existing !== void 0 && existing !== folder.path) {
10996
+ throw new CommandError(
10997
+ `Duplicate folder name "${leaf}" (folders "${existing}" and "${folder.path}"): Storyblok group names must be unique per space, even under different parents.`
10998
+ );
10999
+ }
11000
+ leafToPath.set(leaf, folder.path);
11001
+ }
11002
+ return folders;
11003
+ }
10797
11004
  function isSchemaObject(value) {
10798
- return isRecord(value) && ("blocks" in value || "datasources" in value);
11005
+ return isRecord(value) && ("blocks" in value || "datasources" in value || "folders" in value);
10799
11006
  }
10800
11007
  function emptySchemaData() {
10801
- return { components: [], datasources: [] };
11008
+ return { components: [], folders: [], datasources: [] };
10802
11009
  }
10803
11010
  function classifyExports(moduleExports) {
10804
11011
  const data = emptySchemaData();
10805
11012
  const seenComponents = /* @__PURE__ */ new Set();
10806
11013
  const seenDatasources = /* @__PURE__ */ new Set();
11014
+ const registered = [];
11015
+ const derived = [];
11016
+ function harvestComponentPaths(value) {
11017
+ if (typeof value.folder === "string") {
11018
+ derived.push(value.folder);
11019
+ }
11020
+ if (Array.isArray(value.fields)) {
11021
+ for (const field of value.fields) {
11022
+ if (!isRecord(field) || !Array.isArray(field.allow)) {
11023
+ continue;
11024
+ }
11025
+ for (const entry of field.allow) {
11026
+ if (isRecord(entry) && typeof entry.folder === "string") {
11027
+ derived.push(entry.folder);
11028
+ }
11029
+ }
11030
+ }
11031
+ }
11032
+ }
10807
11033
  function collect(value) {
10808
11034
  if (isComponent(value)) {
11035
+ harvestComponentPaths(value);
10809
11036
  if (seenComponents.has(value.name)) {
10810
11037
  return;
10811
11038
  }
10812
11039
  seenComponents.add(value.name);
10813
11040
  data.components.push(mapBlockToWire(value));
11041
+ } else if (isFolder(value)) {
11042
+ registered.push(value.path);
10814
11043
  } else if (isDatasource(value)) {
10815
11044
  if (seenDatasources.has(value.name)) {
10816
11045
  return;
@@ -10832,6 +11061,7 @@ function classifyExports(moduleExports) {
10832
11061
  collect(value);
10833
11062
  }
10834
11063
  }
11064
+ data.folders = buildLocalFolders(registered, derived);
10835
11065
  return data;
10836
11066
  }
10837
11067
  async function loadSchema(entryPath) {
@@ -10859,6 +11089,7 @@ function sortSchemaByPos$1(schema) {
10859
11089
  })
10860
11090
  );
10861
11091
  }
11092
+ const FOLDER_UNGROUPED = "__FOLDER_UNGROUPED__";
10862
11093
  function serializeComponent(component, options = {}) {
10863
11094
  const stripSet = options.includeGroupUuid ? new Set([...COMPONENT_STRIP_KEYS].filter((key) => key !== "component_group_uuid")) : COMPONENT_STRIP_KEYS;
10864
11095
  const clean = stripKeys(component, stripSet);
@@ -10878,7 +11109,10 @@ function serializeComponent(component, options = {}) {
10878
11109
  if (clean.is_nestable !== void 0) {
10879
11110
  ordered.is_nestable = clean.is_nestable;
10880
11111
  }
10881
- const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
11112
+ if ("folder" in component) {
11113
+ ordered.folder = component.folder === null ? FOLDER_UNGROUPED : component.folder;
11114
+ }
11115
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "folder", "schema"]);
10882
11116
  for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10883
11117
  if (!handled.has(key)) {
10884
11118
  ordered[key] = value;
@@ -10887,7 +11121,7 @@ function serializeComponent(component, options = {}) {
10887
11121
  if (clean.schema !== void 0) {
10888
11122
  ordered.schema = clean.schema;
10889
11123
  }
10890
- return `defineBlock(${formatValue(ordered, 0)})`;
11124
+ return `defineBlock(${formatValue(ordered, 0)})`.replace(`folder: '${FOLDER_UNGROUPED}'`, "folder: null");
10891
11125
  }
10892
11126
  function serializeDatasource(datasource) {
10893
11127
  const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
@@ -10912,6 +11146,23 @@ function serializeDatasource(datasource) {
10912
11146
  return `defineDatasource(${formatValue(ordered, 0)})`;
10913
11147
  }
10914
11148
 
11149
+ function translateGroupWhitelist(schema, uuidToPath) {
11150
+ if (!isRecord(schema)) {
11151
+ return schema;
11152
+ }
11153
+ const result = {};
11154
+ for (const [fieldName, field] of Object.entries(schema)) {
11155
+ if (isRecord(field) && Array.isArray(field.component_group_whitelist)) {
11156
+ result[fieldName] = {
11157
+ ...field,
11158
+ component_group_whitelist: field.component_group_whitelist.map((entry) => typeof entry === "string" ? uuidToPath.get(entry) ?? entry : entry)
11159
+ };
11160
+ } else {
11161
+ result[fieldName] = field;
11162
+ }
11163
+ }
11164
+ return result;
11165
+ }
10915
11166
  function diffEntity(type, name, localSerialized, remoteSerialized) {
10916
11167
  if (!remoteSerialized && localSerialized) {
10917
11168
  return { type, name, action: "create", diff: null, local: null, remote: null };
@@ -10934,13 +11185,46 @@ function diffEntity(type, name, localSerialized, remoteSerialized) {
10934
11185
  }
10935
11186
  function diffSchema(local, remote) {
10936
11187
  const diffs = [];
11188
+ const groupPathByUuid = buildGroupPathByUuid([...remote.componentFolders.values()]);
11189
+ const uuidToPath = /* @__PURE__ */ new Map();
11190
+ for (const [uuid, segments] of groupPathByUuid) {
11191
+ uuidToPath.set(uuid, segments.join("/"));
11192
+ }
11193
+ const remoteFolderPaths = new Set(uuidToPath.values());
11194
+ const localFolderPaths = new Set(local.folders.map((f) => f.path));
11195
+ for (const folder of local.folders) {
11196
+ const action = remoteFolderPaths.has(folder.path) ? "unchanged" : "create";
11197
+ diffs.push({ type: "folder", name: folder.path, action, diff: null, local: null, remote: null });
11198
+ }
11199
+ for (const path of remoteFolderPaths) {
11200
+ if (!localFolderPaths.has(path)) {
11201
+ diffs.push({ type: "folder", name: path, action: "stale", diff: null, local: null, remote: null });
11202
+ }
11203
+ }
10937
11204
  const processedComponentNames = /* @__PURE__ */ new Set();
10938
11205
  for (const comp of local.components) {
10939
11206
  processedComponentNames.add(comp.name);
10940
11207
  const remoteComp = remote.components.get(comp.name);
10941
11208
  const includeGroupUuid = typeof comp.component_group_uuid === "string";
10942
- const localSerialized = serializeComponent(applyDefaults(comp, COMPONENT_DEFAULTS), { includeGroupUuid });
10943
- const remoteSerialized = remoteComp ? serializeComponent(applyDefaults(remoteComp, COMPONENT_DEFAULTS), { includeGroupUuid }) : null;
11209
+ const localForDiff = { ...comp };
11210
+ const remoteForDiff = remoteComp ? { ...remoteComp } : void 0;
11211
+ if ("folder" in comp) {
11212
+ if (remoteForDiff) {
11213
+ const uuid = remoteForDiff.component_group_uuid;
11214
+ remoteForDiff.folder = typeof uuid === "string" && uuid ? uuidToPath.get(uuid) ?? null : null;
11215
+ }
11216
+ } else {
11217
+ delete localForDiff.folder;
11218
+ if (remoteForDiff) {
11219
+ delete remoteForDiff.folder;
11220
+ }
11221
+ }
11222
+ localForDiff.schema = translateGroupWhitelist(localForDiff.schema, uuidToPath);
11223
+ if (remoteForDiff) {
11224
+ remoteForDiff.schema = translateGroupWhitelist(remoteForDiff.schema, uuidToPath);
11225
+ }
11226
+ const localSerialized = serializeComponent(applyDefaults(localForDiff, COMPONENT_DEFAULTS), { includeGroupUuid });
11227
+ const remoteSerialized = remoteForDiff ? serializeComponent(applyDefaults(remoteForDiff, COMPONENT_DEFAULTS), { includeGroupUuid }) : null;
10944
11228
  diffs.push(diffEntity("component", comp.name, localSerialized, remoteSerialized));
10945
11229
  }
10946
11230
  for (const [name] of remote.components) {
@@ -11023,7 +11307,10 @@ function buildComponentPayload(input) {
11023
11307
  ...isRecord(input.schema) && { schema: toSchemaRecord(input.schema) },
11024
11308
  ...typeof input.is_root === "boolean" && { is_root: input.is_root },
11025
11309
  ...typeof input.is_nestable === "boolean" && { is_nestable: input.is_nestable },
11026
- ...typeof input.component_group_uuid === "string" && { component_group_uuid: input.component_group_uuid }
11310
+ // Forward the group membership only when the key is present on input: a
11311
+ // string sets the group, an explicit `null` clears it. Unmanaged components
11312
+ // (key absent) omit it so their remote group is left untouched.
11313
+ ..."component_group_uuid" in input && (typeof input.component_group_uuid === "string" || input.component_group_uuid === null) && { component_group_uuid: input.component_group_uuid }
11027
11314
  };
11028
11315
  }
11029
11316
  function toComponentCreate(input) {
@@ -11066,11 +11353,54 @@ function toDatasourceUpdate(input, remote) {
11066
11353
  return base;
11067
11354
  }
11068
11355
 
11356
+ function resolveGroupRefs(comp, groupByPath) {
11357
+ const { folder, ...rest } = comp;
11358
+ const resolved = { ...rest };
11359
+ if (typeof folder === "string") {
11360
+ const group = groupByPath.get(folder);
11361
+ if (!group) {
11362
+ throw new CommandError(
11363
+ `Unknown folder path "${folder}" for component "${comp.name}": no matching component group exists. Folder creation runs before component push, so this indicates an internal inconsistency.`
11364
+ );
11365
+ }
11366
+ resolved.component_group_uuid = group.uuid;
11367
+ } else if (folder === null) {
11368
+ resolved.component_group_uuid = null;
11369
+ }
11370
+ if (isRecord(resolved.schema)) {
11371
+ const schema = {};
11372
+ for (const [key, field] of Object.entries(resolved.schema)) {
11373
+ if (isRecord(field) && Array.isArray(field.component_group_whitelist)) {
11374
+ schema[key] = {
11375
+ ...field,
11376
+ component_group_whitelist: field.component_group_whitelist.map((path) => typeof path === "string" ? groupByPath.get(path)?.uuid ?? path : path)
11377
+ };
11378
+ } else {
11379
+ schema[key] = field;
11380
+ }
11381
+ }
11382
+ resolved.schema = schema;
11383
+ }
11384
+ return resolved;
11385
+ }
11386
+ function buildGroupByPath(remote) {
11387
+ const remoteFolders = [...remote.componentFolders.values()];
11388
+ const groupPathByUuid = buildGroupPathByUuid(remoteFolders);
11389
+ const groupByPath = /* @__PURE__ */ new Map();
11390
+ for (const folder of remoteFolders) {
11391
+ const segments = groupPathByUuid.get(folder.uuid);
11392
+ if (segments?.length) {
11393
+ groupByPath.set(segments.join("/"), { id: folder.id, uuid: folder.uuid });
11394
+ }
11395
+ }
11396
+ return groupByPath;
11397
+ }
11069
11398
  function formatDiffOutput(result, options) {
11070
11399
  const lines = [];
11071
11400
  const byType = {
11072
11401
  component: [],
11073
- datasource: []
11402
+ datasource: [],
11403
+ folder: []
11074
11404
  };
11075
11405
  for (const diff of result.diffs) {
11076
11406
  byType[diff.type].push(diff);
@@ -11083,6 +11413,7 @@ function formatDiffOutput(result, options) {
11083
11413
  stale: chalk.red("-")
11084
11414
  };
11085
11415
  const sections = [
11416
+ ["Folders", byType.folder],
11086
11417
  ["Components", byType.component],
11087
11418
  ["Datasources", byType.datasource]
11088
11419
  ];
@@ -11123,24 +11454,62 @@ async function executePush(spaceId, local, remote, diffResult, options) {
11123
11454
  let created = 0;
11124
11455
  let updated = 0;
11125
11456
  let deleted = 0;
11457
+ const groupByPath = buildGroupByPath(remote);
11458
+ const foldersByPath = new Map(local.folders.map((f) => [f.path, f]));
11459
+ const folderCreates = diffResult.diffs.filter((d) => d.type === "folder" && d.action === "create").sort((a, b) => a.name.split("/").length - b.name.split("/").length);
11460
+ for (const diff of folderCreates) {
11461
+ const localFolder = foldersByPath.get(diff.name);
11462
+ if (!localFolder) {
11463
+ continue;
11464
+ }
11465
+ const parent = localFolder.parentPath ? groupByPath.get(localFolder.parentPath) : void 0;
11466
+ let createdGroup;
11467
+ try {
11468
+ const res = await client.componentFolders.create({
11469
+ path: { space_id: spaceIdNum },
11470
+ body: { component_group: { name: localFolder.name, parent_id: parent?.id ?? null } },
11471
+ throwOnError: true
11472
+ });
11473
+ createdGroup = res.data?.component_group;
11474
+ } catch (error) {
11475
+ handleAPIError("push_component_folder", error, `Failed to create folder ${localFolder.name}`);
11476
+ }
11477
+ if (createdGroup?.id == null || !createdGroup.uuid) {
11478
+ throw new CommandError(
11479
+ `Folder "${localFolder.name}" was created but the Management API response did not include an id and uuid; blocks that reference this folder cannot be resolved.`
11480
+ );
11481
+ }
11482
+ groupByPath.set(localFolder.path, { id: createdGroup.id, uuid: createdGroup.uuid });
11483
+ created++;
11484
+ }
11126
11485
  const componentDiffs = diffResult.diffs.filter((d) => d.type === "component");
11486
+ const resolvedComponents = /* @__PURE__ */ new Map();
11487
+ for (const diff of componentDiffs) {
11488
+ if (diff.action !== "create" && diff.action !== "update") {
11489
+ continue;
11490
+ }
11491
+ const localComp = local.components.find((c) => c.name === diff.name);
11492
+ if (localComp) {
11493
+ resolvedComponents.set(diff.name, resolveGroupRefs(localComp, groupByPath));
11494
+ }
11495
+ }
11127
11496
  const componentResults = await Promise.allSettled(
11128
11497
  componentDiffs.map(async (diff) => {
11129
- const localComp = local.components.find((c) => c.name === diff.name);
11130
- if (diff.action === "create" && localComp) {
11498
+ const resolvedComp = resolvedComponents.get(diff.name);
11499
+ if (diff.action === "create" && resolvedComp) {
11131
11500
  await client.components.create({
11132
11501
  path: { space_id: spaceIdNum },
11133
- body: { component: toComponentCreate(localComp) },
11502
+ body: { component: toComponentCreate(resolvedComp) },
11134
11503
  throwOnError: true
11135
11504
  });
11136
11505
  return "created";
11137
11506
  }
11138
- if (diff.action === "update" && localComp) {
11507
+ if (diff.action === "update" && resolvedComp) {
11139
11508
  const existing = remote.components.get(diff.name);
11140
11509
  if (existing?.id) {
11141
11510
  await client.components.update(existing.id, {
11142
11511
  path: { space_id: spaceIdNum },
11143
- body: { component: toComponentUpdate(localComp) },
11512
+ body: { component: toComponentUpdate(resolvedComp) },
11144
11513
  throwOnError: true
11145
11514
  });
11146
11515
  return "updated";
@@ -11202,6 +11571,7 @@ async function executePush(spaceId, local, remote, diffResult, options) {
11202
11571
  }
11203
11572
  }
11204
11573
  if (options.delete) {
11574
+ const deleteErrors = [];
11205
11575
  const staleComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
11206
11576
  const deleteComponentResults = await Promise.allSettled(
11207
11577
  staleComponents.map(async (diff) => {
@@ -11222,7 +11592,7 @@ async function executePush(spaceId, local, remote, diffResult, options) {
11222
11592
  deleted++;
11223
11593
  }
11224
11594
  } else {
11225
- handleAPIError("delete_component", result.reason, `Failed to delete component ${staleComponents[i].name}`);
11595
+ deleteErrors.push({ action: "delete_component", reason: result.reason, message: `Failed to delete component ${staleComponents[i].name}` });
11226
11596
  }
11227
11597
  }
11228
11598
  const staleDatasources = diffResult.diffs.filter((d) => d.type === "datasource" && d.action === "stale");
@@ -11245,14 +11615,49 @@ async function executePush(spaceId, local, remote, diffResult, options) {
11245
11615
  deleted++;
11246
11616
  }
11247
11617
  } else {
11248
- handleAPIError("delete_datasource", result.reason, `Failed to delete datasource ${staleDatasources[i].name}`);
11618
+ deleteErrors.push({ action: "delete_datasource", reason: result.reason, message: `Failed to delete datasource ${staleDatasources[i].name}` });
11249
11619
  }
11250
11620
  }
11621
+ const staleFolders = diffResult.diffs.filter((d) => d.type === "folder" && d.action === "stale").sort((a, b) => b.name.split("/").length - a.name.split("/").length);
11622
+ for (const diff of staleFolders) {
11623
+ const group = groupByPath.get(diff.name);
11624
+ if (!group) {
11625
+ deleteErrors.push({
11626
+ action: "delete_component_folder",
11627
+ reason: new Error(`No component group resolved for stale folder path "${diff.name}".`),
11628
+ message: `Failed to delete folder ${diff.name}`
11629
+ });
11630
+ continue;
11631
+ }
11632
+ try {
11633
+ await client.componentFolders.delete(group.id, {
11634
+ path: { space_id: spaceIdNum },
11635
+ throwOnError: true
11636
+ });
11637
+ deleted++;
11638
+ } catch (error) {
11639
+ deleteErrors.push({ action: "delete_component_folder", reason: error, message: `Failed to delete folder ${diff.name}` });
11640
+ }
11641
+ }
11642
+ if (deleteErrors.length > 0) {
11643
+ const first = deleteErrors[0];
11644
+ const suffix = deleteErrors.length > 1 ? ` (and ${deleteErrors.length - 1} more delete error(s))` : "";
11645
+ handleAPIError(first.action, first.reason, `${first.message}${suffix}`);
11646
+ }
11251
11647
  }
11252
11648
  return { created, updated, deleted };
11253
11649
  }
11254
11650
  function buildChangesetEntries(diffResult, local, remote, options) {
11255
11651
  const changes = [];
11652
+ const remoteFolders = [...remote.componentFolders.values()];
11653
+ const folderPathByUuid = buildGroupPathByUuid(remoteFolders);
11654
+ const remoteFolderByPath = /* @__PURE__ */ new Map();
11655
+ for (const folder of remoteFolders) {
11656
+ const segments = folderPathByUuid.get(folder.uuid);
11657
+ if (segments?.length) {
11658
+ remoteFolderByPath.set(segments.join("/"), folder);
11659
+ }
11660
+ }
11256
11661
  for (const diff of diffResult.diffs) {
11257
11662
  if (diff.action === "unchanged") {
11258
11663
  continue;
@@ -11269,6 +11674,9 @@ function buildChangesetEntries(diffResult, local, remote, options) {
11269
11674
  } else if (diff.type === "datasource") {
11270
11675
  remoteSrc = remote.datasources.get(diff.name);
11271
11676
  localSrc = local.datasources.find((d) => d.name === diff.name);
11677
+ } else if (diff.type === "folder") {
11678
+ remoteSrc = remoteFolderByPath.get(diff.name);
11679
+ localSrc = local.folders.find((f) => f.path === diff.name);
11272
11680
  }
11273
11681
  changes.push({
11274
11682
  type: diff.type,
@@ -11552,6 +11960,25 @@ async function writeMigrationFile(options) {
11552
11960
 
11553
11961
  const DEFAULT_GROUPS_FILENAME = "groups.json";
11554
11962
  const CONSOLIDATED_COMPONENTS_FILENAME = "components.json";
11963
+ function sanitizeForLocalWrite(component) {
11964
+ const { folder, ...rest } = component;
11965
+ if (isRecord(rest.schema)) {
11966
+ const schema = {};
11967
+ for (const [key, field] of Object.entries(rest.schema)) {
11968
+ if (isRecord(field) && "component_group_whitelist" in field) {
11969
+ const { component_group_whitelist, restrict_components, ...fieldRest } = field;
11970
+ if (fieldRest.restrict_type === "groups") {
11971
+ delete fieldRest.restrict_type;
11972
+ }
11973
+ schema[key] = fieldRest;
11974
+ } else {
11975
+ schema[key] = field;
11976
+ }
11977
+ }
11978
+ rest.schema = schema;
11979
+ }
11980
+ return rest;
11981
+ }
11555
11982
  async function writeLocalComponents({
11556
11983
  space,
11557
11984
  basePath,
@@ -11570,7 +11997,7 @@ async function writeLocalComponents({
11570
11997
  }
11571
11998
  for (const component of resolved.components) {
11572
11999
  const filePath = join(componentsDir, `${sanitizeFilename(component.name || "")}.json`);
11573
- await saveToFile(filePath, JSON.stringify(component, null, 2));
12000
+ await saveToFile(filePath, JSON.stringify(sanitizeForLocalWrite(component), null, 2));
11574
12001
  }
11575
12002
  const groupsPath = join(componentsDir, DEFAULT_GROUPS_FILENAME);
11576
12003
  if (await fileExists(groupsPath)) {
@@ -11631,10 +12058,10 @@ schemaCommand.command("push <entry-file>").description("Push local TypeScript sc
11631
12058
  handleError(toError(maybeError), verbose);
11632
12059
  return;
11633
12060
  }
11634
- loadSpinner.succeed(`Found: ${local.components.length} components, ${local.datasources.length} datasources`);
11635
- const totalLocal = local.components.length + local.datasources.length;
12061
+ loadSpinner.succeed(`Found: ${local.components.length} components, ${local.folders.length} folders, ${local.datasources.length} datasources`);
12062
+ const totalLocal = local.components.length + local.datasources.length + local.folders.length;
11636
12063
  if (totalLocal === 0) {
11637
- ui.warn("No components or datasources found in the entry file. Verify the file exports schema definitions.");
12064
+ ui.warn("No components, folders, or datasources found in the entry file. Verify the file exports schema definitions.");
11638
12065
  return;
11639
12066
  }
11640
12067
  const remoteSpinner = ui.createSpinner(`Fetching remote state from space ${space}...`);
@@ -11720,6 +12147,26 @@ schemaCommand.command("push <entry-file>").description("Push local TypeScript sc
11720
12147
  for (const comp of deletedComponents) {
11721
12148
  ui.warn(`Component '${comp.name}' will be deleted. Stories using it will have out-of-schema content.`);
11722
12149
  }
12150
+ const staleFolders = diffResult.diffs.filter((d) => d.type === "folder" && d.action === "stale");
12151
+ if (staleFolders.length > 0) {
12152
+ const uuidByPath = /* @__PURE__ */ new Map();
12153
+ for (const [uuid, segments] of buildGroupPathByUuid([...remote.componentFolders.values()])) {
12154
+ uuidByPath.set(segments.join("/"), uuid);
12155
+ }
12156
+ const staleComponentNames = new Set(
12157
+ diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale").map((d) => d.name)
12158
+ );
12159
+ for (const folder of staleFolders) {
12160
+ const uuid = uuidByPath.get(folder.name);
12161
+ if (!uuid) {
12162
+ continue;
12163
+ }
12164
+ const count = [...remote.components.values()].filter((c) => c.component_group_uuid === uuid && !staleComponentNames.has(c.name)).length;
12165
+ if (count > 0) {
12166
+ ui.warn(`Folder '${folder.name}' will be deleted; ${count} component(s) inside will be ungrouped.`);
12167
+ }
12168
+ }
12169
+ }
11723
12170
  }
11724
12171
  if (diffResult.stale > 0 && !options.delete) {
11725
12172
  ui.warn(`${diffResult.stale} stale entity(s) exist remotely but not in schema. Use --delete to remove.`);
@@ -11780,35 +12227,6 @@ schemaCommand.command("push <entry-file>").description("Push local TypeScript sc
11780
12227
  }
11781
12228
  });
11782
12229
 
11783
- function buildGroupPathByUuid(folders) {
11784
- const byUuid = new Map(folders.map((folder) => [folder.uuid, folder]));
11785
- const cache = /* @__PURE__ */ new Map();
11786
- function pathFor(uuid, visited) {
11787
- if (!uuid) {
11788
- return [];
11789
- }
11790
- const cached = cache.get(uuid);
11791
- if (cached) {
11792
- return cached;
11793
- }
11794
- const folder = byUuid.get(uuid);
11795
- if (!folder) {
11796
- return [];
11797
- }
11798
- if (visited.has(uuid)) {
11799
- return [];
11800
- }
11801
- visited.add(uuid);
11802
- const path = [...pathFor(folder.parent_uuid, visited), slugify(folder.name)];
11803
- cache.set(uuid, path);
11804
- return path;
11805
- }
11806
- for (const folder of folders) {
11807
- pathFor(folder.uuid, /* @__PURE__ */ new Set());
11808
- }
11809
- return cache;
11810
- }
11811
-
11812
12230
  const FIELD_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "pos"]);
11813
12231
  function toCamelCaseIdentifier(str) {
11814
12232
  const camel = slugify(str).replace(/^[_-]+/, "").replace(/[_-]+(.)/g, (_, char) => char.toUpperCase());
@@ -11826,6 +12244,9 @@ function componentVarName(name) {
11826
12244
  function datasourceVarName(name) {
11827
12245
  return `${toCamelCaseIdentifier(name)}Datasource`;
11828
12246
  }
12247
+ function folderVarName(name) {
12248
+ return `${toCamelCaseIdentifier(name)}Folder`;
12249
+ }
11829
12250
  function resolveVarNames(rawNames, baseVarName) {
11830
12251
  const used = /* @__PURE__ */ new Set();
11831
12252
  return rawNames.map((raw) => {
@@ -11885,12 +12306,37 @@ function resolveDatasources(datasources) {
11885
12306
  fileName: fileNames[i]
11886
12307
  }));
11887
12308
  }
11888
- function toDslField(field) {
11889
- const { component_whitelist, datasource_slug, restrict_components, restrict_type, ...rest } = field;
12309
+ function resolveFolders(folders) {
12310
+ const pathByUuid = buildGroupPathByUuid(folders);
12311
+ const ordered = [...folders].sort((a, b) => (pathByUuid.get(a.uuid)?.length ?? 0) - (pathByUuid.get(b.uuid)?.length ?? 0));
12312
+ const varNames = resolveVarNames(ordered.map((f) => f.name), folderVarName);
12313
+ return ordered.map((folder, i) => ({
12314
+ folder,
12315
+ varName: varNames[i],
12316
+ segments: pathByUuid.get(folder.uuid) ?? []
12317
+ }));
12318
+ }
12319
+ function resolveGroupWhitelistRefs(whitelist, folderVarByUuid) {
12320
+ if (!folderVarByUuid || !Array.isArray(whitelist) || whitelist.length === 0) {
12321
+ return void 0;
12322
+ }
12323
+ const vars = whitelist.map((uuid) => typeof uuid === "string" ? folderVarByUuid.get(uuid) : void 0);
12324
+ if (!vars.every((v) => typeof v === "string")) {
12325
+ return void 0;
12326
+ }
12327
+ return vars.map((v) => new RawCode(v));
12328
+ }
12329
+ function toDslField(field, folderVarByUuid) {
12330
+ const { component_whitelist, component_group_whitelist, datasource_slug, restrict_components, restrict_type, ...rest } = field;
11890
12331
  const out = { ...rest };
11891
- if (component_whitelist !== void 0) {
12332
+ const groupRefs = resolveGroupWhitelistRefs(component_group_whitelist, folderVarByUuid);
12333
+ const hasBlockNames = Array.isArray(component_whitelist) && component_whitelist.length > 0;
12334
+ if (hasBlockNames) {
11892
12335
  out.allow = component_whitelist;
11893
- } else {
12336
+ } else if (groupRefs) {
12337
+ out.allow = groupRefs;
12338
+ } else if (component_group_whitelist !== void 0) {
12339
+ out.component_group_whitelist = component_group_whitelist;
11894
12340
  if (restrict_components !== void 0) {
11895
12341
  out.restrict_components = restrict_components;
11896
12342
  }
@@ -11903,10 +12349,33 @@ function toDslField(field) {
11903
12349
  }
11904
12350
  return out;
11905
12351
  }
11906
- function generateFieldCode(fieldName, fieldData, depth) {
11907
- const clean = toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS));
12352
+ function omitEmptyArrays(obj) {
12353
+ const out = {};
12354
+ for (const [key, value] of Object.entries(obj)) {
12355
+ if (Array.isArray(value) && value.length === 0) {
12356
+ continue;
12357
+ }
12358
+ out[key] = value;
12359
+ }
12360
+ return out;
12361
+ }
12362
+ function generateFieldCode(fieldName, fieldData, depth, folderVarByUuid) {
12363
+ const clean = omitEmptyArrays(toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS), folderVarByUuid));
11908
12364
  return `defineField(${quoteString(fieldName)}, ${formatValue(clean, depth)})`;
11909
12365
  }
12366
+ function collectWhitelistFolderVars(schema, folderVarByUuid) {
12367
+ const vars = /* @__PURE__ */ new Set();
12368
+ for (const field of Object.values(schema)) {
12369
+ if (!isRecord(field)) {
12370
+ continue;
12371
+ }
12372
+ const refs = resolveGroupWhitelistRefs(field.component_group_whitelist, folderVarByUuid);
12373
+ if (refs) {
12374
+ refs.forEach((ref) => vars.add(ref.code));
12375
+ }
12376
+ }
12377
+ return [...vars].sort();
12378
+ }
11910
12379
  function sortSchemaByPos(schema) {
11911
12380
  return Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
11912
12381
  const posA = typeof a.pos === "number" ? a.pos : Infinity;
@@ -11914,16 +12383,42 @@ function sortSchemaByPos(schema) {
11914
12383
  return posA - posB;
11915
12384
  });
11916
12385
  }
11917
- function generateComponentFile(component, varName) {
12386
+ function generateFoldersFile(resolved) {
12387
+ const varByUuid = new Map(resolved.map((r) => [r.folder.uuid, r.varName]));
12388
+ const lines = ["import { defineFolder } from '@storyblok/schema';", ""];
12389
+ for (const { folder, varName } of resolved) {
12390
+ const parentVar = folder.parent_uuid ? varByUuid.get(folder.parent_uuid) : void 0;
12391
+ lines.push(`export const ${varName} = defineFolder({`);
12392
+ lines.push(`${INDENT}name: ${quoteString(folder.name)},`);
12393
+ if (parentVar) {
12394
+ lines.push(`${INDENT}parent: ${parentVar},`);
12395
+ }
12396
+ lines.push("});");
12397
+ lines.push("");
12398
+ }
12399
+ return lines.join("\n");
12400
+ }
12401
+ function generateComponentFile(component, varName, folderRef, folderVarByUuid) {
11918
12402
  const lines = [];
11919
12403
  lines.push("import {");
11920
12404
  lines.push(" defineBlock,");
11921
12405
  lines.push(" defineField,");
11922
12406
  lines.push("} from '@storyblok/schema';");
11923
12407
  lines.push("");
12408
+ const blockDepth = folderRef?.segments.length ?? 0;
12409
+ const schema = isRecord(component.schema) ? component.schema : {};
12410
+ const folderVars = [.../* @__PURE__ */ new Set([
12411
+ ...folderRef ? [folderRef.varName] : [],
12412
+ ...collectWhitelistFolderVars(schema, folderVarByUuid)
12413
+ ])].sort();
12414
+ if (folderVars.length > 0) {
12415
+ const rel = "../".repeat(blockDepth + 1);
12416
+ lines.push(`import { ${folderVars.join(", ")} } from '${rel}folders';`);
12417
+ lines.push("");
12418
+ }
11924
12419
  const resolvedVarName = varName ?? componentVarName(component.name);
11925
12420
  lines.push(`export const ${resolvedVarName} = defineBlock({`);
11926
- const clean = stripKeys(component, COMPONENT_STRIP_KEYS);
12421
+ const clean = omitEmptyArrays(stripKeys(component, COMPONENT_STRIP_KEYS));
11927
12422
  delete clean.component_group_uuid;
11928
12423
  const orderedKeys = [];
11929
12424
  if (clean.name !== void 0) {
@@ -11938,22 +12433,29 @@ function generateComponentFile(component, varName) {
11938
12433
  if (clean.is_nestable !== void 0) {
11939
12434
  orderedKeys.push("is_nestable");
11940
12435
  }
12436
+ const prefixKeyCount = orderedKeys.length;
11941
12437
  const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
11942
12438
  for (const key of Object.keys(clean).sort()) {
11943
12439
  if (!handled.has(key)) {
11944
12440
  orderedKeys.push(key);
11945
12441
  }
11946
12442
  }
11947
- for (const key of orderedKeys) {
12443
+ orderedKeys.forEach((key, i) => {
11948
12444
  lines.push(`${INDENT}${key}: ${formatValue(clean[key], 1)},`);
12445
+ if (folderRef && i === prefixKeyCount - 1) {
12446
+ lines.push(`${INDENT}folder: ${folderRef.varName},`);
12447
+ }
12448
+ });
12449
+ if (folderRef && prefixKeyCount === 0) {
12450
+ lines.push(`${INDENT}folder: ${folderRef.varName},`);
11949
12451
  }
11950
12452
  if (clean.schema && typeof clean.schema === "object") {
11951
- const schema = clean.schema;
11952
- const sortedFields = sortSchemaByPos(schema);
12453
+ const schema2 = clean.schema;
12454
+ const sortedFields = sortSchemaByPos(schema2);
11953
12455
  if (sortedFields.length > 0) {
11954
12456
  lines.push(`${INDENT}fields: [`);
11955
12457
  for (const [fieldName, fieldData] of sortedFields) {
11956
- const fieldCode = generateFieldCode(fieldName, fieldData, 2);
12458
+ const fieldCode = generateFieldCode(fieldName, fieldData, 2, folderVarByUuid);
11957
12459
  lines.push(`${INDENT}${INDENT}${fieldCode},`);
11958
12460
  }
11959
12461
  lines.push(`${INDENT}],`);
@@ -11971,7 +12473,7 @@ function generateDatasourceFile(datasource, varName) {
11971
12473
  lines.push("");
11972
12474
  const resolvedVarName = varName ?? datasourceVarName(datasource.name);
11973
12475
  lines.push(`export const ${resolvedVarName} = defineDatasource({`);
11974
- const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
12476
+ const clean = omitEmptyArrays(stripKeys(datasource, DATASOURCE_STRIP_KEYS));
11975
12477
  if (clean.name !== void 0) {
11976
12478
  lines.push(`${INDENT}name: ${formatValue(clean.name, 1)},`);
11977
12479
  }
@@ -11988,7 +12490,7 @@ function generateDatasourceFile(datasource, varName) {
11988
12490
  lines.push("");
11989
12491
  return lines.join("\n");
11990
12492
  }
11991
- function generateSchemaFile(components, datasources) {
12493
+ function generateSchemaFile(components, datasources, folders = []) {
11992
12494
  const lines = [];
11993
12495
  lines.push("import { defineSchema } from '@storyblok/schema';");
11994
12496
  lines.push("import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';");
@@ -12001,6 +12503,9 @@ function generateSchemaFile(components, datasources) {
12001
12503
  for (const { varName, fileName } of datasources) {
12002
12504
  lines.push(`import { ${varName} } from './datasources/${fileName}';`);
12003
12505
  }
12506
+ if (folders.length > 0) {
12507
+ lines.push(`import { ${folders.map((f) => f.varName).join(", ")} } from './folders';`);
12508
+ }
12004
12509
  lines.push("");
12005
12510
  lines.push("export const schema = defineSchema({");
12006
12511
  if (components.length > 0) {
@@ -12017,6 +12522,13 @@ function generateSchemaFile(components, datasources) {
12017
12522
  }
12018
12523
  lines.push(" },");
12019
12524
  }
12525
+ if (folders.length > 0) {
12526
+ lines.push(" folders: {");
12527
+ for (const { varName } of folders) {
12528
+ lines.push(` ${varName},`);
12529
+ }
12530
+ lines.push(" },");
12531
+ }
12020
12532
  lines.push("});");
12021
12533
  lines.push("");
12022
12534
  lines.push("export type Schema = InferSchema<typeof schema>;");
@@ -12040,9 +12552,14 @@ async function writeSchemaFiles(targetPath, components, componentFolders, dataso
12040
12552
  );
12041
12553
  const resolvedComponents = resolveComponents(components, componentSegments);
12042
12554
  const resolvedDatasources = resolveDatasources(datasources);
12555
+ const resolvedFolders = resolveFolders(componentFolders);
12556
+ const folderByUuid = new Map(resolvedFolders.map((r) => [r.folder.uuid, r]));
12557
+ const folderVarByUuid = new Map([...folderByUuid].map(([uuid, r]) => [uuid, r.varName]));
12043
12558
  for (const { component, varName, fileName, segments } of resolvedComponents) {
12044
12559
  const filePath = join(targetPath, "blocks", ...segments, `${fileName}.ts`);
12045
- await writeFileWithDirs(filePath, generateComponentFile(component, varName));
12560
+ const folder = component.component_group_uuid ? folderByUuid.get(component.component_group_uuid) : void 0;
12561
+ const folderRef = folder && { varName: folder.varName, segments };
12562
+ await writeFileWithDirs(filePath, generateComponentFile(component, varName, folderRef, folderVarByUuid));
12046
12563
  writtenFiles.push(filePath);
12047
12564
  }
12048
12565
  for (const { datasource, varName, fileName } of resolvedDatasources) {
@@ -12050,8 +12567,13 @@ async function writeSchemaFiles(targetPath, components, componentFolders, dataso
12050
12567
  await writeFileWithDirs(filePath, generateDatasourceFile(datasource, varName));
12051
12568
  writtenFiles.push(filePath);
12052
12569
  }
12570
+ if (resolvedFolders.length > 0) {
12571
+ const foldersPath = join(targetPath, "folders.ts");
12572
+ await writeFileWithDirs(foldersPath, generateFoldersFile(resolvedFolders));
12573
+ writtenFiles.push(foldersPath);
12574
+ }
12053
12575
  const schemaPath = join(targetPath, "schema.ts");
12054
- await writeFileWithDirs(schemaPath, generateSchemaFile(resolvedComponents, resolvedDatasources));
12576
+ await writeFileWithDirs(schemaPath, generateSchemaFile(resolvedComponents, resolvedDatasources, resolvedFolders));
12055
12577
  writtenFiles.push(schemaPath);
12056
12578
  return writtenFiles;
12057
12579
  }
@@ -12181,6 +12703,7 @@ function rollbackAction(original) {
12181
12703
  }
12182
12704
  function formatRollbackOutput(changes) {
12183
12705
  const byType = {
12706
+ folder: [],
12184
12707
  component: [],
12185
12708
  datasource: []
12186
12709
  };
@@ -12194,6 +12717,7 @@ function formatRollbackOutput(changes) {
12194
12717
  };
12195
12718
  const lines = [];
12196
12719
  const sections = [
12720
+ ["Folders", byType.folder],
12197
12721
  ["Components", byType.component],
12198
12722
  ["Datasources", byType.datasource]
12199
12723
  ];
@@ -12213,9 +12737,11 @@ function formatRollbackOutput(changes) {
12213
12737
  if (entry.type === "component") {
12214
12738
  fromStr = serializeComponent(applyDefaults(entry.after, COMPONENT_DEFAULTS));
12215
12739
  toStr = serializeComponent(applyDefaults(entry.before, COMPONENT_DEFAULTS));
12216
- } else {
12740
+ } else if (entry.type === "datasource") {
12217
12741
  fromStr = serializeDatasource(entry.after);
12218
12742
  toStr = serializeDatasource(entry.before);
12743
+ } else {
12744
+ continue;
12219
12745
  }
12220
12746
  if (fromStr !== toStr) {
12221
12747
  const patch = createTwoFilesPatch(
@@ -12240,6 +12766,46 @@ function formatRollbackOutput(changes) {
12240
12766
  }
12241
12767
  return lines.join("\n").trimEnd();
12242
12768
  }
12769
+ function buildGroupRefByPath(remote) {
12770
+ const folders = [...remote.componentFolders.values()];
12771
+ const pathByUuid = buildGroupPathByUuid(folders);
12772
+ const byPath = /* @__PURE__ */ new Map();
12773
+ for (const folder of folders) {
12774
+ const segments = pathByUuid.get(folder.uuid);
12775
+ if (segments?.length) {
12776
+ byPath.set(segments.join("/"), { id: folder.id, uuid: folder.uuid });
12777
+ }
12778
+ }
12779
+ return byPath;
12780
+ }
12781
+ function parentPathOf(path) {
12782
+ const segments = path.split("/");
12783
+ return segments.length > 1 ? segments.slice(0, -1).join("/") : null;
12784
+ }
12785
+ function remapGroupUuids(payload, uuidMap) {
12786
+ if (uuidMap.size === 0) {
12787
+ return payload;
12788
+ }
12789
+ const result = { ...payload };
12790
+ if (typeof result.component_group_uuid === "string") {
12791
+ result.component_group_uuid = uuidMap.get(result.component_group_uuid) ?? result.component_group_uuid;
12792
+ }
12793
+ if (isRecord(result.schema)) {
12794
+ const schema = {};
12795
+ for (const [key, field] of Object.entries(result.schema)) {
12796
+ if (isRecord(field) && Array.isArray(field.component_group_whitelist)) {
12797
+ schema[key] = {
12798
+ ...field,
12799
+ component_group_whitelist: field.component_group_whitelist.map((uuid) => typeof uuid === "string" ? uuidMap.get(uuid) ?? uuid : uuid)
12800
+ };
12801
+ } else {
12802
+ schema[key] = field;
12803
+ }
12804
+ }
12805
+ result.schema = schema;
12806
+ }
12807
+ return result;
12808
+ }
12243
12809
  async function executeRollback(spaceId, ops, remote) {
12244
12810
  const client = getMapiClient();
12245
12811
  const spaceIdNum = Number(spaceId);
@@ -12248,9 +12814,43 @@ async function executeRollback(spaceId, ops, remote) {
12248
12814
  let deleted = 0;
12249
12815
  const componentOps = ops.filter((op) => op.type === "component");
12250
12816
  const datasourceOps = ops.filter((op) => op.type === "datasource");
12817
+ const folderOps = ops.filter((op) => op.type === "folder");
12818
+ const groupRefByPath = buildGroupRefByPath(remote);
12819
+ const groupUuidMap = /* @__PURE__ */ new Map();
12820
+ const folderCreates = folderOps.filter((o) => o.action === "create").sort((a, b) => a.name.split("/").length - b.name.split("/").length);
12821
+ for (const op of folderCreates) {
12822
+ const existing = groupRefByPath.get(op.name);
12823
+ const oldUuid = typeof op.payload.uuid === "string" ? op.payload.uuid : void 0;
12824
+ if (existing) {
12825
+ if (oldUuid) {
12826
+ groupUuidMap.set(oldUuid, existing.uuid);
12827
+ }
12828
+ continue;
12829
+ }
12830
+ const parentPath = parentPathOf(op.name);
12831
+ const parentId = parentPath ? groupRefByPath.get(parentPath)?.id ?? null : null;
12832
+ const name = typeof op.payload.name === "string" ? op.payload.name : op.name.split("/").pop() ?? op.name;
12833
+ try {
12834
+ const res = await client.componentFolders.create({
12835
+ path: { space_id: spaceIdNum },
12836
+ body: { component_group: { name, parent_id: parentId } },
12837
+ throwOnError: true
12838
+ });
12839
+ const group = res.data?.component_group;
12840
+ if (group?.id != null && group.uuid) {
12841
+ groupRefByPath.set(op.name, { id: group.id, uuid: group.uuid });
12842
+ if (oldUuid) {
12843
+ groupUuidMap.set(oldUuid, group.uuid);
12844
+ }
12845
+ created++;
12846
+ }
12847
+ } catch (error) {
12848
+ handleAPIError("push_component_folder", error, `Failed to recreate folder ${op.name}`);
12849
+ }
12850
+ }
12251
12851
  for (const op of componentOps.filter((o) => o.action !== "delete")) {
12252
12852
  if (op.action === "create") {
12253
- const payload = toComponentCreate(stripApiFields(op.payload));
12853
+ const payload = toComponentCreate(remapGroupUuids(stripApiFields(op.payload), groupUuidMap));
12254
12854
  try {
12255
12855
  await client.components.create({
12256
12856
  path: { space_id: spaceIdNum },
@@ -12264,7 +12864,7 @@ async function executeRollback(spaceId, ops, remote) {
12264
12864
  } else if (op.action === "update") {
12265
12865
  const existing = remote.components.get(op.name);
12266
12866
  if (existing?.id) {
12267
- const payload = toComponentUpdate(stripApiFields(op.payload));
12867
+ const payload = toComponentUpdate(remapGroupUuids(stripApiFields(op.payload), groupUuidMap));
12268
12868
  try {
12269
12869
  await client.components.update(existing.id, {
12270
12870
  path: { space_id: spaceIdNum },
@@ -12336,6 +12936,22 @@ async function executeRollback(spaceId, ops, remote) {
12336
12936
  }
12337
12937
  }
12338
12938
  }
12939
+ const folderDeletes = folderOps.filter((o) => o.action === "delete").sort((a, b) => b.name.split("/").length - a.name.split("/").length);
12940
+ for (const op of folderDeletes) {
12941
+ const ref = groupRefByPath.get(op.name);
12942
+ if (!ref) {
12943
+ continue;
12944
+ }
12945
+ try {
12946
+ await client.componentFolders.delete(ref.id, {
12947
+ path: { space_id: spaceIdNum },
12948
+ throwOnError: true
12949
+ });
12950
+ deleted++;
12951
+ } catch (error) {
12952
+ handleAPIError("delete_component_folder", error, `Failed to delete folder ${op.name}`);
12953
+ }
12954
+ }
12339
12955
  return { created, updated, deleted };
12340
12956
  }
12341
12957