@terminalfour/terminalfour-js 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) 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/handlebars.js +1 -0
  6. package/dist/cjs/models/content-item.js +14 -1
  7. package/dist/cjs/models/media-category-item.js +2 -0
  8. package/dist/cjs/models/media-item.js +1 -0
  9. package/dist/cjs/models/section-item.js +1 -0
  10. package/dist/cjs/resources/content-resource.d.ts +7 -11
  11. package/dist/cjs/resources/content-resource.js +38 -79
  12. package/dist/cjs/resources/content-type-resource.d.ts +2 -6
  13. package/dist/cjs/resources/content-type-resource.js +20 -6
  14. package/dist/cjs/resources/group-resource.js +1 -0
  15. package/dist/cjs/resources/list-resource.d.ts +2 -6
  16. package/dist/cjs/resources/list-resource.js +9 -6
  17. package/dist/cjs/resources/media-resource.js +9 -2
  18. package/dist/cjs/resources/media-type-resource.js +1 -0
  19. package/dist/cjs/resources/navigation-resource.d.ts +17 -0
  20. package/dist/cjs/resources/navigation-resource.js +14 -2
  21. package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
  22. package/dist/cjs/resources/page-layout-resource.js +13 -2
  23. package/dist/cjs/section-ref.d.ts +3 -1
  24. package/dist/cjs/section-ref.js +11 -5
  25. package/dist/cjs/t4-client.d.ts +1 -0
  26. package/dist/cjs/t4-client.js +6 -1
  27. package/dist/cjs/utils.d.ts +56 -0
  28. package/dist/cjs/utils.js +66 -0
  29. package/dist/esm/content-cache.d.ts +38 -0
  30. package/dist/esm/content-cache.js +38 -0
  31. package/dist/esm/element-resolver.d.ts +16 -0
  32. package/dist/esm/element-resolver.js +77 -1
  33. package/dist/esm/handlebars.js +2 -1
  34. package/dist/esm/models/content-item.js +15 -2
  35. package/dist/esm/models/media-category-item.js +2 -0
  36. package/dist/esm/models/media-item.js +2 -1
  37. package/dist/esm/models/section-item.js +2 -1
  38. package/dist/esm/resources/content-resource.d.ts +7 -11
  39. package/dist/esm/resources/content-resource.js +39 -80
  40. package/dist/esm/resources/content-type-resource.d.ts +2 -6
  41. package/dist/esm/resources/content-type-resource.js +21 -7
  42. package/dist/esm/resources/group-resource.js +2 -1
  43. package/dist/esm/resources/list-resource.d.ts +2 -6
  44. package/dist/esm/resources/list-resource.js +10 -7
  45. package/dist/esm/resources/media-resource.js +10 -3
  46. package/dist/esm/resources/media-type-resource.js +2 -1
  47. package/dist/esm/resources/navigation-resource.d.ts +17 -0
  48. package/dist/esm/resources/navigation-resource.js +14 -2
  49. package/dist/esm/resources/page-layout-resource.d.ts +11 -0
  50. package/dist/esm/resources/page-layout-resource.js +14 -3
  51. package/dist/esm/section-ref.d.ts +3 -1
  52. package/dist/esm/section-ref.js +12 -6
  53. package/dist/esm/t4-client.d.ts +1 -0
  54. package/dist/esm/t4-client.js +6 -1
  55. package/dist/esm/utils.d.ts +56 -0
  56. package/dist/esm/utils.js +59 -0
  57. package/docs/content-types.md +3 -3
  58. package/docs/content.md +13 -0
  59. package/docs/error-handling.md +29 -1
  60. package/docs/getting-started.md +4 -0
  61. package/docs/lists.md +2 -2
  62. package/docs/navigation.md +30 -1
  63. package/docs/page-layouts.md +26 -0
  64. package/package.json +1 -1
