@terminalfour/terminalfour-js 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cjs/content-cache.d.ts +38 -0
  2. package/dist/cjs/content-cache.js +42 -0
  3. package/dist/cjs/element-resolver.d.ts +16 -0
  4. package/dist/cjs/element-resolver.js +77 -1
  5. package/dist/cjs/models/content-item.js +13 -1
  6. package/dist/cjs/resources/content-resource.d.ts +7 -11
  7. package/dist/cjs/resources/content-resource.js +36 -79
  8. package/dist/cjs/resources/content-type-resource.d.ts +8 -8
  9. package/dist/cjs/resources/content-type-resource.js +25 -6
  10. package/dist/cjs/resources/list-resource.d.ts +2 -6
  11. package/dist/cjs/resources/list-resource.js +8 -6
  12. package/dist/cjs/resources/navigation-resource.d.ts +17 -0
  13. package/dist/cjs/resources/navigation-resource.js +13 -2
  14. package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
  15. package/dist/cjs/resources/page-layout-resource.js +12 -2
  16. package/dist/cjs/section-ref.d.ts +3 -1
  17. package/dist/cjs/section-ref.js +6 -5
  18. package/dist/cjs/t4-client.d.ts +1 -0
  19. package/dist/cjs/t4-client.js +6 -1
  20. package/dist/cjs/utils.d.ts +39 -0
  21. package/dist/cjs/utils.js +37 -0
  22. package/dist/esm/content-cache.d.ts +38 -0
  23. package/dist/esm/content-cache.js +38 -0
  24. package/dist/esm/element-resolver.d.ts +16 -0
  25. package/dist/esm/element-resolver.js +77 -1
  26. package/dist/esm/models/content-item.js +13 -1
  27. package/dist/esm/resources/content-resource.d.ts +7 -11
  28. package/dist/esm/resources/content-resource.js +37 -80
  29. package/dist/esm/resources/content-type-resource.d.ts +8 -8
  30. package/dist/esm/resources/content-type-resource.js +26 -7
  31. package/dist/esm/resources/list-resource.d.ts +2 -6
  32. package/dist/esm/resources/list-resource.js +9 -7
  33. package/dist/esm/resources/navigation-resource.d.ts +17 -0
  34. package/dist/esm/resources/navigation-resource.js +13 -2
  35. package/dist/esm/resources/page-layout-resource.d.ts +11 -0
  36. package/dist/esm/resources/page-layout-resource.js +13 -3
  37. package/dist/esm/section-ref.d.ts +3 -1
  38. package/dist/esm/section-ref.js +6 -5
  39. package/dist/esm/t4-client.d.ts +1 -0
  40. package/dist/esm/t4-client.js +6 -1
  41. package/dist/esm/utils.d.ts +39 -0
  42. package/dist/esm/utils.js +32 -0
  43. package/docs/content-types.md +20 -3
  44. package/docs/content.md +13 -0
  45. package/docs/error-handling.md +3 -1
  46. package/docs/getting-started.md +4 -0
  47. package/docs/lists.md +2 -2
  48. package/docs/navigation.md +30 -1
  49. package/docs/page-layouts.md +26 -0
  50. package/package.json +1 -1
