@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
@@ -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);
@@ -56,9 +56,11 @@ class List {
56
56
  }
57
57
  /** Persists current property values to the server via PUT. */
58
58
  async save() {
59
+ (0, utils_js_2.assertRequired)(this.name, 'List name');
59
60
  if (this.isForcedLanguage && this.isDefaultLanguage) {
60
61
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
61
62
  }
63
+ (0, utils_js_2.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
62
64
  const items = Object.values(this.items).map((item, i) => ({
63
65
  id: String(item._rawId ?? 0),
64
66
  name: item.name,
@@ -73,8 +75,8 @@ class List {
73
75
  description: this.description,
74
76
  isForcedLanguage: this.isForcedLanguage,
75
77
  isDefaultLanguage: this.isDefaultLanguage,
76
- primaryGroup: { id: this.primaryGroup || 0 },
77
- sharedGroups: this.sharedGroups.map((id) => ({ id })),
78
+ primaryGroup: (0, utils_js_2.writePrimaryGroup)(this.primaryGroup),
79
+ sharedGroups: (0, utils_js_2.writeSharedGroups)(this.sharedGroups),
78
80
  items,
79
81
  };
80
82
  await this._httpClient.request({
@@ -149,6 +151,7 @@ class ListResource {
149
151
  if (data.isForcedLanguage && data.isDefaultLanguage) {
150
152
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
151
153
  }
154
+ (0, utils_js_2.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
152
155
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
153
156
  const items = (data.items ?? []).map((item, i) => ({
154
157
  id: '0',
@@ -167,8 +170,8 @@ class ListResource {
167
170
  items,
168
171
  isForcedLanguage: data.isForcedLanguage ?? false,
169
172
  isDefaultLanguage: data.isDefaultLanguage ?? false,
170
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
171
- primaryGroup: { id: data.primaryGroup ?? 0 },
173
+ sharedGroups: (0, utils_js_2.writeSharedGroups)(data.sharedGroups),
174
+ primaryGroup: (0, utils_js_2.writePrimaryGroup)(data.primaryGroup ?? 0),
172
175
  sortType: 0,
173
176
  },
174
177
  });
@@ -53,10 +53,17 @@ class MediaResource {
53
53
  * Returns the new media item.
54
54
  */
55
55
  async create(data) {
56
- if (!data.name?.trim())
57
- throw new Error('Media name is required');
56
+ (0, utils_js_1.assertRequired)(data.name, 'Media name');
58
57
  if (!data.category)
59
58
  throw new Error('Media category is required');
59
+ // A media item is meaningless without its file/binary. Guard the common
60
+ // empty cases: missing, or an empty { file } wrapper.
61
+ const fileValue = data.file && typeof data.file === 'object' && !(data.file instanceof Blob) && 'file' in data.file
62
+ ? data.file.file
63
+ : data.file;
64
+ if (fileValue === undefined || fileValue === null || fileValue === '') {
65
+ throw new Error('Media file is required');
66
+ }
60
67
  const language = data.language ?? 'smxx';
61
68
  const filename = (0, utils_js_1.deriveFilename)(data.file);
62
69
  const ext = getExtension(filename);
@@ -68,6 +68,7 @@ class MediaType {
68
68
  }
69
69
  /** Persists current property values to the server via PUT. */
70
70
  async save() {
71
+ (0, utils_js_1.assertRequired)(this.name, 'Media type name');
71
72
  // Sync defaultLayout into the layouts array
72
73
  if (this.defaultLayout) {
73
74
  for (const layout of this.layouts) {
@@ -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,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NavigationResource = exports.NavigationObject = exports.NAVIGATION_TYPE_NAMES = void 0;
4
+ const utils_js_1 = require("../utils.js");
4
5
  /** Maps SDK type codes to API type codes */
5
6
  const SDK_TO_API = {
6
7
  'a-to-z': 'a2z',
@@ -988,6 +989,8 @@ class NavigationObject {
988
989
  this.enabled = raw.isEnabled;
989
990
  this.cachingEnabled = raw.isCachingEnabled;
990
991
  this.previewEnabled = raw.isPreviewModeEnabled;
992
+ this.primaryGroup = (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup);
993
+ this.sharedGroups = (0, utils_js_1.readSharedGroups)(raw.sharedGroups);
991
994
  // Convert properties to camelCase keys with string values
992
995
  const originalKeys = [];
993
996
  const rawCamelProps = {};
@@ -1003,6 +1006,8 @@ class NavigationObject {
1003
1006
  }
1004
1007
  /** Persists current property values to the server via PUT. */
1005
1008
  async save() {
1009
+ (0, utils_js_1.assertRequired)(this.name, 'Navigation object name');
1010
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
1006
1011
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1007
1012
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
1008
1013
  // Rebuild properties in API format
@@ -1022,6 +1027,8 @@ class NavigationObject {
1022
1027
  isEnabled: this.enabled,
1023
1028
  isCachingEnabled: this.cachingEnabled,
1024
1029
  isPreviewModeEnabled: this.previewEnabled,
1030
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
1031
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
1025
1032
  properties: apiProperties,
1026
1033
  };
1027
1034
  await this._httpClient.request({
@@ -1239,6 +1246,7 @@ class NavigationResource {
1239
1246
  throw new Error('Navigation object type is required');
1240
1247
  if (!exports.NAVIGATION_TYPE_NAMES[data.type])
1241
1248
  throw new Error(`Unknown navigation type "${data.type}"`);
1249
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
1242
1250
  const apiType = SDK_TO_API[data.type];
1243
1251
  const properties = await this.buildProperties(data.type, (data.properties ?? {}));
1244
1252
  const body = {
@@ -1248,8 +1256,8 @@ class NavigationResource {
1248
1256
  name: data.name,
1249
1257
  description: data.description ?? '',
1250
1258
  navigationType: apiType,
1251
- sharedGroups: [],
1252
- primaryGroup: { id: 0 },
1259
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
1260
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
1253
1261
  properties,
1254
1262
  };
1255
1263
  // CSS Selector quirk: requires "section-name": "on" at the top level
@@ -1293,6 +1301,10 @@ class NavigationResource {
1293
1301
  nav.previewEnabled = data.previewEnabled;
1294
1302
  if (data.cachingEnabled !== undefined)
1295
1303
  nav.cachingEnabled = data.cachingEnabled;
1304
+ if (data.primaryGroup !== undefined)
1305
+ nav.primaryGroup = data.primaryGroup;
1306
+ if (data.sharedGroups !== undefined)
1307
+ nav.sharedGroups = data.sharedGroups;
1296
1308
  // Merge properties rather than replace — callers pass only what changes
1297
1309
  if (data.properties !== undefined) {
1298
1310
  nav.properties = {
@@ -1,4 +1,5 @@
1
1
  import { HttpClient } from '../http-client.js';
2
+ import { RawPrimaryGroup } from '../utils.js';
2
3
  /** Raw page layout detail from GET /pageLayout/{id} */
3
4
  interface RawPageLayoutDetail {
4
5
  id: number;
@@ -10,6 +11,10 @@ interface RawPageLayoutDetail {
10
11
  fileExtension?: string;
11
12
  syntaxType?: number;
12
13
  layoutProcessor?: number;
14
+ primaryGroup?: RawPrimaryGroup;
15
+ sharedGroups?: Array<{
16
+ id: number;
17
+ }>;
13
18
  [key: string]: unknown;
14
19
  }
15
20
  /** A page layout summary returned from list() */
@@ -28,6 +33,10 @@ export declare class PageLayout {
28
33
  fileExtension: string;
29
34
  syntax: string;
30
35
  processor: string;
36
+ /** Owning group ID. 0 = no primary group (Global). */
37
+ primaryGroup: number;
38
+ /** Group IDs this page layout is shared with. */
39
+ sharedGroups: number[];
31
40
  private readonly _httpClient;
32
41
  private _rawData;
33
42
  private _syntaxMap;
@@ -63,6 +72,8 @@ export declare class PageLayoutResource {
63
72
  fileExtension?: string;
64
73
  syntax?: string;
65
74
  processor?: string;
75
+ primaryGroup?: number;
76
+ sharedGroups?: number[];
66
77
  }): Promise<PageLayout>;
67
78
  /** Creates a new page layout. */
68
79
  create(data: {
@@ -22,6 +22,8 @@ class PageLayout {
22
22
  const procName = processorMap.get(raw.layoutProcessor ?? 0) ?? '';
23
23
  const procEntry = Object.entries(PAGE_PROCESSOR_MAP).find(([, apiName]) => apiName === procName);
24
24
  this.processor = procEntry ? procEntry[0] : procName || `unknown (${raw.layoutProcessor})`;
25
+ this.primaryGroup = (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup);
26
+ this.sharedGroups = (0, utils_js_1.readSharedGroups)(raw.sharedGroups);
25
27
  Object.defineProperty(this, '_httpClient', { value: httpClient, enumerable: false });
26
28
  Object.defineProperty(this, '_rawData', { value: raw, enumerable: false, writable: true });
27
29
  Object.defineProperty(this, '_syntaxMap', { value: syntaxMap, enumerable: false });
@@ -29,6 +31,8 @@ class PageLayout {
29
31
  }
30
32
  /** Persists current property values to the server via PUT. */
31
33
  async save() {
34
+ (0, utils_js_1.assertRequired)(this.name, 'Page layout name');
35
+ (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
32
36
  // Resolve syntax name to ID
33
37
  let syntaxId = this._rawData.syntaxType;
34
38
  if (this._syntaxMap) {
@@ -53,6 +57,8 @@ class PageLayout {
53
57
  fileExtension: this.fileExtension,
54
58
  syntaxType: String(syntaxId),
55
59
  layoutProcessor: String(processorId),
60
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
61
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
56
62
  };
57
63
  await this._httpClient.request({
58
64
  method: 'PUT',
@@ -136,6 +142,10 @@ class PageLayoutResource {
136
142
  layout.syntax = data.syntax;
137
143
  if (data.processor !== undefined)
138
144
  layout.processor = data.processor;
145
+ if (data.primaryGroup !== undefined)
146
+ layout.primaryGroup = data.primaryGroup;
147
+ if (data.sharedGroups !== undefined)
148
+ layout.sharedGroups = data.sharedGroups;
139
149
  await layout.save();
140
150
  return layout;
141
151
  }
@@ -143,6 +153,7 @@ class PageLayoutResource {
143
153
  async create(data) {
144
154
  if (!data.name?.trim())
145
155
  throw new Error('Page layout name is required');
156
+ (0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
146
157
  const [syntaxMap, processorMap] = await Promise.all([
147
158
  this.getSyntaxMap(),
148
159
  this.getProcessorMap(),
@@ -196,8 +207,8 @@ class PageLayoutResource {
196
207
  fileExtension: extensionValue,
197
208
  syntaxType: syntaxId,
198
209
  layoutProcessor: processorId,
199
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
200
- primaryGroup: { id: data.primaryGroup ?? null },
210
+ sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
211
+ primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
201
212
  },
202
213
  });
203
214
  return new PageLayout(raw, this.httpClient, syntaxMap, processorMap);
@@ -3,6 +3,7 @@ import { LanguageOption, SectionChannel, Owner, AddSectionData } from './types.j
3
3
  import { ContentResource } from './resources/content-resource.js';
4
4
  import { SectionItem } from './models/section-item.js';
5
5
  import { MediaCreateFn } from './element-resolver.js';
6
+ import { ContentCache } from './content-cache.js';
6
7
  /** A node in the section hierarchy tree */
7
8
  export interface SectionTreeNode {
8
9
  id: number;
@@ -23,7 +24,8 @@ export declare class SectionRef {
23
24
  private readonly sectionId;
24
25
  private readonly defaultLanguage;
25
26
  private readonly mediaCreateFn;
26
- constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
27
+ private readonly cache;
28
+ constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
27
29
  /** Returns a mutable section object. Modify properties and call save() to persist. */
28
30
  get(options?: LanguageOption): Promise<SectionItem>;
29
31
  /** Returns the list of channels associated with this section. */
@@ -57,12 +57,13 @@ async function resolveMetaDataTypeId(httpClient, sectionMetaDataType) {
57
57
  * child section creation, and deletion — all from a single entry point.
58
58
  */
59
59
  class SectionRef {
60
- constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
60
+ constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
61
61
  this.httpClient = httpClient;
62
62
  this.sectionId = sectionId;
63
63
  this.defaultLanguage = defaultLanguage;
64
64
  this.mediaCreateFn = mediaCreateFn ?? null;
65
- this.content = new content_resource_js_1.ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn);
65
+ this.cache = cache;
66
+ this.content = new content_resource_js_1.ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn, this.cache);
66
67
  }
67
68
  // ── Section metadata ──
68
69
  /** Returns a mutable section object. Modify properties and call save() to persist. */
@@ -77,7 +78,7 @@ class SectionRef {
77
78
  const meta = raw.metaData;
78
79
  if (meta?.enabled && meta.id) {
79
80
  try {
80
- const metaContent = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
81
+ const metaContent = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
81
82
  const item = await metaContent.get(meta.id, { language });
82
83
  customFields = stripNameField(item.fields ?? null);
83
84
  }
@@ -567,6 +568,7 @@ class SectionRef {
567
568
  * If `data.customFields` is provided, creates and saves section metadata content.
568
569
  */
569
570
  async addSection(data, options) {
571
+ (0, utils_js_1.assertRequired)(data.name, 'Section name');
570
572
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
571
573
  // Fetch this section's details to inherit config
572
574
  const parentSection = await this.httpClient.request({
@@ -645,7 +647,7 @@ class SectionRef {
645
647
  if (metaContentTypeId) {
646
648
  // Use a ContentResource on the new section to get full element resolution
647
649
  // (list values, SS links, file uploads, etc.)
648
- const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
650
+ const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
649
651
  // Create the metadata content item (ContentResource.create handles the full body)
650
652
  const metaItem = await metadataContentResource.create({
651
653
  type: metaContentTypeId,
@@ -711,6 +713,10 @@ class SectionRef {
711
713
  * Returns the updated SectionItem.
712
714
  */
713
715
  async update(data, options) {
716
+ // This path PUTs directly (it does not go through SectionItem.save()), so
717
+ // guard the name here. Present-only: omitting name is valid (e.g. delete()
718
+ // calls update({ status: 'inactive' })), but setting it blank is not.
719
+ (0, utils_js_1.assertNotEmptyIfPresent)(data.name, 'Section name');
714
720
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
715
721
  const section = await this.httpClient.request({
716
722
  method: 'GET',
@@ -734,7 +740,7 @@ class SectionRef {
734
740
  if (!metaDataTypeId) {
735
741
  throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
736
742
  }
737
- const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
743
+ const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
738
744
  const metaContentId = meta?.id ?? 0;
739
745
  if (metaContentId > 0) {
740
746
  await contentResource.update(metaContentId, { fields: data.customFields });
@@ -35,6 +35,7 @@ export declare class T4Client {
35
35
  private readonly httpClient;
36
36
  private readonly defaultLanguage;
37
37
  private readonly mediaCreateFn;
38
+ private readonly contentCache;
38
39
  constructor(config: T4ClientConfig);
39
40
  /**
40
41
  * Returns a section reference scoped to the given section ID.
@@ -16,6 +16,7 @@ const page_layout_resource_js_1 = require("./resources/page-layout-resource.js")
16
16
  const media_type_resource_js_1 = require("./resources/media-type-resource.js");
17
17
  const navigation_resource_js_1 = require("./resources/navigation-resource.js");
18
18
  const handlebars_js_1 = require("./handlebars.js");
19
+ const content_cache_js_1 = require("./content-cache.js");
19
20
  const utils_js_1 = require("./utils.js");
20
21
  /**
21
22
  * Main entry point for the T4 SDK.
@@ -57,12 +58,16 @@ class T4Client {
57
58
  });
58
59
  return item.id;
59
60
  };
61
+ // Shared content caches (element TypeRegistry, content type templates).
62
+ // Threaded into every SectionRef/ContentResource so hierarchy traversal
63
+ // doesn't re-fetch instance-wide data on each t4.section(id) call.
64
+ this.contentCache = new content_cache_js_1.ContentCache(this.httpClient, this.defaultLanguage, this.mediaCreateFn);
60
65
  }
61
66
  /**
62
67
  * Returns a section reference scoped to the given section ID.
63
68
  */
64
69
  section(id) {
65
- return new section_ref_js_1.SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn);
70
+ return new section_ref_js_1.SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn, this.contentCache);
66
71
  }
67
72
  /**
68
73
  * Returns a media category reference scoped to the given category ID.
@@ -68,6 +68,62 @@ export declare function flattenGroups(groups: Array<{
68
68
  name: string;
69
69
  groupChildren?: unknown[];
70
70
  }>, map?: Map<number, string>): Map<number, string>;
71
+ /**
72
+ * Group/visibility mapping helpers.
73
+ *
74
+ * The T4 API represents an asset's owning group as `primaryGroup` (an object,
75
+ * where the id may be direct or nested under `group`) and its shared groups as
76
+ * `sharedGroups` (an array of `{ id }`). The SDK exposes these as a plain
77
+ * `primaryGroup: number` (0 = none) and `sharedGroups: number[]`. These helpers
78
+ * centralise the read/write mapping so content types, lists, page layouts, and
79
+ * navigation objects all handle groups identically.
80
+ */
81
+ /** Raw shape of the API `primaryGroup` field. */
82
+ export interface RawPrimaryGroup {
83
+ id: number | null;
84
+ group?: {
85
+ id: number;
86
+ };
87
+ }
88
+ /** Reads the API `primaryGroup` object into a plain group id (0 = none). */
89
+ export declare function readPrimaryGroup(raw?: RawPrimaryGroup): number;
90
+ /** Reads the API `sharedGroups` array into a plain array of group ids. */
91
+ export declare function readSharedGroups(raw?: Array<{
92
+ id: number;
93
+ }>): number[];
94
+ /** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
95
+ export declare function writePrimaryGroup(id: number): {
96
+ id: number | null;
97
+ };
98
+ /** Writes a plain array of group ids back to the API `sharedGroups` shape. */
99
+ export declare function writeSharedGroups(ids?: number[]): Array<{
100
+ id: number;
101
+ }>;
102
+ /**
103
+ * Validates that group/visibility values are acceptable to the T4 API before a
104
+ * write. The API returns an opaque 500 when `sharedGroups` contains the same id
105
+ * as `primaryGroup` (a group cannot be both the owner and a shared group), so we
106
+ * catch it here with a clear message. A `primaryGroup` of 0 means "no owning
107
+ * group" and is never a conflict.
108
+ */
109
+ export declare function assertGroupsValid(primaryGroup: number, sharedGroups?: number[]): void;
110
+ /**
111
+ * Asserts that a required string value is present and not blank.
112
+ *
113
+ * Use on create paths where the field must always be provided. Throws
114
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
115
+ * Prevents opaque API 500s from malformed requests missing a required field.
116
+ */
117
+ export declare function assertRequired(value: string | null | undefined, label: string): void;
118
+ /**
119
+ * Asserts that a value, *if provided*, is not blank.
120
+ *
121
+ * Use on update paths where a field is optional (omitting it leaves the
122
+ * current value unchanged) but explicitly setting it to an empty or
123
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
124
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
125
+ */
126
+ export declare function assertNotEmptyIfPresent(value: string | null | undefined, label: string): void;
71
127
  /** Accepted file input: a file path, URL, Blob, ReadableStream, or { file, filename } object */
72
128
  export type FileInput = string | Blob | NodeJS.ReadableStream | {
73
129
  file: string | Blob | NodeJS.ReadableStream;
package/dist/cjs/utils.js CHANGED
@@ -42,6 +42,13 @@ exports.parseFileSize = parseFileSize;
42
42
  exports.parseElementKey = parseElementKey;
43
43
  exports.mapStatus = mapStatus;
44
44
  exports.flattenGroups = flattenGroups;
45
+ exports.readPrimaryGroup = readPrimaryGroup;
46
+ exports.readSharedGroups = readSharedGroups;
47
+ exports.writePrimaryGroup = writePrimaryGroup;
48
+ exports.writeSharedGroups = writeSharedGroups;
49
+ exports.assertGroupsValid = assertGroupsValid;
50
+ exports.assertRequired = assertRequired;
51
+ exports.assertNotEmptyIfPresent = assertNotEmptyIfPresent;
45
52
  exports.resolveFileToBlob = resolveFileToBlob;
46
53
  exports.deriveFilename = deriveFilename;
47
54
  exports.debugWarn = debugWarn;
@@ -221,6 +228,65 @@ function flattenGroups(groups, map = new Map()) {
221
228
  }
222
229
  return map;
223
230
  }
231
+ /** Reads the API `primaryGroup` object into a plain group id (0 = none). */
232
+ function readPrimaryGroup(raw) {
233
+ return raw?.group?.id ?? raw?.id ?? 0;
234
+ }
235
+ /** Reads the API `sharedGroups` array into a plain array of group ids. */
236
+ function readSharedGroups(raw) {
237
+ return (raw ?? []).map((g) => g.id);
238
+ }
239
+ /** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
240
+ function writePrimaryGroup(id) {
241
+ return { id: id || null };
242
+ }
243
+ /** Writes a plain array of group ids back to the API `sharedGroups` shape. */
244
+ function writeSharedGroups(ids) {
245
+ return (ids ?? []).map((id) => ({ id }));
246
+ }
247
+ /**
248
+ * Validates that group/visibility values are acceptable to the T4 API before a
249
+ * write. The API returns an opaque 500 when `sharedGroups` contains the same id
250
+ * as `primaryGroup` (a group cannot be both the owner and a shared group), so we
251
+ * catch it here with a clear message. A `primaryGroup` of 0 means "no owning
252
+ * group" and is never a conflict.
253
+ */
254
+ function assertGroupsValid(primaryGroup, sharedGroups) {
255
+ if (!primaryGroup)
256
+ return;
257
+ if ((sharedGroups ?? []).includes(primaryGroup)) {
258
+ throw new Error(`sharedGroups cannot contain the primaryGroup id (${primaryGroup}). ` +
259
+ 'A group cannot be both the primary (owning) group and a shared group. ' +
260
+ 'Remove it from sharedGroups or choose a different primaryGroup.');
261
+ }
262
+ }
263
+ /**
264
+ * Asserts that a required string value is present and not blank.
265
+ *
266
+ * Use on create paths where the field must always be provided. Throws
267
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
268
+ * Prevents opaque API 500s from malformed requests missing a required field.
269
+ */
270
+ function assertRequired(value, label) {
271
+ if (!value || !value.trim()) {
272
+ throw new Error(`${label} is required`);
273
+ }
274
+ }
275
+ /**
276
+ * Asserts that a value, *if provided*, is not blank.
277
+ *
278
+ * Use on update paths where a field is optional (omitting it leaves the
279
+ * current value unchanged) but explicitly setting it to an empty or
280
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
281
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
282
+ */
283
+ function assertNotEmptyIfPresent(value, label) {
284
+ if (value === undefined || value === null)
285
+ return;
286
+ if (!value.trim()) {
287
+ throw new Error(`${label} cannot be empty`);
288
+ }
289
+ }
224
290
  /**
225
291
  * Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
226
292
  */
@@ -0,0 +1,38 @@
1
+ import { HttpClient } from './http-client.js';
2
+ import { TypeRegistry } from './type-registry.js';
3
+ import { ElementResolver, MediaCreateFn } from './element-resolver.js';
4
+ import { TtlMap } from './utils.js';
5
+ /**
6
+ * Client-level shared caches for content operations.
7
+ *
8
+ * A single instance is created per `T4Client` and threaded into every
9
+ * `ContentResource` (including the short-lived ones created by
10
+ * `SectionRef.get()` / `addSection()`). This ensures that traversing the
11
+ * hierarchy — which creates many `ContentResource` instances via
12
+ * `t4.section(id)` — does not re-fetch instance-wide data on every hop.
13
+ *
14
+ * Two things previously lived on each `ContentResource` and were rebuilt per
15
+ * section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
16
+ * `GET /content/type/{ct}/{section}`):
17
+ *
18
+ * - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
19
+ * as a single registry and a single `ElementResolver`.
20
+ * - Content type templates — split into:
21
+ * - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
22
+ * response, keyed by content type ID.
23
+ * - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
24
+ * response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
25
+ *
26
+ * All caches respect the global cache epoch, so `T4Client.clearCache()`
27
+ * invalidates them the same way it always has.
28
+ */
29
+ export declare class ContentCache {
30
+ readonly typeRegistry: TypeRegistry;
31
+ readonly resolver: ElementResolver;
32
+ /** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
33
+ readonly sectionTemplates: TtlMap<string, unknown>;
34
+ /** `GET /contenttype/{id}` responses, keyed by content type ID. */
35
+ readonly contentTypeDefinitions: TtlMap<number, unknown>;
36
+ constructor(httpClient: HttpClient, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
37
+ }
38
+ //# sourceMappingURL=content-cache.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { TypeRegistry } from './type-registry.js';
2
+ import { ElementResolver } from './element-resolver.js';
3
+ import { TtlMap } from './utils.js';
4
+ /**
5
+ * Client-level shared caches for content operations.
6
+ *
7
+ * A single instance is created per `T4Client` and threaded into every
8
+ * `ContentResource` (including the short-lived ones created by
9
+ * `SectionRef.get()` / `addSection()`). This ensures that traversing the
10
+ * hierarchy — which creates many `ContentResource` instances via
11
+ * `t4.section(id)` — does not re-fetch instance-wide data on every hop.
12
+ *
13
+ * Two things previously lived on each `ContentResource` and were rebuilt per
14
+ * section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
15
+ * `GET /content/type/{ct}/{section}`):
16
+ *
17
+ * - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
18
+ * as a single registry and a single `ElementResolver`.
19
+ * - Content type templates — split into:
20
+ * - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
21
+ * response, keyed by content type ID.
22
+ * - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
23
+ * response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
24
+ *
25
+ * All caches respect the global cache epoch, so `T4Client.clearCache()`
26
+ * invalidates them the same way it always has.
27
+ */
28
+ export class ContentCache {
29
+ constructor(httpClient, defaultLanguage, mediaCreateFn) {
30
+ /** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
31
+ this.sectionTemplates = new TtlMap();
32
+ /** `GET /contenttype/{id}` responses, keyed by content type ID. */
33
+ this.contentTypeDefinitions = new TtlMap();
34
+ this.typeRegistry = new TypeRegistry(httpClient);
35
+ this.resolver = new ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
36
+ }
37
+ }
38
+ //# sourceMappingURL=content-cache.js.map
@@ -83,6 +83,22 @@ export declare class ElementResolver {
83
83
  * Uses the TypeRegistry to look up type names instead of hardcoded IDs.
84
84
  */
85
85
  resolveValue(value: unknown, element: TemplateElement, language: string, allElements?: TemplateElement[], context?: ResolveContext): Promise<unknown>;
86
+ /**
87
+ * Builds the T4 elements map from developer-friendly field names and values.
88
+ * Resolves list values, dates, repeaters, etc. automatically.
89
+ *
90
+ * Shared by both write paths: `ContentResource` (create/update) and
91
+ * `ContentItem.save()`. Keeping a single implementation here prevents the two
92
+ * paths from drifting — the reason repeaters previously only resolved on one
93
+ * of them.
94
+ */
95
+ buildElements(fields: Record<string, unknown>, elements: TemplateElement[], name: string, language: string, sectionId: number, context?: ResolveContext): Promise<Record<string, unknown>>;
96
+ /**
97
+ * Builds a repeater value array from developer-friendly input.
98
+ * Each repeater item gets its own element key resolution using the
99
+ * repeater's sub-content-type elements from contentTypeElementConfiguration.
100
+ */
101
+ buildRepeaterValue(items: RepeaterInput[], element: TemplateElement, language: string, sectionId: number): Promise<unknown[]>;
86
102
  getList(listId: number, language: string): Promise<ListResponse>;
87
103
  private resolveItemName;
88
104
  private resolveDate;