@@ -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,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContentCache = void 0;
4
+ const type_registry_js_1 = require("./type-registry.js");
5
+ const element_resolver_js_1 = require("./element-resolver.js");
6
+ const utils_js_1 = require("./utils.js");
7
+ /**
8
+ * Client-level shared caches for content operations.
9
+ *
10
+ * A single instance is created per `T4Client` and threaded into every
11
+ * `ContentResource` (including the short-lived ones created by
12
+ * `SectionRef.get()` / `addSection()`). This ensures that traversing the
13
+ * hierarchy — which creates many `ContentResource` instances via
14
+ * `t4.section(id)` — does not re-fetch instance-wide data on every hop.
15
+ *
16
+ * Two things previously lived on each `ContentResource` and were rebuilt per
17
+ * section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
18
+ * `GET /content/type/{ct}/{section}`):
19
+ *
20
+ * - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
21
+ * as a single registry and a single `ElementResolver`.
22
+ * - Content type templates — split into:
23
+ * - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
24
+ * response, keyed by content type ID.
25
+ * - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
26
+ * response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
27
+ *
28
+ * All caches respect the global cache epoch, so `T4Client.clearCache()`
29
+ * invalidates them the same way it always has.
30
+ */
31
+ class ContentCache {
32
+ constructor(httpClient, defaultLanguage, mediaCreateFn) {
33
+ /** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
34
+ this.sectionTemplates = new utils_js_1.TtlMap();
35
+ /** `GET /contenttype/{id}` responses, keyed by content type ID. */
36
+ this.contentTypeDefinitions = new utils_js_1.TtlMap();
37
+ this.typeRegistry = new type_registry_js_1.TypeRegistry(httpClient);
38
+ this.resolver = new element_resolver_js_1.ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
39
+ }
40
+ }
41
+ exports.ContentCache = ContentCache;
42
+ //# 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;
@@ -82,11 +82,87 @@ class ElementResolver {
82
82
  case 'Keyword Selector':
83
83
  return this.resolveKeywordSelector(value, element.listId, language);
84
84
  case 'Repeater':
85
- return value; // handled separately in buildElements
85
+ return value; // handled separately in buildElements / buildRepeaterValue
86
86
  default:
87
87
  return value;
88
88
  }
89
89
  }
90
+ /**
91
+ * Builds the T4 elements map from developer-friendly field names and values.
92
+ * Resolves list values, dates, repeaters, etc. automatically.
93
+ *
94
+ * Shared by both write paths: `ContentResource` (create/update) and
95
+ * `ContentItem.save()`. Keeping a single implementation here prevents the two
96
+ * paths from drifting — the reason repeaters previously only resolved on one
97
+ * of them.
98
+ */
99
+ async buildElements(fields, elements, name, language, sectionId, context) {
100
+ const result = {};
101
+ // Name element
102
+ const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
103
+ if (nameEl) {
104
+ result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
105
+ }
106
+ for (const [fieldName, value] of Object.entries(fields)) {
107
+ const fieldLower = fieldName.toLowerCase();
108
+ const element = elements.find((el) => el.name.toLowerCase() === fieldLower
109
+ || (el.alias && el.alias.toLowerCase() === fieldLower));
110
+ if (!element) {
111
+ const validNames = elements
112
+ .filter((el) => el.name.toLowerCase() !== 'name')
113
+ .map((el) => `"${el.alias || el.name}"`)
114
+ .join(', ');
115
+ throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
116
+ }
117
+ const key = `${element.name}#${element.id}:${element.type}`;
118
+ // Repeater — special handling (no maxSize validation)
119
+ const typeName = await this.typeRegistry.getNameById(element.type);
120
+ if (typeName === 'Repeater' && Array.isArray(value)) {
121
+ result[key] = await this.buildRepeaterValue(value, element, language, sectionId);
122
+ continue;
123
+ }
124
+ const resolved = await this.resolveValue(value, element, language, elements, context);
125
+ // Validate maxSize on the resolved value (what actually gets sent to the API)
126
+ if (element.maxSize) {
127
+ const resolvedStr = String(resolved ?? '');
128
+ if (resolvedStr.length > element.maxSize) {
129
+ const friendlyName = element.alias || element.name;
130
+ throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
131
+ }
132
+ }
133
+ result[key] = resolved;
134
+ }
135
+ return result;
136
+ }
137
+ /**
138
+ * Builds a repeater value array from developer-friendly input.
139
+ * Each repeater item gets its own element key resolution using the
140
+ * repeater's sub-content-type elements from contentTypeElementConfiguration.
141
+ */
142
+ async buildRepeaterValue(items, element, language, sectionId) {
143
+ const config = element.contentTypeElementConfiguration;
144
+ const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
145
+ if (!repeaterElements || repeaterElements.length === 0)
146
+ return items;
147
+ const result = [];
148
+ for (const item of items) {
149
+ const repeaterId = -Math.floor(Math.random() * 100000);
150
+ // Repeater items use their own repeaterId as fromContentId for SS links
151
+ const repeaterContext = {
152
+ fromSectionId: sectionId,
153
+ fromContentId: repeaterId,
154
+ };
155
+ const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, sectionId, repeaterContext);
156
+ result.push({
157
+ repeaterId,
158
+ repeaterContent: {
159
+ name: item.name,
160
+ elements,
161
+ },
162
+ });
163
+ }
164
+ return result;
165
+ }
90
166
  // ── List fetching ──
