@terminalfour/terminalfour-js 1.0.3 → 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 +2 -6
  9. package/dist/cjs/resources/content-type-resource.js +17 -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 +2 -6
  30. package/dist/esm/resources/content-type-resource.js +18 -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 +2 -2
  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
@@ -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);
@@ -531,7 +531,19 @@ class ContentItem {
531
531
  }
532
532
  let resolved;
533
533
  if (templateEl && this._resolver) {
534
- resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
534
+ // Repeater fields need the same dedicated resolution ContentResource
535
+ // uses on create/update — each item's sub-fields resolved into element
536
+ // keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
537
+ // passes repeater arrays through untouched, so branch here explicitly.
538
+ const typeName = this._typeRegistry
539
+ ? await this._typeRegistry.getNameById(templateEl.type)
540
+ : null;
541
+ if (typeName === 'Repeater' && Array.isArray(value)) {
542
+ resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
543
+ }
544
+ else {
545
+ resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
546
+ }
535
547
  }
536
548
  else {
537
549
  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. */
@@ -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) {
@@ -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
  },
@@ -590,6 +590,7 @@ class ContentType {
590
590
  }
591
591
  /** Persists current property values to the server via PUT. */
592
592
  async save() {
593
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
593
594
  const authLevel = String(utils_js_1.AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
594
595
  // Sync field changes back to raw contentTypeElements
595
596
  let rawElements = (this._rawData.contentTypeElements ?? []);
@@ -657,8 +658,8 @@ class ContentType {
657
658
  minAuthLevel: authLevel,
658
659
  workflow: String(this.workflow),
659
660
  enableDirectEdit: this.directEdit,
660
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
661
- primaryGroup: { id: this.primaryGroup || null },
661
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
662
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
662
663
  contentTypeElements: rawElements,
663
664
  };
664
665
  // Resolve useAsFilename → elementIdforFilename
@@ -683,6 +684,10 @@ class ContentType {
683
684
  path: `/contenttype/${this.id}`,
684
685
  body: updated,
685
686
  });
687
+ // The content type definition changed, so any cached copy (content type
688
+ // templates/definitions, element type maps, etc.) is now stale. Invalidate
689
+ // all caches via the global epoch so subsequent reads re-fetch.
690
+ (0, utils_js_1.invalidateAllCaches)();
686
691
  // Update raw data for next save
687
692
  this._rawData = updated;
688
693
  this._removedElementIds.clear();
@@ -867,6 +872,9 @@ class ContentTypeResource {
867
872
  method: 'DELETE',
868
873
  path: `/contenttype/${id}`,
869
874
  });
875
+ // A deleted content type may be cached; invalidate all caches so stale
876
+ // definitions/templates aren't served after the delete.
877
+ (0, utils_js_1.invalidateAllCaches)();
870
878
  }
871
879
  /** Creates a new content type. */
872
880
  async create(data) {
@@ -876,6 +884,7 @@ class ContentTypeResource {
876
884
  if (!data.elements?.length) {
877
885
  throw new Error('Content type must have at least one element');
878
886
  }
887
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
879
888
  // Validate useAsFilename constraints
880
889
  const filenameElements = data.elements.filter((el) => el.useAsFilename);
881
890
  if (filenameElements.length > 1) {
@@ -994,11 +1003,13 @@ class ContentTypeResource {
994
1003
  warningMessage: '',
995
1004
  elementIdforFilename: elementIdForFilename,
996
1005
  conditionals: [],
997
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
998
- primaryGroup: { id: data.primaryGroup ?? 0 },
1006
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
1007
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
999
1008
  contentTypeElements,
1000
1009
  },
1001
1010
  });
1011
+ // New content type may affect cached lookups; invalidate for consistency.
1012
+ (0, utils_js_1.invalidateAllCaches)();
1002
1013
  const editorMap = await this.getEditorMap();
1003
1014
  const result = mapContentType(raw, typeMap, editorMap);
1004
1015
  await this.resolveListNames([result.data]);
@@ -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
  }>;
