@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
@@ -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);
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CACHE_TTL, getCacheEpoch } from './utils.js';
1
+ import { DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from './utils.js';
2
2
  /**
3
3
  * A mutable Handlebars item (helper or partial).
4
4
  * Modify `name` and/or `code`, then call `save()` to persist.
@@ -22,6 +22,7 @@ export class HandlebarsItem {
22
22
  * Always saves with approved status.
23
23
  */
24
24
  async save() {
25
+ assertRequired(this.name, 'Name');
25
26
  // Update elements with current name and code
26
27
  const elements = { ...this._rawDTO.elements };
27
28
  for (const key of Object.keys(elements)) {
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
1
+ import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
2
2
  /** Symbol used to restrict _init() access to the factory function in this module */
3
3
  const INIT = Symbol('ContentItem.init');
4
4
  /**
@@ -497,6 +497,7 @@ export class ContentItem {
497
497
  * save and approve in one step.
498
498
  */
499
499
  async save() {
500
+ assertRequired(this.name, 'Content name');
500
501
  // Start from the original raw elements (correct API format)
501
502
  const rawElements = { ...this._rawDTO.elements };
502
503
  // Re-resolve dirty fields through the ElementResolver so friendly values
@@ -526,7 +527,19 @@ export class ContentItem {
526
527
  }
527
528
  let resolved;
528
529
  if (templateEl && this._resolver) {
529
- resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
530
+ // Repeater fields need the same dedicated resolution ContentResource
531
+ // uses on create/update — each item's sub-fields resolved into element
532
+ // keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
533
+ // passes repeater arrays through untouched, so branch here explicitly.
534
+ const typeName = this._typeRegistry
535
+ ? await this._typeRegistry.getNameById(templateEl.type)
536
+ : null;
537
+ if (typeName === 'Repeater' && Array.isArray(value)) {
538
+ resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
539
+ }
540
+ else {
541
+ resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
542
+ }
530
543
  }
531
544
  else {
532
545
  resolved = value;
@@ -1,3 +1,4 @@
1
+ import { assertRequired } from '../utils.js';
1
2
  /**
2
3
  * A mutable media category object. Modify properties and call save() to persist.
3
4
  */
@@ -14,6 +15,7 @@ export class MediaCategoryItem {
14
15
  }
15
16
  /** Persists current property values to the server via PUT. */
16
17
  async save() {
18
+ assertRequired(this.name, 'Media category name');
17
19
  const updated = {
18
20
  ...this._rawData,
19
21
  name: this.name,
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename } from '../utils.js';
1
+ import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename, assertRequired } from '../utils.js';
2
2
  /** Extension → syntax type mapping for non-binary media */
3
3
  const EXTENSION_SYNTAX_MAP = {
4
4
  js: 1,
@@ -77,6 +77,7 @@ export class MediaItem {
77
77
  * If `content` has changed on non-binary media, the version is bumped.
78
78
  */
79
79
  async save() {
80
+ assertRequired(this.name, 'Media name');
80
81
  const categoryId = this.categories[0];
81
82
  if (!categoryId) {
82
83
  throw new Error('Cannot save media item — no category assigned.');
@@ -1,4 +1,4 @@
1
- import { mapStatus, STATUS_CODES } from '../utils.js';
1
+ import { mapStatus, STATUS_CODES, assertRequired } from '../utils.js';
2
2
  import { ContentResource } from '../resources/content-resource.js';
3
3
  /**
4
4
  * A mutable section object. Modify properties and call save() to persist.
@@ -31,6 +31,7 @@ export class SectionItem {
31
31
  }
32
32
  /** Persists current property values to the server via PUT. */
33
33
  async save() {
34
+ assertRequired(this.name, 'Section name');
34
35
  const statusCode = STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
35
36
  const updated = {
36
37
  ...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. */
@@ -1,35 +1,42 @@
1
- import { resolveLanguage, toTimestamp, STATUS_CODES, TtlMap } from '../utils.js';
1
+ import { resolveLanguage, toTimestamp, STATUS_CODES, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
2
2
  import { createContentItem } from '../models/content-item.js';
3
- import { ElementResolver } from '../element-resolver.js';
4
- import { TypeRegistry } from '../type-registry.js';
3
+ import { ContentCache } from '../content-cache.js';
5
4
  /**
6
5
  * Section-scoped resource for content CRUD operations.
7
6
  * All requests are scoped to the section ID provided at construction time.
8
7
  */
9
8
  export class ContentResource {
10
- constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
11
- /** Cache of content type templates keyed by content type ID */
12
- this.templateCache = new TtlMap();
9
+ constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
13
10
  this.httpClient = httpClient;
14
11
  this.sectionId = sectionId;
15
12
  this.defaultLanguage = defaultLanguage;
16
- this.typeRegistry = new TypeRegistry(httpClient);
17
- this.resolver = new ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
13
+ // A shared cache is threaded in by T4Client so the element TypeRegistry and
14
+ // content type templates survive across sections during a traversal. When
15
+ // constructed standalone (e.g. in tests), fall back to a private cache so
16
+ // behaviour is unchanged.
17
+ this.cache = cache ?? new ContentCache(httpClient, defaultLanguage, mediaCreateFn);
18
+ }
19
+ get resolver() {
20
+ return this.cache.resolver;
21
+ }
22
+ get typeRegistry() {
23
+ return this.cache.typeRegistry;
18
24
  }
19
25
  async getTemplate(contentTypeId) {
20
- const cached = this.templateCache.get(contentTypeId);
26
+ const sectionKey = `${contentTypeId}:${this.sectionId}`;
27
+ const cached = this.cache.sectionTemplates.get(sectionKey);
21
28
  if (cached)
22
29
  return cached;
23
- // Fetch both the new content template and the full content type definition
30
+ // The section template (channels etc.) is section-specific; the content type
31
+ // definition (alias, listId, repeater config) is instance-wide. Fetch each
32
+ // through its own shared cache so revisiting a section, or reusing a content
33
+ // type across sections, avoids re-fetching.
24
34
  const [template, rawContentType] = await Promise.all([
25
35
  this.httpClient.request({
26
36
  method: 'GET',
27
37
  path: `/content/type/${contentTypeId}/${this.sectionId}`,
28
38
  }),
29
- this.httpClient.request({
30
- method: 'GET',
31
- path: `/contenttype/${contentTypeId}`,
32
- }),
39
+ this.getContentTypeDefinition(contentTypeId),
33
40
  ]);
34
41
  // Merge alias and contentTypeElementConfiguration from the full content type
35
42
  for (const templateEl of template.contentType.contentTypeElements) {
@@ -44,79 +51,29 @@ export class ContentResource {
44
51
  }
45
52
  }
46
53
  }
47
- this.templateCache.set(contentTypeId, template);
54
+ this.cache.sectionTemplates.set(sectionKey, template);
48
55
  return template;
49
56
  }
57
+ /** Fetches the section-independent content type definition, cached by content type ID. */
58
+ async getContentTypeDefinition(contentTypeId) {
59
+ const cached = this.cache.contentTypeDefinitions.get(contentTypeId);
60
+ if (cached)
61
+ return cached;
62
+ const rawContentType = await this.httpClient.request({
63
+ method: 'GET',
64
+ path: `/contenttype/${contentTypeId}`,
65
+ });
66
+ this.cache.contentTypeDefinitions.set(contentTypeId, rawContentType);
67
+ return rawContentType;
68
+ }
50
69
  /**
51
70
  * Builds the T4 elements map from developer-friendly field names and values.
52
71
  * Resolves list values, dates, repeaters, etc. automatically.
53
72
  */
54
73
  async buildElements(fields, elements, name, language, context) {
55
- const result = {};
56
- // Name element
57
- const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
58
- if (nameEl) {
59
- result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
60
- }
61
- for (const [fieldName, value] of Object.entries(fields)) {
62
- const fieldLower = fieldName.toLowerCase();
63
- const element = elements.find((el) => el.name.toLowerCase() === fieldLower
64
- || (el.alias && el.alias.toLowerCase() === fieldLower));
65
- if (!element) {
66
- const validNames = elements
67
- .filter((el) => el.name.toLowerCase() !== 'name')
68
- .map((el) => `"${el.alias || el.name}"`)
69
- .join(', ');
70
- throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
71
- }
72
- const key = `${element.name}#${element.id}:${element.type}`;
73
- // Repeater — special handling (no maxSize validation)
74
- const typeName = await this.typeRegistry.getNameById(element.type);
75
- if (typeName === 'Repeater' && Array.isArray(value)) {
76
- result[key] = await this.buildRepeaterValue(value, element, language);
77
- continue;
78
- }
79
- const resolved = await this.resolver.resolveValue(value, element, language, elements, context);
80
- // Validate maxSize on the resolved value (what actually gets sent to the API)
81
- if (element.maxSize) {
82
- const resolvedStr = String(resolved ?? '');
83
- if (resolvedStr.length > element.maxSize) {
84
- const friendlyName = element.alias || element.name;
85
- throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
86
- }
87
- }
88
- result[key] = resolved;
89
- }
90
- return result;
91
- }
92
- /**
93
- * Builds repeater value array from developer-friendly input.
94
- * Each repeater item gets its own element key resolution using the
95
- * repeater's sub-content-type elements from contentTypeElementConfiguration.
96
- */
97
- async buildRepeaterValue(items, element, language) {
98
- const config = element.contentTypeElementConfiguration;
99
- const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
100
- if (!repeaterElements || repeaterElements.length === 0)
101
- return items;
102
- const result = [];
103
- for (const item of items) {
104
- const repeaterId = -Math.floor(Math.random() * 100000);
105
- // Repeater items use their own repeaterId as fromContentId for SS links
106
- const repeaterContext = {
107
- fromSectionId: this.sectionId,
108
- fromContentId: repeaterId,
109
- };
110
- const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, repeaterContext);
111
- result.push({
112
- repeaterId,
113
- repeaterContent: {
114
- name: item.name,
115
- elements,
116
- },
117
- });
118
- }
119
- return result;
74
+ // Delegates to the shared implementation on ElementResolver so this path
75
+ // and ContentItem.save() resolve fields (repeaters included) identically.
76
+ return this.resolver.buildElements(fields, elements, name, language, this.sectionId, context);
120
77
  }
121
78
  /** Lists all content items in this section. */
122
79
  async list(options) {
@@ -140,6 +97,7 @@ export class ContentResource {
140
97
  }
141
98
  /** Creates a new content item in this section. */
142
99
  async create(data, options) {
100
+ assertRequired(data.name, 'Content name');
143
101
  const language = resolveLanguage(options?.language, this.defaultLanguage);
144
102
  const template = await this.getTemplate(data.type);
145
103
  const contentId = -Math.floor(Math.random() * 1000000);
@@ -175,6 +133,7 @@ export class ContentResource {
175
133
  }
176
134
  /** Updates an existing content item's fields. */
177
135
  async update(id, data, options) {
136
+ assertNotEmptyIfPresent(data.name, 'Content name');
178
137
  const language = resolveLanguage(options?.language, this.defaultLanguage);
179
138
  // 1. Fetch the existing content (full body needed for the POST)
180
139
  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[];
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
1
+ import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, invalidateAllCaches, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
2
2
  /** The content type category ID that marks a system content type. */
3
3
  const SYSTEM_CONTENT_TYPE = 30;
4
4
  function resolveTypeName(type, typeMap) {
@@ -56,8 +56,8 @@ function mapContentType(raw, typeMap, editorMap) {
56
56
  description: decodeHtmlEntities(raw.description ?? ''),
57
57
  minUserLevel: AUTH_LEVEL_MAP[raw.minAuthLevel ?? 2] ?? `unknown (${raw.minAuthLevel})`,
58
58
  workflow: raw.workflow ?? 0,
59
- sharedGroups: (raw.sharedGroups ?? []).map((g) => g.id),
60
- primaryGroup: raw.primaryGroup?.group?.id ?? raw.primaryGroup?.id ?? 0,
59
+ sharedGroups: readSharedGroups(raw.sharedGroups),
60
+ primaryGroup: readPrimaryGroup(raw.primaryGroup),
61
61
  directEdit: raw.enableDirectEdit ?? true,
62
62
  fields: fieldsRecord,
63
63
  },
@@ -245,6 +245,7 @@ export class ContentType {
245
245
  return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
246
246
  },
247
247
  update: async (layoutName, updateData) => {
248
+ assertNotEmptyIfPresent(updateData.name, 'Layout name');
248
249
  const all = await fetchLayouts();
249
250
  const match = all.find((l) => l.name === layoutName);
250
251
  if (!match)
@@ -285,6 +286,7 @@ export class ContentType {
285
286
  return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
286
287
  },
287
288
  create: async (createData) => {
289
+ assertRequired(createData.name, 'Layout name');
288
290
  // Check name uniqueness
289
291
  const existing = await fetchLayouts();
290
292
  if (existing.some((l) => l.name === createData.name)) {
@@ -586,6 +588,8 @@ export class ContentType {
586
588
  }
587
589
  /** Persists current property values to the server via PUT. */
588
590
  async save() {
591
+ assertRequired(this.name, 'Content type name');
592
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
589
593
  const authLevel = String(AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
590
594
  // Sync field changes back to raw contentTypeElements
591
595
  let rawElements = (this._rawData.contentTypeElements ?? []);
@@ -653,8 +657,8 @@ export class ContentType {
653
657
  minAuthLevel: authLevel,
654
658
  workflow: String(this.workflow),
655
659
  enableDirectEdit: this.directEdit,
656
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
657
- primaryGroup: { id: this.primaryGroup || null },
660
+ sharedGroups: writeSharedGroups(this.sharedGroups),
661
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
658
662
  contentTypeElements: rawElements,
659
663
  };
660
664
  // Resolve useAsFilename → elementIdforFilename
@@ -679,6 +683,10 @@ export class ContentType {
679
683
  path: `/contenttype/${this.id}`,
680
684
  body: updated,
681
685
  });
686
+ // The content type definition changed, so any cached copy (content type
687
+ // templates/definitions, element type maps, etc.) is now stale. Invalidate
688
+ // all caches via the global epoch so subsequent reads re-fetch.
689
+ invalidateAllCaches();
682
690
  // Update raw data for next save
683
691
  this._rawData = updated;
684
692
  this._removedElementIds.clear();
@@ -862,6 +870,9 @@ export class ContentTypeResource {
862
870
  method: 'DELETE',
863
871
  path: `/contenttype/${id}`,
864
872
  });
873
+ // A deleted content type may be cached; invalidate all caches so stale
874
+ // definitions/templates aren't served after the delete.
875
+ invalidateAllCaches();
865
876
  }
866
877
  /** Creates a new content type. */
867
878
  async create(data) {
@@ -871,6 +882,7 @@ export class ContentTypeResource {
871
882
  if (!data.elements?.length) {
872
883
  throw new Error('Content type must have at least one element');
873
884
  }
885
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
874
886
  // Validate useAsFilename constraints
875
887
  const filenameElements = data.elements.filter((el) => el.useAsFilename);
876
888
  if (filenameElements.length > 1) {
@@ -989,11 +1001,13 @@ export class ContentTypeResource {
989
1001
  warningMessage: '',
990
1002
  elementIdforFilename: elementIdForFilename,
991
1003
  conditionals: [],
992
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
993
- primaryGroup: { id: data.primaryGroup ?? 0 },
1004
+ sharedGroups: writeSharedGroups(data.sharedGroups),
1005
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
994
1006
  contentTypeElements,
995
1007
  },
996
1008
  });
1009
+ // New content type may affect cached lookups; invalidate for consistency.
1010
+ invalidateAllCaches();
997
1011
  const editorMap = await this.getEditorMap();
998
1012
  const result = mapContentType(raw, typeMap, editorMap);
999
1013
  await this.resolveListNames([result.data]);
@@ -1,4 +1,4 @@
1
- import { AUTH_LEVEL_MAP } from '../utils.js';
1
+ import { AUTH_LEVEL_MAP, assertRequired } from '../utils.js';
2
2
  /** A mutable group object. Modify properties and call save() to persist. */
3
3
  export class Group {
4
4
  constructor(raw, httpClient) {
@@ -40,6 +40,7 @@ export class Group {
40
40
  }
41
41
  /** Persists current property values to the server via PUT. */
42
42
  async save() {
43
+ assertRequired(this.name, 'Group name');
43
44
  // Start from existing raw members
44
45
  let rawMembers = [...(this._rawData.members ?? [])];
45
46
  // 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
  }>;
@@ -1,5 +1,5 @@
1
1
  import { resolveLanguage } from '../utils.js';
2
- import { decodeHtmlEntities } from '../utils.js';
2
+ import { decodeHtmlEntities, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired } from '../utils.js';
3
3
  /** A mutable list object. Modify properties and call save() to persist. */
4
4
  export class List {
5
5
  constructor(raw, httpClient, language) {
@@ -8,8 +8,8 @@ export class List {
8
8
  this.description = decodeHtmlEntities(raw.description ?? '');
9
9
  this.isForcedLanguage = raw.isForcedLanguage ?? false;
10
10
  this.isDefaultLanguage = raw.isDefaultLanguage ?? false;
11
- this.primaryGroup = raw.primaryGroup?.group?.id ?? raw.primaryGroup?.id ?? 0;
12
- this.sharedGroups = (raw.sharedGroups ?? []).map((g) => g.id);
11
+ this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
12
+ this.sharedGroups = readSharedGroups(raw.sharedGroups);
13
13
  this.items = {};
14
14
  for (const item of (raw.items ?? []).sort((a, b) => a.sequence - b.sequence)) {
15
15
  const friendlyName = decodeHtmlEntities(item.name);
@@ -53,9 +53,11 @@ export class List {
53
53
  }
54
54
  /** Persists current property values to the server via PUT. */
55
55
  async save() {
56
+ assertRequired(this.name, 'List name');
56
57
  if (this.isForcedLanguage && this.isDefaultLanguage) {
57
58
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
58
59
  }
60
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
59
61
  const items = Object.values(this.items).map((item, i) => ({
60
62
  id: String(item._rawId ?? 0),
61
63
  name: item.name,
@@ -70,8 +72,8 @@ export class List {
70
72
  description: this.description,
71
73
  isForcedLanguage: this.isForcedLanguage,
72
74
  isDefaultLanguage: this.isDefaultLanguage,
73
- primaryGroup: { id: this.primaryGroup || 0 },
74
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
75
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
76
+ sharedGroups: writeSharedGroups(this.sharedGroups),
75
77
  items,
76
78
  };
77
79
  await this._httpClient.request({
@@ -145,6 +147,7 @@ export class ListResource {
145
147
  if (data.isForcedLanguage && data.isDefaultLanguage) {
146
148
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
147
149
  }
150
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
148
151
  const language = resolveLanguage(options?.language, this.defaultLanguage);
149
152
  const items = (data.items ?? []).map((item, i) => ({
150
153
  id: '0',
@@ -163,8 +166,8 @@ export class ListResource {
163
166
  items,
164
167
  isForcedLanguage: data.isForcedLanguage ?? false,
165
168
  isDefaultLanguage: data.isDefaultLanguage ?? false,
166
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
167
- primaryGroup: { id: data.primaryGroup ?? 0 },
169
+ sharedGroups: writeSharedGroups(data.sharedGroups),
170
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
168
171
  sortType: 0,
169
172
  },
170
173
  });
@@ -1,5 +1,5 @@
1
1
  import { MediaItem } from '../models/media-item.js';
2
- import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
2
+ import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
3
3
  /** Extension → syntax type mapping for non-binary media */
4
4
  const EXTENSION_SYNTAX_MAP = {
5
5
  js: 1,
@@ -50,10 +50,17 @@ export class MediaResource {
50
50
  * Returns the new media item.
51
51
  */
52
52
  async create(data) {
53
- if (!data.name?.trim())
54
- throw new Error('Media name is required');
53
+ assertRequired(data.name, 'Media name');
55
54
  if (!data.category)
56
55
  throw new Error('Media category is required');
56
+ // A media item is meaningless without its file/binary. Guard the common
57
+ // empty cases: missing, or an empty { file } wrapper.
58
+ const fileValue = data.file && typeof data.file === 'object' && !(data.file instanceof Blob) && 'file' in data.file
59
+ ? data.file.file
60
+ : data.file;
61
+ if (fileValue === undefined || fileValue === null || fileValue === '') {
62
+ throw new Error('Media file is required');
63
+ }
57
64
  const language = data.language ?? 'smxx';
58
65
  const filename = deriveFilename(data.file);
59
66
  const ext = getExtension(filename);
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseFileSize } from '../utils.js';
1
+ import { formatFileSize, parseFileSize, assertRequired } from '../utils.js';
2
2
  function mapFromDetail(raw) {
3
3
  const layouts = (raw.formatters ?? []).map((f) => ({
4
4
  name: f.mediaLayout,
@@ -65,6 +65,7 @@ export class MediaType {
65
65
  }
66
66
  /** Persists current property values to the server via PUT. */
67
67
  async save() {
68
+ assertRequired(this.name, 'Media type name');
68
69
  // Sync defaultLayout into the layouts array
69
70
  if (this.defaultLayout) {
70
71
  for (const layout of this.layouts) {