@@ -1,35 +1,42 @@
1
- import { resolveLanguage, toTimestamp, STATUS_CODES, TtlMap } from '../utils.js';
1
+ import { resolveLanguage, toTimestamp, STATUS_CODES } 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) {
@@ -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[];
@@ -242,11 +238,15 @@ export declare class ContentTypeResource {
242
238
  /**
243
239
  * Existing fields to modify, matched by `name`. Only the provided
244
240
  * properties are changed; omitted ones are left as-is. Use this to change
245
- * an element's length (`maxSize`), description, or its `required`/`shown`
246
- * flags. Throws if a named field does not exist on the content type.
241
+ * an element's length (`maxSize`), description, its `required`/`shown`
242
+ * flags, or to rename it with `newName`. Throws if a named field does not
243
+ * exist on the content type. Renaming an element on a system content type
244
+ * is blocked (except the Section Meta Data and Extended User types).
247
245
  */
248
246
  updateFields?: Array<{
249
247
  name: string;
248
+ /** New name for the element. Renames are blocked on system content types. */
249
+ newName?: string;
250
250
  maxSize?: number;
251
251
  description?: string;
252
252
  required?: boolean;
@@ -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 } 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
  },
@@ -586,6 +586,7 @@ export class ContentType {
586
586
  }
587
587
  /** Persists current property values to the server via PUT. */
588
588
  async save() {
589
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
589
590
  const authLevel = String(AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
590
591
  // Sync field changes back to raw contentTypeElements
591
592
  let rawElements = (this._rawData.contentTypeElements ?? []);
@@ -653,8 +654,8 @@ export class ContentType {
653
654
  minAuthLevel: authLevel,
654
655
  workflow: String(this.workflow),
655
656
  enableDirectEdit: this.directEdit,
656
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
657
- primaryGroup: { id: this.primaryGroup || null },
657
+ sharedGroups: writeSharedGroups(this.sharedGroups),
658
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
658
659
  contentTypeElements: rawElements,
659
660
  };
660
661
  // Resolve useAsFilename → elementIdforFilename
@@ -679,6 +680,10 @@ export class ContentType {
679
680
  path: `/contenttype/${this.id}`,
680
681
  body: updated,
681
682
  });
683
+ // The content type definition changed, so any cached copy (content type
684
+ // templates/definitions, element type maps, etc.) is now stale. Invalidate
685
+ // all caches via the global epoch so subsequent reads re-fetch.
686
+ invalidateAllCaches();
682
687
  // Update raw data for next save
683
688
  this._rawData = updated;
684
689
  this._removedElementIds.clear();
@@ -818,6 +823,14 @@ export class ContentTypeResource {
818
823
  field.required = change.required;
819
824
  if (change.shown !== undefined)
820
825
  field.shown = change.shown;
826
+ // Rename last: save() detects the change against the element's _elementId
827
+ // link and applies the system content type rename guard. Re-key the
828
+ // fields record so the returned model is addressable by the new name.
829
+ if (change.newName !== undefined && change.newName !== change.name) {
830
+ field.name = change.newName;
831
+ ct.fields[change.newName] = field;
832
+ delete ct.fields[change.name];
833
+ }
821
834
  }
822
835
  }
823
836
  if (data.removeFields) {
@@ -854,6 +867,9 @@ export class ContentTypeResource {
854
867
  method: 'DELETE',
855
868
  path: `/contenttype/${id}`,
856
869
  });
870
+ // A deleted content type may be cached; invalidate all caches so stale
871
+ // definitions/templates aren't served after the delete.
872
+ invalidateAllCaches();
857
873
  }
858
874
  /** Creates a new content type. */
859
875
  async create(data) {
@@ -863,6 +879,7 @@ export class ContentTypeResource {
863
879
  if (!data.elements?.length) {
864
880
  throw new Error('Content type must have at least one element');
865
881
  }
882
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
866
883
  // Validate useAsFilename constraints
867
884
  const filenameElements = data.elements.filter((el) => el.useAsFilename);
868
885
  if (filenameElements.length > 1) {
@@ -981,11 +998,13 @@ export class ContentTypeResource {
981
998
  warningMessage: '',
982
999
  elementIdforFilename: elementIdForFilename,
983
1000
  conditionals: [],
984
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
985
- primaryGroup: { id: data.primaryGroup ?? 0 },
1001
+ sharedGroups: writeSharedGroups(data.sharedGroups),
1002
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
986
1003
  contentTypeElements,
987
1004
  },
988
1005
  });
1006
+ // New content type may affect cached lookups; invalidate for consistency.
1007
+ invalidateAllCaches();
989
1008
  const editorMap = await this.getEditorMap();
990
1009
  const result = mapContentType(raw, typeMap, editorMap);
991
1010
  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
  }>;