@@ -11,8 +11,8 @@ class List {
11
11
  this.description = (0, utils_js_2.decodeHtmlEntities)(raw.description ?? '');
12
12
  this.isForcedLanguage = raw.isForcedLanguage ?? false;
13
13
  this.isDefaultLanguage = raw.isDefaultLanguage ?? false;
14
- this.primaryGroup = raw.primaryGroup?.group?.id ?? raw.primaryGroup?.id ?? 0;
15
- this.sharedGroups = (raw.sharedGroups ?? []).map((g) => g.id);
14
+ this.primaryGroup = (0, utils_js_2.readPrimaryGroup)(raw.primaryGroup);
15
+ this.sharedGroups = (0, utils_js_2.readSharedGroups)(raw.sharedGroups);
16
16
  this.items = {};
17
17
  for (const item of (raw.items ?? []).sort((a, b) => a.sequence - b.sequence)) {
18
18
  const friendlyName = (0, utils_js_2.decodeHtmlEntities)(item.name);
@@ -59,6 +59,7 @@ class List {
59
59
  if (this.isForcedLanguage && this.isDefaultLanguage) {
60
60
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
61
61
  }
62
+ (0, utils_js_2.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
62
63
  const items = Object.values(this.items).map((item, i) => ({
63
64
  id: String(item._rawId ?? 0),
64
65
  name: item.name,
@@ -73,8 +74,8 @@ class List {
73
74
  description: this.description,
74
75
  isForcedLanguage: this.isForcedLanguage,
75
76
  isDefaultLanguage: this.isDefaultLanguage,
76
- primaryGroup: { id: this.primaryGroup || 0 },
77
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
77
+ primaryGroup: (0, utils_js_2.writePrimaryGroup)(this.primaryGroup),
78
+ sharedGroups: (0, utils_js_2.writeSharedGroups)(this.sharedGroups),
78
79
  items,
79
80
  };
80
81
  await this._httpClient.request({
@@ -149,6 +150,7 @@ class ListResource {
149
150
  if (data.isForcedLanguage && data.isDefaultLanguage) {
150
151
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
151
152
  }
153
+ (0, utils_js_2.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
152
154
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
153
155
  const items = (data.items ?? []).map((item, i) => ({
154
156
  id: '0',
@@ -167,8 +169,8 @@ class ListResource {
167
169
  items,
168
170
  isForcedLanguage: data.isForcedLanguage ?? false,
169
171
  isDefaultLanguage: data.isDefaultLanguage ?? false,
170
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
171
- primaryGroup: { id: data.primaryGroup ?? 0 },
172
+ sharedGroups: (0, utils_js_2.writeSharedGroups)(data.sharedGroups),
173
+ primaryGroup: (0, utils_js_2.writePrimaryGroup)(data.primaryGroup ?? 0),
172
174
  sortType: 0,
173
175
  },
174
176
  });
@@ -1,4 +1,5 @@
1
1
  import { HttpClient } from '../http-client.js';
2
+ import { RawPrimaryGroup } from '../utils.js';
2
3
  /** SDK-friendly navigation type codes (consistent kebab-case) */
3
4
  export type NavigationType = 'a-to-z' | 'breadcrumbs' | 'css-selector' | 'generate-file' | 'keyword-search' | 'language-switcher' | 'link-menu' | 'pagination' | 'previous-next-fulltext' | 'publish-to-one-file' | 'related-content' | 'related-section-branch' | 'return-to-index' | 'section-details' | 'section-iterator' | 'section-meta-info' | 'site-map' | 'top-content' | 'top-stories';
4
5
  /** Human-readable type name mapping */
@@ -23,6 +24,10 @@ interface RawNavigationDetail {
23
24
  isPreviewModeEnabled: boolean;
24
25
  isCachingEnabled: boolean;
25
26
  date?: string;
27
+ primaryGroup?: RawPrimaryGroup;
28
+ sharedGroups?: Array<{
29
+ id: number;
30
+ }>;
26
31
  properties: Record<string, {
27
32
  value?: string;
28
33
  attribute: string;
@@ -42,6 +47,10 @@ export declare class NavigationObject {
42
47
  enabled: boolean;
43
48
  cachingEnabled: boolean;
44
49
  previewEnabled: boolean;
50
+ /** Owning group ID. 0 = no primary group (Global). */
51
+ primaryGroup: number;
52
+ /** Group IDs this navigation object is shared with. */
53
+ sharedGroups: number[];
45
54
  properties: Record<string, unknown>;
46
55
  private readonly _httpClient;
47
56
  private _rawData;
@@ -525,6 +534,10 @@ export interface CreateNavigationData {
525
534
  description?: string;
526
535
  enabled?: boolean;
527
536
  previewEnabled?: boolean;
537
+ /** Owning group ID. 0 = no primary group (Global). Defaults to 0. */
538
+ primaryGroup?: number;
539
+ /** Group IDs to share this navigation object with. Defaults to none. */
540
+ sharedGroups?: number[];
528
541
  properties?: A2ZProperties | BreadcrumbsProperties | CssSelectorProperties | GenerateFileProperties | LanguageSwitcherProperties | PaginationProperties | PreviousNextProperties | SectionIteratorProperties | RelatedSectionBranchProperties | ReturnToIndexProperties | SectionMetaInfoProperties | TopStoriesProperties | SiteMapProperties | SectionDetailsProperties | RelatedContentProperties | LinkMenuProperties | PublishToOneFileProperties | TopContentProperties | KeywordSearchProperties | Record<string, unknown>;
529
542
  }
530
543
  /**
@@ -538,6 +551,10 @@ export interface UpdateNavigationData {
538
551
  enabled?: boolean;
539
552
  previewEnabled?: boolean;
540
553
  cachingEnabled?: boolean;
554
+ /** Owning group ID. 0 = no primary group (Global). */
555
+ primaryGroup?: number;
556
+ /** Group IDs to share this navigation object with. */
557
+ sharedGroups?: number[];
541
558
  properties?: A2ZProperties | BreadcrumbsProperties | CssSelectorProperties | GenerateFileProperties | LanguageSwitcherProperties | PaginationProperties | PreviousNextProperties | SectionIteratorProperties | RelatedSectionBranchProperties | ReturnToIndexProperties | SectionMetaInfoProperties | TopStoriesProperties | SiteMapProperties | SectionDetailsProperties | RelatedContentProperties | LinkMenuProperties | PublishToOneFileProperties | TopContentProperties | KeywordSearchProperties | Record<string, unknown>;
542
559
  }
543
560
  /**