@terminalfour/terminalfour-js 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cjs/content-cache.d.ts +38 -0
  2. package/dist/cjs/content-cache.js +42 -0
  3. package/dist/cjs/element-resolver.d.ts +16 -0
  4. package/dist/cjs/element-resolver.js +77 -1
  5. package/dist/cjs/models/content-item.js +13 -1
  6. package/dist/cjs/resources/content-resource.d.ts +7 -11
  7. package/dist/cjs/resources/content-resource.js +36 -79
  8. package/dist/cjs/resources/content-type-resource.d.ts +8 -8
  9. package/dist/cjs/resources/content-type-resource.js +25 -6
  10. package/dist/cjs/resources/list-resource.d.ts +2 -6
  11. package/dist/cjs/resources/list-resource.js +8 -6
  12. package/dist/cjs/resources/navigation-resource.d.ts +17 -0
  13. package/dist/cjs/resources/navigation-resource.js +13 -2
  14. package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
  15. package/dist/cjs/resources/page-layout-resource.js +12 -2
  16. package/dist/cjs/section-ref.d.ts +3 -1
  17. package/dist/cjs/section-ref.js +6 -5
  18. package/dist/cjs/t4-client.d.ts +1 -0
  19. package/dist/cjs/t4-client.js +6 -1
  20. package/dist/cjs/utils.d.ts +39 -0
  21. package/dist/cjs/utils.js +37 -0
  22. package/dist/esm/content-cache.d.ts +38 -0
  23. package/dist/esm/content-cache.js +38 -0
  24. package/dist/esm/element-resolver.d.ts +16 -0
  25. package/dist/esm/element-resolver.js +77 -1
  26. package/dist/esm/models/content-item.js +13 -1
  27. package/dist/esm/resources/content-resource.d.ts +7 -11
  28. package/dist/esm/resources/content-resource.js +37 -80
  29. package/dist/esm/resources/content-type-resource.d.ts +8 -8
  30. package/dist/esm/resources/content-type-resource.js +26 -7
  31. package/dist/esm/resources/list-resource.d.ts +2 -6
  32. package/dist/esm/resources/list-resource.js +9 -7
  33. package/dist/esm/resources/navigation-resource.d.ts +17 -0
  34. package/dist/esm/resources/navigation-resource.js +13 -2
  35. package/dist/esm/resources/page-layout-resource.d.ts +11 -0
  36. package/dist/esm/resources/page-layout-resource.js +13 -3
  37. package/dist/esm/section-ref.d.ts +3 -1
  38. package/dist/esm/section-ref.js +6 -5
  39. package/dist/esm/t4-client.d.ts +1 -0
  40. package/dist/esm/t4-client.js +6 -1
  41. package/dist/esm/utils.d.ts +39 -0
  42. package/dist/esm/utils.js +32 -0
  43. package/docs/content-types.md +20 -3
  44. package/docs/content.md +13 -0
  45. package/docs/error-handling.md +3 -1
  46. package/docs/getting-started.md +4 -0
  47. package/docs/lists.md +2 -2
  48. package/docs/navigation.md +30 -1
  49. package/docs/page-layouts.md +26 -0
  50. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NavigationResource = exports.NavigationObject = exports.NAVIGATION_TYPE_NAMES = void 0;
4
+ const utils_js_1 = require("../utils.js");
4
5
  /** Maps SDK type codes to API type codes */