91
167
  async getList(listId, language) {
92
168
  const cached = this.listCache.get(listId);
@@ -25,6 +25,7 @@ class HandlebarsItem {
25
25
  * Always saves with approved status.
26
26
  */
27
27
  async save() {
28
+ (0, utils_js_1.assertRequired)(this.name, 'Name');
28
29
  // Update elements with current name and code
29
30
  const elements = { ...this._rawDTO.elements };
30
31
  for (const key of Object.keys(elements)) {
@@ -502,6 +502,7 @@ class ContentItem {
502
502
  * save and approve in one step.
503
503
  */
504
504
  async save() {
505
+ (0, utils_js_1.assertRequired)(this.name, 'Content name');
505
506
  // Start from the original raw elements (correct API format)
506
507
  const rawElements = { ...this._rawDTO.elements };
507
508
  // Re-resolve dirty fields through the ElementResolver so friendly values
@@ -531,7 +532,19 @@ class ContentItem {
531
532
  }
532
533
  let resolved;
533
534
  if (templateEl && this._resolver) {
534
- resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
535
+ // Repeater fields need the same dedicated resolution ContentResource
536
+ // uses on create/update — each item's sub-fields resolved into element
537
+ // keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
538
+ // passes repeater arrays through untouched, so branch here explicitly.
539
+ const typeName = this._typeRegistry
540
+ ? await this._typeRegistry.getNameById(templateEl.type)
541
+ : null;
542
+ if (typeName === 'Repeater' && Array.isArray(value)) {
543
+ resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
544
+ }
545
+ else {
546
+ resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
547
+ }
535
548
  }
536
549
  else {
537
550
  resolved = value;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MediaCategoryItem = void 0;
4
+ const utils_js_1 = require("../utils.js");
4
5
  /**
5
6
  * A mutable media category object. Modify properties and call save() to persist.
6
7
  */
@@ -17,6 +18,7 @@ class MediaCategoryItem {
17
18
  }
18
19
  /** Persists current property values to the server via PUT. */
19
20
  async save() {
21
+ (0, utils_js_1.assertRequired)(this.name, 'Media category name');
20
22
  const updated = {
21
23
  ...this._rawData,
22
24
  name: this.name,
@@ -80,6 +80,7 @@ class MediaItem {
80
80
  * If `content` has changed on non-binary media, the version is bumped.
81
81
  */
82
82
  async save() {
83
+ (0, utils_js_1.assertRequired)(this.name, 'Media name');
83
84
  const categoryId = this.categories[0];
84
85
  if (!categoryId) {
85
86
  throw new Error('Cannot save media item — no category assigned.');
@@ -34,6 +34,7 @@ class SectionItem {
34
34
  }
35
35
  /** Persists current property values to the server via PUT. */
36
36
  async save() {
37
+ (0, utils_js_1.assertRequired)(this.name, 'Section name');
37
38
  const statusCode = utils_js_1.STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
38
39
  const updated = {
39
40
  ...this._rawData,
@@ -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. */
@@ -3,36 +3,43 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContentResource = void 0;
4
4
  const utils_js_1 = require("../utils.js");
5
5
  const content_item_js_1 = require("../models/content-item.js");
6
- const element_resolver_js_1 = require("../element-resolver.js");
7
- const type_registry_js_1 = require("../type-registry.js");
6
+ const content_cache_js_1 = require("../content-cache.js");
8
7
  /**
9
8
  * Section-scoped resource for content CRUD operations.
10
9
  * All requests are scoped to the section ID provided at construction time.
11
10
  */
12
11
  class ContentResource {
13
- constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
14
- /** Cache of content type templates keyed by content type ID */
15
- this.templateCache = new utils_js_1.TtlMap();
12
+ constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
16
13
  this.httpClient = httpClient;
17
14
  this.sectionId = sectionId;
18
15
  this.defaultLanguage = defaultLanguage;
19
- this.typeRegistry = new type_registry_js_1.TypeRegistry(httpClient);
20
- this.resolver = new element_resolver_js_1.ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
16
+ // A shared cache is threaded in by T4Client so the element TypeRegistry and
17
+ // content type templates survive across sections during a traversal. When
18
+ // constructed standalone (e.g. in tests), fall back to a private cache so
19
+ // behaviour is unchanged.
20
+ this.cache = cache ?? new content_cache_js_1.ContentCache(httpClient, defaultLanguage, mediaCreateFn);
21
+ }
22
+ get resolver() {
23
+ return this.cache.resolver;
24
+ }
25
+ get typeRegistry() {
26
+ return this.cache.typeRegistry;
21
27
  }
22
28
  async getTemplate(contentTypeId) {
23
- const cached = this.templateCache.get(contentTypeId);
29
+ const sectionKey = `${contentTypeId}:${this.sectionId}`;
30
+ const cached = this.cache.sectionTemplates.get(sectionKey);
24
31
  if (cached)
25
32
  return cached;
26
- // Fetch both the new content template and the full content type definition
33
+ // The section template (channels etc.) is section-specific; the content type
34
+ // definition (alias, listId, repeater config) is instance-wide. Fetch each
35
+ // through its own shared cache so revisiting a section, or reusing a content
36
+ // type across sections, avoids re-fetching.
27
37
  const [template, rawContentType] = await Promise.all([
28
38
  this.httpClient.request({
29
39
  method: 'GET',
30
40
  path: `/content/type/${contentTypeId}/${this.sectionId}`,
31
41
  }),
32
- this.httpClient.request({
33
- method: 'GET',
34
- path: `/contenttype/${contentTypeId}`,
35
- }),
42
+ this.getContentTypeDefinition(contentTypeId),
36
43
  ]);
37
44
  // Merge alias and contentTypeElementConfiguration from the full content type
38
45
  for (const templateEl of template.contentType.contentTypeElements) {
@@ -47,79 +54,29 @@ class ContentResource {
47
54
  }
48
55
  }
49
56
  }
50
- this.templateCache.set(contentTypeId, template);
57
+ this.cache.sectionTemplates.set(sectionKey, template);
51
58
  return template;
52
59
  }
60
+ /** Fetches the section-independent content type definition, cached by content type ID. */
61
+ async getContentTypeDefinition(contentTypeId) {
62
+ const cached = this.cache.contentTypeDefinitions.get(contentTypeId);
63
+ if (cached)
64
+ return cached;
65
+ const rawContentType = await this.httpClient.request({
66
+ method: 'GET',
67
+ path: `/contenttype/${contentTypeId}`,
68
+ });
69
+ this.cache.contentTypeDefinitions.set(contentTypeId, rawContentType);
70
+ return rawContentType;
71
+ }
53
72
  /**
54
73
  * Builds the T4 elements map from developer-friendly field names and values.
55
74
  * Resolves list values, dates, repeaters, etc. automatically.
56
75
  */
57
76
  async buildElements(fields, elements, name, language, context) {
58
- const result = {};
59
- // Name element
60
- const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
61
- if (nameEl) {
62
- result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
63
- }
64
- for (const [fieldName, value] of Object.entries(fields)) {
65
- const fieldLower = fieldName.toLowerCase();
66
- const element = elements.find((el) => el.name.toLowerCase() === fieldLower
67
- || (el.alias && el.alias.toLowerCase() === fieldLower));
68
- if (!element) {
69
- const validNames = elements
70
- .filter((el) => el.name.toLowerCase() !== 'name')
71
- .map((el) => `"${el.alias || el.name}"`)
72
- .join(', ');
73
- throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
74
- }
75
- const key = `${element.name}#${element.id}:${element.type}`;
76
- // Repeater — special handling (no maxSize validation)
77
- const typeName = await this.typeRegistry.getNameById(element.type);
78
- if (typeName === 'Repeater' && Array.isArray(value)) {
79
- result[key] = await this.buildRepeaterValue(value, element, language);
80
- continue;
81
- }
82
- const resolved = await this.resolver.resolveValue(value, element, language, elements, context);
83
- // Validate maxSize on the resolved value (what actually gets sent to the API)
84
- if (element.maxSize) {
85
- const resolvedStr = String(resolved ?? '');
86
- if (resolvedStr.length > element.maxSize) {
87
- const friendlyName = element.alias || element.name;
88
- throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
89
- }
90
- }
91
- result[key] = resolved;
92
- }
93
- return result;
94
- }
95
- /**
96
- * Builds repeater value array from developer-friendly input.
97
- * Each repeater item gets its own element key resolution using the
98
- * repeater's sub-content-type elements from contentTypeElementConfiguration.
99
- */
100
- async buildRepeaterValue(items, element, language) {
101
- const config = element.contentTypeElementConfiguration;
102
- const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
103
- if (!repeaterElements || repeaterElements.length === 0)
104
- return items;
105
- const result = [];
106
- for (const item of items) {
107
- const repeaterId = -Math.floor(Math.random() * 100000);
108
- // Repeater items use their own repeaterId as fromContentId for SS links
109
- const repeaterContext = {
110
- fromSectionId: this.sectionId,
111
- fromContentId: repeaterId,
112
- };
113
- const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, repeaterContext);
114
- result.push({
115
- repeaterId,
116
- repeaterContent: {
117
- name: item.name,
118
- elements,
119
- },
120
- });
121
- }
122
- return result;
77
+ // Delegates to the shared implementation on ElementResolver so this path
78
+ // and ContentItem.save() resolve fields (repeaters included) identically.
79
+ return this.resolver.buildElements(fields, elements, name, language, this.sectionId, context);
123
80
  }
124
81
  /** Lists all content items in this section. */
125
82
  async list(options) {
@@ -143,6 +100,7 @@ class ContentResource {
143
100
  }
144
101
  /** Creates a new content item in this section. */
145
102
  async create(data, options) {
103
+ (0, utils_js_1.assertRequired)(data.name, 'Content name');
146
104
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
147
105
  const template = await this.getTemplate(data.type);
148
106
  const contentId = -Math.floor(Math.random() * 1000000);
@@ -178,6 +136,7 @@ class ContentResource {
178
136
  }
179
137
  /** Updates an existing content item's fields. */
180
138
  async update(id, data, options) {
139
+ (0, utils_js_1.assertNotEmptyIfPresent)(data.name, 'Content name');
181
140
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
182
141
  // 1. Fetch the existing content (full body needed for the POST)
183
142
  const existing = await this.httpClient.request({
@@ -1,5 +1,6 @@
1
1
  import { HttpClient } from '../http-client.js';
2
2
  import { ContentTypeData, ContentTypeFieldDef } from '../types.js';
3
+ import { RawPrimaryGroup } from '../utils.js';
3
4
  /** Raw content type element from the API response */
4
5
  interface ApiContentTypeElement {
5
6
  id?: number;
@@ -40,12 +41,7 @@ interface ApiContentType {
40
41
  sharedGroups?: Array<{
41
42
  id: number;
42
43
  }>;
43
- primaryGroup?: {
44
- id: number | null;
45
- group?: {
46
- id: number;
47
- };
48
- };
44
+ primaryGroup?: RawPrimaryGroup;
49
45
  enableDirectEdit?: boolean;
50
46
  elementIdforFilename?: number;
51
47
  contentTypeElements?: ApiContentTypeElement[];
@@ -59,8 +59,8 @@ function mapContentType(raw, typeMap, editorMap) {
59
59
  description: (0, utils_js_1.decodeHtmlEntities)(raw.description ?? ''),
60
60
  minUserLevel: utils_js_1.AUTH_LEVEL_MAP[raw.minAuthLevel ?? 2] ?? `unknown (${raw.minAuthLevel})`,
61
61
  workflow: raw.workflow ?? 0,
62
- sharedGroups: (raw.sharedGroups ?? []).map((g) => g.id),
63
- primaryGroup: raw.primaryGroup?.group?.id ?? raw.primaryGroup?.id ?? 0,
62
+ sharedGroups: (0, utils_js_1.readSharedGroups)(raw.sharedGroups),
63
+ primaryGroup: (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup),
64
64
  directEdit: raw.enableDirectEdit ?? true,
65
65
  fields: fieldsRecord,
66
66
  },
@@ -249,6 +249,7 @@ class ContentType {
249
249
  return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
250
250
  },
251
251
  update: async (layoutName, updateData) => {
252
+ (0, utils_js_1.assertNotEmptyIfPresent)(updateData.name, 'Layout name');
252
253
  const all = await fetchLayouts();
253
254
  const match = all.find((l) => l.name === layoutName);
254
255
  if (!match)
@@ -289,6 +290,7 @@ class ContentType {
289
290
  return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
290
291
  },
291
292
  create: async (createData) => {
293
+ (0, utils_js_1.assertRequired)(createData.name, 'Layout name');
292
294
  // Check name uniqueness
293
295
  const existing = await fetchLayouts();
294
296
  if (existing.some((l) => l.name === createData.name)) {
@@ -590,6 +592,8 @@ class ContentType {
590
592
  }
591
593
  /** Persists current property values to the server via PUT. */
592
594
  async save() {
595
+ (0, utils_js_1.assertRequired)(this.name, 'Content type name');
596
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
593
597
  const authLevel = String(utils_js_1.AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
594
598
  // Sync field changes back to raw contentTypeElements
595
599
  let rawElements = (this._rawData.contentTypeElements ?? []);
@@ -657,8 +661,8 @@ class ContentType {
657
661
  minAuthLevel: authLevel,
658
662
  workflow: String(this.workflow),
659
663
  enableDirectEdit: this.directEdit,
660
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
661
- primaryGroup: { id: this.primaryGroup || null },
664
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
665
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
662
666
  contentTypeElements: rawElements,
663
667
  };
664
668
  // Resolve useAsFilename → elementIdforFilename
@@ -683,6 +687,10 @@ class ContentType {
683
687
  path: `/contenttype/${this.id}`,
684
688
  body: updated,
685
689
  });
690
+ // The content type definition changed, so any cached copy (content type
691
+ // templates/definitions, element type maps, etc.) is now stale. Invalidate
692
+ // all caches via the global epoch so subsequent reads re-fetch.
693
+ (0, utils_js_1.invalidateAllCaches)();
686
694
  // Update raw data for next save
687
695
  this._rawData = updated;
688
696
  this._removedElementIds.clear();
@@ -867,6 +875,9 @@ class ContentTypeResource {
867
875
  method: 'DELETE',
868
876
  path: `/contenttype/${id}`,
869
877
  });
878
+ // A deleted content type may be cached; invalidate all caches so stale
879
+ // definitions/templates aren't served after the delete.
880
+ (0, utils_js_1.invalidateAllCaches)();
870
881
  }
871
882
  /** Creates a new content type. */
872
883
  async create(data) {
@@ -876,6 +887,7 @@ class ContentTypeResource {
876
887
  if (!data.elements?.length) {
877
888
  throw new Error('Content type must have at least one element');
878
889
  }
890
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
879
891
  // Validate useAsFilename constraints
880
892
  const filenameElements = data.elements.filter((el) => el.useAsFilename);
881
893
  if (filenameElements.length > 1) {
@@ -994,11 +1006,13 @@ class ContentTypeResource {
994
1006
  warningMessage: '',
995
1007
  elementIdforFilename: elementIdForFilename,
996
1008
  conditionals: [],
997
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
998
- primaryGroup: { id: data.primaryGroup ?? 0 },
1009
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
1010
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
999
1011
  contentTypeElements,
1000
1012
  },
1001
1013
  });
1014
+ // New content type may affect cached lookups; invalidate for consistency.
1015
+ (0, utils_js_1.invalidateAllCaches)();
1002
1016
  const editorMap = await this.getEditorMap();
1003
1017
  const result = mapContentType(raw, typeMap, editorMap);
1004
1018
  await this.resolveListNames([result.data]);
@@ -43,6 +43,7 @@ class Group {
43
43
  }
44
44
  /** Persists current property values to the server via PUT. */
45
45
  async save() {
46
+ (0, utils_js_1.assertRequired)(this.name, 'Group name');
46
47
  // Start from existing raw members
47
48
  let rawMembers = [...(this._rawData.members ?? [])];
48
49
  // Remove members
@@ -1,5 +1,6 @@
1
1
  import { HttpClient } from '../http-client.js';
2
2
  import { LanguageOption } from '../types.js';
3
+ import { RawPrimaryGroup } from '../utils.js';
3
4
  /** Raw list item from GET /list/{id}/{language} */
4
5
  interface RawListItem {
5
6
  id: number;
@@ -18,12 +19,7 @@ interface RawListDetail {
18
19
  language: string;
19
20
  isForcedLanguage?: boolean;
20
21
  isDefaultLanguage?: boolean;
21
- primaryGroup?: {
22
- id: number | null;
23
- group?: {
24
- id: number;
25
- };
26
- };
22
+ primaryGroup?: RawPrimaryGroup;
27
23
  sharedGroups?: Array<{
28
24
  id: number;
29
25
  }>;