@@ -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 } 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);
@@ -56,6 +56,7 @@ export class List {
56
56
  if (this.isForcedLanguage && this.isDefaultLanguage) {
57
57
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
58
58
  }
59
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
59
60
  const items = Object.values(this.items).map((item, i) => ({
60
61
  id: String(item._rawId ?? 0),
61
62
  name: item.name,
@@ -70,8 +71,8 @@ export class List {
70
71
  description: this.description,
71
72
  isForcedLanguage: this.isForcedLanguage,
72
73
  isDefaultLanguage: this.isDefaultLanguage,
73
- primaryGroup: { id: this.primaryGroup || 0 },
74
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
74
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
75
+ sharedGroups: writeSharedGroups(this.sharedGroups),
75
76
  items,
76
77
  };
77
78
  await this._httpClient.request({
@@ -145,6 +146,7 @@ export class ListResource {
145
146
  if (data.isForcedLanguage && data.isDefaultLanguage) {
146
147
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
147
148
  }
149
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
148
150
  const language = resolveLanguage(options?.language, this.defaultLanguage);
149
151
  const items = (data.items ?? []).map((item, i) => ({
150
152
  id: '0',
@@ -163,8 +165,8 @@ export class ListResource {
163
165
  items,
164
166
  isForcedLanguage: data.isForcedLanguage ?? false,
165
167
  isDefaultLanguage: data.isDefaultLanguage ?? false,
166
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
167
- primaryGroup: { id: data.primaryGroup ?? 0 },
168
+ sharedGroups: writeSharedGroups(data.sharedGroups),
169
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
168
170
  sortType: 0,
169
171
  },
170
172
  });
@@ -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
  /**
@@ -1,3 +1,4 @@
1
+ import { readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
1
2
  /** Maps SDK type codes to API type codes */
2
3
  const SDK_TO_API = {
3
4
  'a-to-z': 'a2z',
@@ -985,6 +986,8 @@ export class NavigationObject {
985
986
  this.enabled = raw.isEnabled;
986
987
  this.cachingEnabled = raw.isCachingEnabled;
987
988
  this.previewEnabled = raw.isPreviewModeEnabled;
989
+ this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
990
+ this.sharedGroups = readSharedGroups(raw.sharedGroups);
988
991
  // Convert properties to camelCase keys with string values
989
992
  const originalKeys = [];
990
993
  const rawCamelProps = {};
@@ -1000,6 +1003,7 @@ export class NavigationObject {
1000
1003
  }
1001
1004
  /** Persists current property values to the server via PUT. */
1002
1005
  async save() {
1006
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
1003
1007
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1004
1008
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
1005
1009
  // Rebuild properties in API format
@@ -1019,6 +1023,8 @@ export class NavigationObject {
1019
1023
  isEnabled: this.enabled,
1020
1024
  isCachingEnabled: this.cachingEnabled,
1021
1025
  isPreviewModeEnabled: this.previewEnabled,
1026
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
1027
+ sharedGroups: writeSharedGroups(this.sharedGroups),
1022
1028
  properties: apiProperties,
1023
1029
  };
1024
1030
  await this._httpClient.request({
@@ -1235,6 +1241,7 @@ export class NavigationResource {
1235
1241
  throw new Error('Navigation object type is required');
1236
1242
  if (!NAVIGATION_TYPE_NAMES[data.type])
1237
1243
  throw new Error(`Unknown navigation type "${data.type}"`);
1244
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
1238
1245
  const apiType = SDK_TO_API[data.type];
1239
1246
  const properties = await this.buildProperties(data.type, (data.properties ?? {}));
1240
1247
  const body = {
@@ -1244,8 +1251,8 @@ export class NavigationResource {
1244
1251
  name: data.name,
1245
1252
  description: data.description ?? '',
1246
1253
  navigationType: apiType,
1247
- sharedGroups: [],
1248
- primaryGroup: { id: 0 },
1254
+ sharedGroups: writeSharedGroups(data.sharedGroups),
1255
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
1249
1256
  properties,
1250
1257
  };
1251
1258
  // CSS Selector quirk: requires "section-name": "on" at the top level
@@ -1289,6 +1296,10 @@ export class NavigationResource {
1289
1296
  nav.previewEnabled = data.previewEnabled;
1290
1297
  if (data.cachingEnabled !== undefined)
1291
1298
  nav.cachingEnabled = data.cachingEnabled;
1299
+ if (data.primaryGroup !== undefined)
1300
+ nav.primaryGroup = data.primaryGroup;
1301
+ if (data.sharedGroups !== undefined)
1302
+ nav.sharedGroups = data.sharedGroups;
1292
1303
  // Merge properties rather than replace — callers pass only what changes
1293
1304
  if (data.properties !== undefined) {
1294
1305
  nav.properties = {
@@ -1,4 +1,5 @@
1
1
  import { HttpClient } from '../http-client.js';
2
+ import { RawPrimaryGroup } from '../utils.js';
2
3
  /** Raw page layout detail from GET /pageLayout/{id} */
3
4
  interface RawPageLayoutDetail {
4
5
  id: number;
@@ -10,6 +11,10 @@ interface RawPageLayoutDetail {
10
11
  fileExtension?: string;
11
12
  syntaxType?: number;
12
13
  layoutProcessor?: number;
14
+ primaryGroup?: RawPrimaryGroup;
15
+ sharedGroups?: Array<{
16
+ id: number;
17
+ }>;
13
18
  [key: string]: unknown;
14
19
  }
15
20
  /** A page layout summary returned from list() */
@@ -28,6 +33,10 @@ export declare class PageLayout {
28
33
  fileExtension: string;
29
34
  syntax: string;
30
35
  processor: string;
36
+ /** Owning group ID. 0 = no primary group (Global). */
37
+ primaryGroup: number;
38
+ /** Group IDs this page layout is shared with. */
39
+ sharedGroups: number[];
31
40
  private readonly _httpClient;
32
41
  private _rawData;
33
42
  private _syntaxMap;
@@ -63,6 +72,8 @@ export declare class PageLayoutResource {
63
72
  fileExtension?: string;
64
73
  syntax?: string;
65
74
  processor?: string;
75
+ primaryGroup?: number;
76
+ sharedGroups?: number[];
66
77
  }): Promise<PageLayout>;
67
78
  /** Creates a new page layout. */
68
79
  create(data: {
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
1
+ import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
2
2
  /** Friendly processor keys mapped to API names for page layouts */
3
3
  const PAGE_PROCESSOR_MAP = {
4
4
  't4-tags': 'T4 Tag Page',
@@ -19,6 +19,8 @@ export class PageLayout {
19
19
  const procName = processorMap.get(raw.layoutProcessor ?? 0) ?? '';
20
20
  const procEntry = Object.entries(PAGE_PROCESSOR_MAP).find(([, apiName]) => apiName === procName);
21
21
  this.processor = procEntry ? procEntry[0] : procName || `unknown (${raw.layoutProcessor})`;
22
+ this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
23
+ this.sharedGroups = readSharedGroups(raw.sharedGroups);
22
24
  Object.defineProperty(this, '_httpClient', { value: httpClient, enumerable: false });
23
25
  Object.defineProperty(this, '_rawData', { value: raw, enumerable: false, writable: true });
24
26
  Object.defineProperty(this, '_syntaxMap', { value: syntaxMap, enumerable: false });
@@ -26,6 +28,7 @@ export class PageLayout {
26
28
  }
27
29
  /** Persists current property values to the server via PUT. */
28
30
  async save() {
31
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
29
32
  // Resolve syntax name to ID
30
33
  let syntaxId = this._rawData.syntaxType;
31
34
  if (this._syntaxMap) {
@@ -50,6 +53,8 @@ export class PageLayout {
50
53
  fileExtension: this.fileExtension,
51
54
  syntaxType: String(syntaxId),
52
55
  layoutProcessor: String(processorId),
56
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
57
+ sharedGroups: writeSharedGroups(this.sharedGroups),
53
58
  };
54
59
  await this._httpClient.request({
55
60
  method: 'PUT',
@@ -132,6 +137,10 @@ export class PageLayoutResource {
132
137
  layout.syntax = data.syntax;
133
138
  if (data.processor !== undefined)
134
139
  layout.processor = data.processor;
140
+ if (data.primaryGroup !== undefined)
141
+ layout.primaryGroup = data.primaryGroup;
142
+ if (data.sharedGroups !== undefined)
143
+ layout.sharedGroups = data.sharedGroups;
135
144
  await layout.save();
136
145
  return layout;
137
146
  }
@@ -139,6 +148,7 @@ export class PageLayoutResource {
139
148
  async create(data) {
140
149
  if (!data.name?.trim())
141
150
  throw new Error('Page layout name is required');
151
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
142
152
  const [syntaxMap, processorMap] = await Promise.all([
143
153
  this.getSyntaxMap(),
144
154
  this.getProcessorMap(),
@@ -192,8 +202,8 @@ export class PageLayoutResource {
192
202
  fileExtension: extensionValue,
193
203
  syntaxType: syntaxId,
194
204
  layoutProcessor: processorId,
195
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
196
- primaryGroup: { id: data.primaryGroup ?? null },
205
+ sharedGroups: writeSharedGroups(data.sharedGroups),
206
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
197
207
  },
198
208
  });
199
209
  return new PageLayout(raw, this.httpClient, syntaxMap, processorMap);
@@ -3,6 +3,7 @@ import { LanguageOption, SectionChannel, Owner, AddSectionData } from './types.j
3
3
  import { ContentResource } from './resources/content-resource.js';
4
4
  import { SectionItem } from './models/section-item.js';
5
5
  import { MediaCreateFn } from './element-resolver.js';
6
+ import { ContentCache } from './content-cache.js';
6
7
  /** A node in the section hierarchy tree */
7
8
  export interface SectionTreeNode {
8
9
  id: number;
@@ -23,7 +24,8 @@ export declare class SectionRef {
23
24
  private readonly sectionId;
24
25
  private readonly defaultLanguage;
25
26
  private readonly mediaCreateFn;
26
- constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
27
+ private readonly cache;
28
+ constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
27
29
  /** Returns a mutable section object. Modify properties and call save() to persist. */
28
30
  get(options?: LanguageOption): Promise<SectionItem>;
29
31
  /** Returns the list of channels associated with this section. */
@@ -53,12 +53,13 @@ async function resolveMetaDataTypeId(httpClient, sectionMetaDataType) {
53
53
  * child section creation, and deletion — all from a single entry point.
54
54
  */
55
55
  export class SectionRef {
56
- constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
56
+ constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
57
57
  this.httpClient = httpClient;
58
58
  this.sectionId = sectionId;
59
59
  this.defaultLanguage = defaultLanguage;
60
60
  this.mediaCreateFn = mediaCreateFn ?? null;
61
- this.content = new ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn);
61
+ this.cache = cache;
62
+ this.content = new ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn, this.cache);
62
63
  }
63
64
  // ── Section metadata ──
64
65
  /** Returns a mutable section object. Modify properties and call save() to persist. */
@@ -73,7 +74,7 @@ export class SectionRef {
73
74
  const meta = raw.metaData;
74
75
  if (meta?.enabled && meta.id) {
75
76
  try {
76
- const metaContent = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
77
+ const metaContent = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
77
78
  const item = await metaContent.get(meta.id, { language });
78
79
  customFields = stripNameField(item.fields ?? null);
79
80
  }
@@ -641,7 +642,7 @@ export class SectionRef {
641
642
  if (metaContentTypeId) {
642
643
  // Use a ContentResource on the new section to get full element resolution
643
644
  // (list values, SS links, file uploads, etc.)
644
- const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
645
+ const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
645
646
  // Create the metadata content item (ContentResource.create handles the full body)
646
647
  const metaItem = await metadataContentResource.create({
647
648
  type: metaContentTypeId,
@@ -730,7 +731,7 @@ export class SectionRef {
730
731
  if (!metaDataTypeId) {
731
732
  throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
732
733
  }
733
- const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
734
+ const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
734
735
  const metaContentId = meta?.id ?? 0;
735
736
  if (metaContentId > 0) {
736
737
  await contentResource.update(metaContentId, { fields: data.customFields });
@@ -35,6 +35,7 @@ export declare class T4Client {
35
35
  private readonly httpClient;
36
36
  private readonly defaultLanguage;
37
37
  private readonly mediaCreateFn;
38
+ private readonly contentCache;
38
39
  constructor(config: T4ClientConfig);
39
40
  /**
40
41
  * Returns a section reference scoped to the given section ID.