5
6
  const SDK_TO_API = {
6
7
  'a-to-z': 'a2z',
@@ -988,6 +989,8 @@ class NavigationObject {
988
989
  this.enabled = raw.isEnabled;
989
990
  this.cachingEnabled = raw.isCachingEnabled;
990
991
  this.previewEnabled = raw.isPreviewModeEnabled;
992
+ this.primaryGroup = (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup);
993
+ this.sharedGroups = (0, utils_js_1.readSharedGroups)(raw.sharedGroups);
991
994
  // Convert properties to camelCase keys with string values
992
995
  const originalKeys = [];
993
996
  const rawCamelProps = {};
@@ -1003,6 +1006,7 @@ class NavigationObject {
1003
1006
  }
1004
1007
  /** Persists current property values to the server via PUT. */
1005
1008
  async save() {
1009
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
1006
1010
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1007
1011
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
1008
1012
  // Rebuild properties in API format
@@ -1022,6 +1026,8 @@ class NavigationObject {
1022
1026
  isEnabled: this.enabled,
1023
1027
  isCachingEnabled: this.cachingEnabled,
1024
1028
  isPreviewModeEnabled: this.previewEnabled,
1029
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
1030
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
1025
1031
  properties: apiProperties,
1026
1032
  };
1027
1033
  await this._httpClient.request({
@@ -1239,6 +1245,7 @@ class NavigationResource {
1239
1245
  throw new Error('Navigation object type is required');
1240
1246
  if (!exports.NAVIGATION_TYPE_NAMES[data.type])
1241
1247
  throw new Error(`Unknown navigation type "${data.type}"`);
1248
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
1242
1249
  const apiType = SDK_TO_API[data.type];
1243
1250
  const properties = await this.buildProperties(data.type, (data.properties ?? {}));
1244
1251
  const body = {
@@ -1248,8 +1255,8 @@ class NavigationResource {
1248
1255
  name: data.name,
1249
1256
  description: data.description ?? '',
1250
1257
  navigationType: apiType,
1251
- sharedGroups: [],
1252
- primaryGroup: { id: 0 },
1258
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
1259
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
1253
1260
  properties,
1254
1261
  };
1255
1262
  // CSS Selector quirk: requires "section-name": "on" at the top level
@@ -1293,6 +1300,10 @@ class NavigationResource {
1293
1300
  nav.previewEnabled = data.previewEnabled;
1294
1301
  if (data.cachingEnabled !== undefined)
1295
1302
  nav.cachingEnabled = data.cachingEnabled;
1303
+ if (data.primaryGroup !== undefined)
1304
+ nav.primaryGroup = data.primaryGroup;
1305
+ if (data.sharedGroups !== undefined)
1306
+ nav.sharedGroups = data.sharedGroups;
1296
1307
  // Merge properties rather than replace — callers pass only what changes
1297
1308
  if (data.properties !== undefined) {
1298
1309
  nav.properties = {
@@ -1,4 +1,5 @@
1
1
  import { HttpClient } from '../http-client.js';
2
+ import { RawPrimaryGroup } from '../utils.js';
2
3
  /** Raw page layout detail from GET /pageLayout/{id} */
3
4
  interface RawPageLayoutDetail {
4
5
  id: number;
@@ -10,6 +11,10 @@ interface RawPageLayoutDetail {
10
11
  fileExtension?: string;
11
12
  syntaxType?: number;
12
13
  layoutProcessor?: number;
14
+ primaryGroup?: RawPrimaryGroup;
15
+ sharedGroups?: Array<{
16
+ id: number;
17
+ }>;
13
18
  [key: string]: unknown;
14
19
  }
15
20
  /** A page layout summary returned from list() */
@@ -28,6 +33,10 @@ export declare class PageLayout {
28
33
  fileExtension: string;
29
34
  syntax: string;
30
35
  processor: string;
36
+ /** Owning group ID. 0 = no primary group (Global). */
37
+ primaryGroup: number;
38
+ /** Group IDs this page layout is shared with. */
39
+ sharedGroups: number[];
31
40
  private readonly _httpClient;
32
41
  private _rawData;
33
42
  private _syntaxMap;
@@ -63,6 +72,8 @@ export declare class PageLayoutResource {
63
72
  fileExtension?: string;
64
73
  syntax?: string;
65
74
  processor?: string;
75
+ primaryGroup?: number;
76
+ sharedGroups?: number[];
66
77
  }): Promise<PageLayout>;
67
78
  /** Creates a new page layout. */
68
79
  create(data: {
@@ -22,6 +22,8 @@ class PageLayout {
22
22
  const procName = processorMap.get(raw.layoutProcessor ?? 0) ?? '';
23
23
  const procEntry = Object.entries(PAGE_PROCESSOR_MAP).find(([, apiName]) => apiName === procName);
24
24
  this.processor = procEntry ? procEntry[0] : procName || `unknown (${raw.layoutProcessor})`;
25
+ this.primaryGroup = (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup);
26
+ this.sharedGroups = (0, utils_js_1.readSharedGroups)(raw.sharedGroups);
25
27
  Object.defineProperty(this, '_httpClient', { value: httpClient, enumerable: false });
26
28
  Object.defineProperty(this, '_rawData', { value: raw, enumerable: false, writable: true });
27
29
  Object.defineProperty(this, '_syntaxMap', { value: syntaxMap, enumerable: false });
@@ -29,6 +31,7 @@ class PageLayout {
29
31
  }
30
32
  /** Persists current property values to the server via PUT. */
31
33
  async save() {
34
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
32
35
  // Resolve syntax name to ID
33
36
  let syntaxId = this._rawData.syntaxType;
34
37
  if (this._syntaxMap) {
@@ -53,6 +56,8 @@ class PageLayout {
53
56
  fileExtension: this.fileExtension,
54
57
  syntaxType: String(syntaxId),
55
58
  layoutProcessor: String(processorId),
59
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
60
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
56
61
  };
57
62
  await this._httpClient.request({
58
63
  method: 'PUT',
@@ -136,6 +141,10 @@ class PageLayoutResource {
136
141
  layout.syntax = data.syntax;
137
142
  if (data.processor !== undefined)
138
143
  layout.processor = data.processor;
144
+ if (data.primaryGroup !== undefined)
145
+ layout.primaryGroup = data.primaryGroup;
146
+ if (data.sharedGroups !== undefined)
147
+ layout.sharedGroups = data.sharedGroups;
139
148
  await layout.save();
140
149
  return layout;
141
150
  }
@@ -143,6 +152,7 @@ class PageLayoutResource {
143
152
  async create(data) {
144
153
  if (!data.name?.trim())
145
154
  throw new Error('Page layout name is required');
155
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
146
156
  const [syntaxMap, processorMap] = await Promise.all([
147
157
  this.getSyntaxMap(),
148
158
  this.getProcessorMap(),
@@ -196,8 +206,8 @@ class PageLayoutResource {
196
206
  fileExtension: extensionValue,
197
207
  syntaxType: syntaxId,
198
208
  layoutProcessor: processorId,
199
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
200
- primaryGroup: { id: data.primaryGroup ?? null },
209
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
210
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
201
211
  },
202
212
  });
203
213
  return new PageLayout(raw, this.httpClient, syntaxMap, processorMap);
@@ -3,6 +3,7 @@ import { LanguageOption, SectionChannel, Owner, AddSectionData } from './types.j
3
3
  import { ContentResource } from './resources/content-resource.js';
4
4
  import { SectionItem } from './models/section-item.js';
5
5
  import { MediaCreateFn } from './element-resolver.js';
6
+ import { ContentCache } from './content-cache.js';
6
7
  /** A node in the section hierarchy tree */
7
8
  export interface SectionTreeNode {
8
9
  id: number;
@@ -23,7 +24,8 @@ export declare class SectionRef {
23
24
  private readonly sectionId;
24
25
  private readonly defaultLanguage;
25
26
  private readonly mediaCreateFn;
26
- constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
27
+ private readonly cache;
28
+ constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
27
29
  /** Returns a mutable section object. Modify properties and call save() to persist. */
28
30
  get(options?: LanguageOption): Promise<SectionItem>;
29
31
  /** Returns the list of channels associated with this section. */
@@ -57,12 +57,13 @@ async function resolveMetaDataTypeId(httpClient, sectionMetaDataType) {
57
57
  * child section creation, and deletion — all from a single entry point.
58
58
  */
59
59
  class SectionRef {
60
- constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
60
+ constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
61
61
  this.httpClient = httpClient;
62
62
  this.sectionId = sectionId;
63
63
  this.defaultLanguage = defaultLanguage;
64
64
  this.mediaCreateFn = mediaCreateFn ?? null;
65
- this.content = new content_resource_js_1.ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn);
65
+ this.cache = cache;
66
+ this.content = new content_resource_js_1.ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn, this.cache);
66
67
  }
67
68
  // ── Section metadata ──
68
69
  /** Returns a mutable section object. Modify properties and call save() to persist. */
@@ -77,7 +78,7 @@ class SectionRef {
77
78
  const meta = raw.metaData;
78
79
  if (meta?.enabled && meta.id) {
79
80
  try {
80
- const metaContent = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
81
+ const metaContent = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
81
82
  const item = await metaContent.get(meta.id, { language });
82
83
  customFields = stripNameField(item.fields ?? null);
83
84
  }
@@ -645,7 +646,7 @@ class SectionRef {
645
646
  if (metaContentTypeId) {
646
647
  // Use a ContentResource on the new section to get full element resolution
647
648
  // (list values, SS links, file uploads, etc.)
648
- const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
649
+ const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
649
650
  // Create the metadata content item (ContentResource.create handles the full body)
650
651
  const metaItem = await metadataContentResource.create({
651
652
  type: metaContentTypeId,
@@ -734,7 +735,7 @@ class SectionRef {
734
735
  if (!metaDataTypeId) {
735
736
  throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
736
737
  }
737
- const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
738
+ const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
738
739
  const metaContentId = meta?.id ?? 0;
739
740
  if (metaContentId > 0) {
740
741
  await contentResource.update(metaContentId, { fields: data.customFields });
@@ -35,6 +35,7 @@ export declare class T4Client {
35
35
  private readonly httpClient;
36
36
  private readonly defaultLanguage;
37
37
  private readonly mediaCreateFn;
38
+ private readonly contentCache;
38
39
  constructor(config: T4ClientConfig);
39
40
  /**
40
41
  * Returns a section reference scoped to the given section ID.
@@ -16,6 +16,7 @@ const page_layout_resource_js_1 = require("./resources/page-layout-resource.js")
16
16
  const media_type_resource_js_1 = require("./resources/media-type-resource.js");
17
17
  const navigation_resource_js_1 = require("./resources/navigation-resource.js");
18
18
  const handlebars_js_1 = require("./handlebars.js");
19
+ const content_cache_js_1 = require("./content-cache.js");
19
20
  const utils_js_1 = require("./utils.js");
20
21
  /**
21
22
  * Main entry point for the T4 SDK.
@@ -57,12 +58,16 @@ class T4Client {
57
58
  });
58
59
  return item.id;
59
60
  };
61
+ // Shared content caches (element TypeRegistry, content type templates).
62
+ // Threaded into every SectionRef/ContentResource so hierarchy traversal
63
+ // doesn't re-fetch instance-wide data on each t4.section(id) call.
64
+ this.contentCache = new content_cache_js_1.ContentCache(this.httpClient, this.defaultLanguage, this.mediaCreateFn);
60
65
  }
61
66
  /**
62
67
  * Returns a section reference scoped to the given section ID.
63
68
  */
64
69
  section(id) {
65
- return new section_ref_js_1.SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn);
70
+ return new section_ref_js_1.SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn, this.contentCache);
66
71
  }
67
72
  /**
68
73
  * Returns a media category reference scoped to the given category ID.
@@ -68,6 +68,45 @@ export declare function flattenGroups(groups: Array<{
68
68
  name: string;
69
69
  groupChildren?: unknown[];
70
70
  }>, map?: Map<number, string>): Map<number, string>;
71
+ /**
72
+ * Group/visibility mapping helpers.
73
+ *
74
+ * The T4 API represents an asset's owning group as `primaryGroup` (an object,
75
+ * where the id may be direct or nested under `group`) and its shared groups as
76
+ * `sharedGroups` (an array of `{ id }`). The SDK exposes these as a plain
77
+ * `primaryGroup: number` (0 = none) and `sharedGroups: number[]`. These helpers
78
+ * centralise the read/write mapping so content types, lists, page layouts, and
79
+ * navigation objects all handle groups identically.
80
+ */
81
+ /** Raw shape of the API `primaryGroup` field. */
82
+ export interface RawPrimaryGroup {
83
+ id: number | null;
84
+ group?: {
85
+ id: number;
86
+ };
87
+ }
88
+ /** Reads the API `primaryGroup` object into a plain group id (0 = none). */
89
+ export declare function readPrimaryGroup(raw?: RawPrimaryGroup): number;
90
+ /** Reads the API `sharedGroups` array into a plain array of group ids. */
91
+ export declare function readSharedGroups(raw?: Array<{
92
+ id: number;
93
+ }>): number[];
94
+ /** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
95
+ export declare function writePrimaryGroup(id: number): {
96
+ id: number | null;
97
+ };
98
+ /** Writes a plain array of group ids back to the API `sharedGroups` shape. */
99
+ export declare function writeSharedGroups(ids?: number[]): Array<{
100
+ id: number;
101
+ }>;
102
+ /**
103
+ * Validates that group/visibility values are acceptable to the T4 API before a
104
+ * write. The API returns an opaque 500 when `sharedGroups` contains the same id
105
+ * as `primaryGroup` (a group cannot be both the owner and a shared group), so we
106
+ * catch it here with a clear message. A `primaryGroup` of 0 means "no owning
107
+ * group" and is never a conflict.
108
+ */
109
+ export declare function assertGroupsValid(primaryGroup: number, sharedGroups?: number[]): void;
71
110
  /** Accepted file input: a file path, URL, Blob, ReadableStream, or { file, filename } object */
72
111
  export type FileInput = string | Blob | NodeJS.ReadableStream | {
73
112
  file: string | Blob | NodeJS.ReadableStream;
package/dist/cjs/utils.js CHANGED
@@ -42,6 +42,11 @@ exports.parseFileSize = parseFileSize;
42
42
  exports.parseElementKey = parseElementKey;
43
43
  exports.mapStatus = mapStatus;
44
44
  exports.flattenGroups = flattenGroups;
45
+ exports.readPrimaryGroup = readPrimaryGroup;
46
+ exports.readSharedGroups = readSharedGroups;
47
+ exports.writePrimaryGroup = writePrimaryGroup;
48
+ exports.writeSharedGroups = writeSharedGroups;
49
+ exports.assertGroupsValid = assertGroupsValid;
45
50
  exports.resolveFileToBlob = resolveFileToBlob;
46
51
  exports.deriveFilename = deriveFilename;
47
52
  exports.debugWarn = debugWarn;
@@ -221,6 +226,38 @@ function flattenGroups(groups, map = new Map()) {
221
226
  }
222
227
  return map;
223
228
  }
229
+ /** Reads the API `primaryGroup` object into a plain group id (0 = none). */
230
+ function readPrimaryGroup(raw) {
231
+ return raw?.group?.id ?? raw?.id ?? 0;
232
+ }
233
+ /** Reads the API `sharedGroups` array into a plain array of group ids. */
234
+ function readSharedGroups(raw) {
235
+ return (raw ?? []).map((g) => g.id);
236
+ }
237
+ /** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
238
+ function writePrimaryGroup(id) {
239
+ return { id: id || null };
240
+ }
241
+ /** Writes a plain array of group ids back to the API `sharedGroups` shape. */
242
+ function writeSharedGroups(ids) {
243
+ return (ids ?? []).map((id) => ({ id }));
244
+ }
245
+ /**
246
+ * Validates that group/visibility values are acceptable to the T4 API before a
247
+ * write. The API returns an opaque 500 when `sharedGroups` contains the same id
248
+ * as `primaryGroup` (a group cannot be both the owner and a shared group), so we
249
+ * catch it here with a clear message. A `primaryGroup` of 0 means "no owning
250
+ * group" and is never a conflict.
251
+ */
252
+ function assertGroupsValid(primaryGroup, sharedGroups) {
253
+ if (!primaryGroup)
254
+ return;
255
+ if ((sharedGroups ?? []).includes(primaryGroup)) {
256
+ throw new Error(`sharedGroups cannot contain the primaryGroup id (${primaryGroup}). ` +
257
+ 'A group cannot be both the primary (owning) group and a shared group. ' +
258
+ 'Remove it from sharedGroups or choose a different primaryGroup.');
259
+ }
260
+ }
224
261
  /**
225
262
  * Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
226
263
  */
@@ -0,0 +1,38 @@
1
+ import { HttpClient } from './http-client.js';
2
+ import { TypeRegistry } from './type-registry.js';
3
+ import { ElementResolver, MediaCreateFn } from './element-resolver.js';
4
+ import { TtlMap } from './utils.js';
5
+ /**
6
+ * Client-level shared caches for content operations.
7
+ *
8
+ * A single instance is created per `T4Client` and threaded into every
9
+ * `ContentResource` (including the short-lived ones created by
10
+ * `SectionRef.get()` / `addSection()`). This ensures that traversing the
11
+ * hierarchy — which creates many `ContentResource` instances via
12
+ * `t4.section(id)` — does not re-fetch instance-wide data on every hop.
13
+ *
14
+ * Two things previously lived on each `ContentResource` and were rebuilt per
15
+ * section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
16
+ * `GET /content/type/{ct}/{section}`):
17
+ *
18
+ * - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
19
+ * as a single registry and a single `ElementResolver`.
20
+ * - Content type templates — split into:
21
+ * - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
22
+ * response, keyed by content type ID.
23
+ * - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
24
+ * response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
25
+ *
26
+ * All caches respect the global cache epoch, so `T4Client.clearCache()`
27
+ * invalidates them the same way it always has.
28
+ */
29
+ export declare class ContentCache {
30
+ readonly typeRegistry: TypeRegistry;
31
+ readonly resolver: ElementResolver;
32
+ /** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
33
+ readonly sectionTemplates: TtlMap<string, unknown>;
34
+ /** `GET /contenttype/{id}` responses, keyed by content type ID. */
35
+ readonly contentTypeDefinitions: TtlMap<number, unknown>;
36
+ constructor(httpClient: HttpClient, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
37
+ }
38
+ //# sourceMappingURL=content-cache.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { TypeRegistry } from './type-registry.js';
2
+ import { ElementResolver } from './element-resolver.js';
3
+ import { TtlMap } from './utils.js';
4
+ /**
5
+ * Client-level shared caches for content operations.
6
+ *
7
+ * A single instance is created per `T4Client` and threaded into every
8
+ * `ContentResource` (including the short-lived ones created by
9
+ * `SectionRef.get()` / `addSection()`). This ensures that traversing the
10
+ * hierarchy — which creates many `ContentResource` instances via
11
+ * `t4.section(id)` — does not re-fetch instance-wide data on every hop.
12
+ *
13
+ * Two things previously lived on each `ContentResource` and were rebuilt per
14
+ * section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
15
+ * `GET /content/type/{ct}/{section}`):
16
+ *
17
+ * - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
18
+ * as a single registry and a single `ElementResolver`.
19
+ * - Content type templates — split into:
20
+ * - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
21
+ * response, keyed by content type ID.
22
+ * - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
23
+ * response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
24
+ *
25
+ * All caches respect the global cache epoch, so `T4Client.clearCache()`
26
+ * invalidates them the same way it always has.
27
+ */
28
+ export class ContentCache {
29
+ constructor(httpClient, defaultLanguage, mediaCreateFn) {
30
+ /** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
31
+ this.sectionTemplates = new TtlMap();
32
+ /** `GET /contenttype/{id}` responses, keyed by content type ID. */
33
+ this.contentTypeDefinitions = new TtlMap();
34
+ this.typeRegistry = new TypeRegistry(httpClient);
35
+ this.resolver = new ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
36
+ }
37
+ }
38
+ //# sourceMappingURL=content-cache.js.map
@@ -83,6 +83,22 @@ export declare class ElementResolver {
83
83
  * Uses the TypeRegistry to look up type names instead of hardcoded IDs.
84
84
  */
85
85
  resolveValue(value: unknown, element: TemplateElement, language: string, allElements?: TemplateElement[], context?: ResolveContext): Promise<unknown>;
86
+ /**
87
+ * Builds the T4 elements map from developer-friendly field names and values.
88
+ * Resolves list values, dates, repeaters, etc. automatically.
89
+ *
90
+ * Shared by both write paths: `ContentResource` (create/update) and
91
+ * `ContentItem.save()`. Keeping a single implementation here prevents the two
92
+ * paths from drifting — the reason repeaters previously only resolved on one
93
+ * of them.
94
+ */
95
+ buildElements(fields: Record<string, unknown>, elements: TemplateElement[], name: string, language: string, sectionId: number, context?: ResolveContext): Promise<Record<string, unknown>>;
96
+ /**
97
+ * Builds a repeater value array from developer-friendly input.
98
+ * Each repeater item gets its own element key resolution using the
99
+ * repeater's sub-content-type elements from contentTypeElementConfiguration.
100
+ */
101
+ buildRepeaterValue(items: RepeaterInput[], element: TemplateElement, language: string, sectionId: number): Promise<unknown[]>;
86
102
  getList(listId: number, language: string): Promise<ListResponse>;
87
103
  private resolveItemName;
88
104
  private resolveDate;
@@ -79,11 +79,87 @@ export class ElementResolver {
79
79
  case 'Keyword Selector':
80
80
  return this.resolveKeywordSelector(value, element.listId, language);
81
81
  case 'Repeater':
82
- return value; // handled separately in buildElements
82
+ return value; // handled separately in buildElements / buildRepeaterValue
83
83
  default:
84
84
  return value;
85
85
  }
86
86
  }
87
+ /**
88
+ * Builds the T4 elements map from developer-friendly field names and values.
89
+ * Resolves list values, dates, repeaters, etc. automatically.
90
+ *
91
+ * Shared by both write paths: `ContentResource` (create/update) and
92
+ * `ContentItem.save()`. Keeping a single implementation here prevents the two
93
+ * paths from drifting — the reason repeaters previously only resolved on one
94
+ * of them.
95
+ */
96
+ async buildElements(fields, elements, name, language, sectionId, context) {
97
+ const result = {};
98
+ // Name element
99
+ const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
100
+ if (nameEl) {
101
+ result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
102
+ }
103
+ for (const [fieldName, value] of Object.entries(fields)) {
104
+ const fieldLower = fieldName.toLowerCase();
105
+ const element = elements.find((el) => el.name.toLowerCase() === fieldLower
106
+ || (el.alias && el.alias.toLowerCase() === fieldLower));
107
+ if (!element) {
108
+ const validNames = elements
109
+ .filter((el) => el.name.toLowerCase() !== 'name')
110
+ .map((el) => `"${el.alias || el.name}"`)
111
+ .join(', ');
112
+ throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
113
+ }
114
+ const key = `${element.name}#${element.id}:${element.type}`;
115
+ // Repeater — special handling (no maxSize validation)
116
+ const typeName = await this.typeRegistry.getNameById(element.type);
117
+ if (typeName === 'Repeater' && Array.isArray(value)) {
118
+ result[key] = await this.buildRepeaterValue(value, element, language, sectionId);
119
+ continue;
120
+ }
121
+ const resolved = await this.resolveValue(value, element, language, elements, context);
122
+ // Validate maxSize on the resolved value (what actually gets sent to the API)
123
+ if (element.maxSize) {
124
+ const resolvedStr = String(resolved ?? '');
125
+ if (resolvedStr.length > element.maxSize) {
126
+ const friendlyName = element.alias || element.name;
127
+ throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
128
+ }
129
+ }
130
+ result[key] = resolved;
131
+ }
132
+ return result;
133
+ }
134
+ /**
135
+ * Builds a repeater value array from developer-friendly input.
136
+ * Each repeater item gets its own element key resolution using the
137
+ * repeater's sub-content-type elements from contentTypeElementConfiguration.
138
+ */
139
+ async buildRepeaterValue(items, element, language, sectionId) {
140
+ const config = element.contentTypeElementConfiguration;
141
+ const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
142
+ if (!repeaterElements || repeaterElements.length === 0)
143
+ return items;
144
+ const result = [];
145
+ for (const item of items) {
146
+ const repeaterId = -Math.floor(Math.random() * 100000);
147
+ // Repeater items use their own repeaterId as fromContentId for SS links
148
+ const repeaterContext = {
149
+ fromSectionId: sectionId,
150
+ fromContentId: repeaterId,
151
+ };
152
+ const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, sectionId, repeaterContext);
153
+ result.push({
154
+ repeaterId,
155
+ repeaterContent: {
156
+ name: item.name,
157
+ elements,
158
+ },
159
+ });
160
+ }
161
+ return result;
162
+ }
87
163
  // ── List fetching ──
88
164
  async getList(listId, language) {
89
165
  const cached = this.listCache.get(listId);
@@ -526,7 +526,19 @@ export class ContentItem {
526
526
  }
527
527
  let resolved;
528
528
  if (templateEl && this._resolver) {
529
- resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
529
+ // Repeater fields need the same dedicated resolution ContentResource
530
+ // uses on create/update — each item's sub-fields resolved into element
531
+ // keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
532
+ // passes repeater arrays through untouched, so branch here explicitly.
533
+ const typeName = this._typeRegistry
534
+ ? await this._typeRegistry.getNameById(templateEl.type)
535
+ : null;
536
+ if (typeName === 'Repeater' && Array.isArray(value)) {
537
+ resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
538
+ }
539
+ else {
540
+ resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
541
+ }
530
542
  }
531
543
  else {
532
544
  resolved = value;
@@ -2,6 +2,7 @@ import { HttpClient } from '../http-client.js';
2
2
  import { LanguageOption, CreateContentData, UpdateContentData } from '../types.js';
3
3
  import { ContentItem } from '../models/content-item.js';
4
4
  import { MediaCreateFn } from '../element-resolver.js';
5
+ import { ContentCache } from '../content-cache.js';
5
6
  /**
6
7
  * Section-scoped resource for content CRUD operations.
7
8
  * All requests are scoped to the section ID provided at construction time.
@@ -10,23 +11,18 @@ export declare class ContentResource {
10
11
  private readonly httpClient;
11
12
  private readonly sectionId;
12
13
  private readonly defaultLanguage;
13
- private readonly resolver;
14
- private readonly typeRegistry;
15
- /** Cache of content type templates keyed by content type ID */
16
- private templateCache;
17
- constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
14
+ private readonly cache;
15
+ constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
16
+ private get resolver();
17
+ private get typeRegistry();
18
18
  private getTemplate;
19
+ /** Fetches the section-independent content type definition, cached by content type ID. */
20
+ private getContentTypeDefinition;
19
21
  /**
20
22
  * Builds the T4 elements map from developer-friendly field names and values.
21
23
  * Resolves list values, dates, repeaters, etc. automatically.
22
24
  */
23
25
  private buildElements;
24
- /**
25
- * Builds repeater value array from developer-friendly input.
26
- * Each repeater item gets its own element key resolution using the
27
- * repeater's sub-content-type elements from contentTypeElementConfiguration.
28
- */
29
- private buildRepeaterValue;
30
26
  /** Lists all content items in this section. */
31
27
  list(options?: LanguageOption): Promise<ContentItem[]>;
32
28
  /** Retrieves a single content item by ID. */