@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
@@ -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, assertRequired } 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,8 @@ export class NavigationObject {
1000
1003
  }
1001
1004
  /** Persists current property values to the server via PUT. */
1002
1005
  async save() {
1006
+ assertRequired(this.name, 'Navigation object name');
1007
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
1003
1008
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1004
1009
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
1005
1010
  // Rebuild properties in API format
@@ -1019,6 +1024,8 @@ export class NavigationObject {
1019
1024
  isEnabled: this.enabled,
1020
1025
  isCachingEnabled: this.cachingEnabled,
1021
1026
  isPreviewModeEnabled: this.previewEnabled,
1027
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
1028
+ sharedGroups: writeSharedGroups(this.sharedGroups),
1022
1029
  properties: apiProperties,
1023
1030
  };
1024
1031
  await this._httpClient.request({
@@ -1235,6 +1242,7 @@ export class NavigationResource {
1235
1242
  throw new Error('Navigation object type is required');
1236
1243
  if (!NAVIGATION_TYPE_NAMES[data.type])
1237
1244
  throw new Error(`Unknown navigation type "${data.type}"`);
1245
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
1238
1246
  const apiType = SDK_TO_API[data.type];
1239
1247
  const properties = await this.buildProperties(data.type, (data.properties ?? {}));
1240
1248
  const body = {
@@ -1244,8 +1252,8 @@ export class NavigationResource {
1244
1252
  name: data.name,
1245
1253
  description: data.description ?? '',
1246
1254
  navigationType: apiType,
1247
- sharedGroups: [],
1248
- primaryGroup: { id: 0 },
1255
+ sharedGroups: writeSharedGroups(data.sharedGroups),
1256
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
1249
1257
  properties,
1250
1258
  };
1251
1259
  // CSS Selector quirk: requires "section-name": "on" at the top level
@@ -1289,6 +1297,10 @@ export class NavigationResource {
1289
1297
  nav.previewEnabled = data.previewEnabled;
1290
1298
  if (data.cachingEnabled !== undefined)
1291
1299
  nav.cachingEnabled = data.cachingEnabled;
1300
+ if (data.primaryGroup !== undefined)
1301
+ nav.primaryGroup = data.primaryGroup;
1302
+ if (data.sharedGroups !== undefined)
1303
+ nav.sharedGroups = data.sharedGroups;
1292
1304
  // Merge properties rather than replace — callers pass only what changes
1293
1305
  if (data.properties !== undefined) {
1294
1306
  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, assertRequired } 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,8 @@ export class PageLayout {
26
28
  }
27
29
  /** Persists current property values to the server via PUT. */
28
30
  async save() {
31
+ assertRequired(this.name, 'Page layout name');
32
+ assertGroupsValid(this.primaryGroup, this.sharedGroups);
29
33
  // Resolve syntax name to ID
30
34
  let syntaxId = this._rawData.syntaxType;
31
35
  if (this._syntaxMap) {
@@ -50,6 +54,8 @@ export class PageLayout {
50
54
  fileExtension: this.fileExtension,
51
55
  syntaxType: String(syntaxId),
52
56
  layoutProcessor: String(processorId),
57
+ primaryGroup: writePrimaryGroup(this.primaryGroup),
58
+ sharedGroups: writeSharedGroups(this.sharedGroups),
53
59
  };
54
60
  await this._httpClient.request({
55
61
  method: 'PUT',
@@ -132,6 +138,10 @@ export class PageLayoutResource {
132
138
  layout.syntax = data.syntax;
133
139
  if (data.processor !== undefined)
134
140
  layout.processor = data.processor;
141
+ if (data.primaryGroup !== undefined)
142
+ layout.primaryGroup = data.primaryGroup;
143
+ if (data.sharedGroups !== undefined)
144
+ layout.sharedGroups = data.sharedGroups;
135
145
  await layout.save();
136
146
  return layout;
137
147
  }
@@ -139,6 +149,7 @@ export class PageLayoutResource {
139
149
  async create(data) {
140
150
  if (!data.name?.trim())
141
151
  throw new Error('Page layout name is required');
152
+ assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
142
153
  const [syntaxMap, processorMap] = await Promise.all([
143
154
  this.getSyntaxMap(),
144
155
  this.getProcessorMap(),
@@ -192,8 +203,8 @@ export class PageLayoutResource {
192
203
  fileExtension: extensionValue,
193
204
  syntaxType: syntaxId,
194
205
  layoutProcessor: processorId,
195
- sharedGroups: (data.sharedGroups ?? []).map((id) => ({ id })),
196
- primaryGroup: { id: data.primaryGroup ?? null },
206
+ sharedGroups: writeSharedGroups(data.sharedGroups),
207
+ primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
197
208
  },
198
209
  });
199
210
  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. */
@@ -1,4 +1,4 @@
1
- import { resolveLanguage, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from './utils.js';
1
+ import { resolveLanguage, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired, assertNotEmptyIfPresent } from './utils.js';
2
2
  import { ContentResource } from './resources/content-resource.js';
3
3
  import { SectionItem } from './models/section-item.js';
4
4
  /** Per-client cache for meta tag definitions, keyed by HttpClient instance */
@@ -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
  }
@@ -563,6 +564,7 @@ export class SectionRef {
563
564
  * If `data.customFields` is provided, creates and saves section metadata content.
564
565
  */
565
566
  async addSection(data, options) {
567
+ assertRequired(data.name, 'Section name');
566
568
  const language = resolveLanguage(options?.language, this.defaultLanguage);
567
569
  // Fetch this section's details to inherit config
568
570
  const parentSection = await this.httpClient.request({
@@ -641,7 +643,7 @@ export class SectionRef {
641
643
  if (metaContentTypeId) {
642
644
  // Use a ContentResource on the new section to get full element resolution
643
645
  // (list values, SS links, file uploads, etc.)
644
- const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
646
+ const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
645
647
  // Create the metadata content item (ContentResource.create handles the full body)
646
648
  const metaItem = await metadataContentResource.create({
647
649
  type: metaContentTypeId,
@@ -707,6 +709,10 @@ export class SectionRef {
707
709
  * Returns the updated SectionItem.
708
710
  */
709
711
  async update(data, options) {
712
+ // This path PUTs directly (it does not go through SectionItem.save()), so
713
+ // guard the name here. Present-only: omitting name is valid (e.g. delete()
714
+ // calls update({ status: 'inactive' })), but setting it blank is not.
715
+ assertNotEmptyIfPresent(data.name, 'Section name');
710
716
  const language = resolveLanguage(options?.language, this.defaultLanguage);
711
717
  const section = await this.httpClient.request({
712
718
  method: 'GET',
@@ -730,7 +736,7 @@ export class SectionRef {
730
736
  if (!metaDataTypeId) {
731
737
  throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
732
738
  }
733
- const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
739
+ const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
734
740
  const metaContentId = meta?.id ?? 0;
735
741
  if (metaContentId > 0) {
736
742
  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.
@@ -13,6 +13,7 @@ import { PageLayoutResource } from './resources/page-layout-resource.js';
13
13
  import { MediaTypeResource } from './resources/media-type-resource.js';
14
14
  import { NavigationResource } from './resources/navigation-resource.js';
15
15
  import { Handlebars } from './handlebars.js';
16
+ import { ContentCache } from './content-cache.js';
16
17
  import { invalidateAllCaches, normaliseBaseUrl, assertNotBrowser } from './utils.js';
17
18
  /**
18
19
  * Main entry point for the T4 SDK.
@@ -54,12 +55,16 @@ export class T4Client {
54
55
  });
55
56
  return item.id;
56
57
  };
58
+ // Shared content caches (element TypeRegistry, content type templates).
59
+ // Threaded into every SectionRef/ContentResource so hierarchy traversal
60
+ // doesn't re-fetch instance-wide data on each t4.section(id) call.
61
+ this.contentCache = new ContentCache(this.httpClient, this.defaultLanguage, this.mediaCreateFn);
57
62
  }
58
63
  /**
59
64
  * Returns a section reference scoped to the given section ID.
60
65
  */
61
66
  section(id) {
62
- return new SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn);
67
+ return new SectionRef(this.httpClient, id, this.defaultLanguage, this.mediaCreateFn, this.contentCache);
63
68
  }
64
69
  /**
65
70
  * 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/esm/utils.js CHANGED
@@ -170,6 +170,65 @@ export function flattenGroups(groups, map = new Map()) {
170
170
  }
171
171
  return map;
172
172
  }
173
+ /** Reads the API `primaryGroup` object into a plain group id (0 = none). */
174
+ export function readPrimaryGroup(raw) {
175
+ return raw?.group?.id ?? raw?.id ?? 0;
176
+ }
177
+ /** Reads the API `sharedGroups` array into a plain array of group ids. */
178
+ export function readSharedGroups(raw) {
179
+ return (raw ?? []).map((g) => g.id);
180
+ }
181
+ /** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
182
+ export function writePrimaryGroup(id) {
183
+ return { id: id || null };
184
+ }
185
+ /** Writes a plain array of group ids back to the API `sharedGroups` shape. */
186
+ export function writeSharedGroups(ids) {
187
+ return (ids ?? []).map((id) => ({ id }));
188
+ }
189
+ /**
190
+ * Validates that group/visibility values are acceptable to the T4 API before a
191
+ * write. The API returns an opaque 500 when `sharedGroups` contains the same id
192
+ * as `primaryGroup` (a group cannot be both the owner and a shared group), so we
193
+ * catch it here with a clear message. A `primaryGroup` of 0 means "no owning
194
+ * group" and is never a conflict.
195
+ */
196
+ export function assertGroupsValid(primaryGroup, sharedGroups) {
197
+ if (!primaryGroup)
198
+ return;
199
+ if ((sharedGroups ?? []).includes(primaryGroup)) {
200
+ throw new Error(`sharedGroups cannot contain the primaryGroup id (${primaryGroup}). ` +
201
+ 'A group cannot be both the primary (owning) group and a shared group. ' +
202
+ 'Remove it from sharedGroups or choose a different primaryGroup.');
203
+ }
204
+ }
205
+ /**
206
+ * Asserts that a required string value is present and not blank.
207
+ *
208
+ * Use on create paths where the field must always be provided. Throws
209
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
210
+ * Prevents opaque API 500s from malformed requests missing a required field.
211
+ */
212
+ export function assertRequired(value, label) {
213
+ if (!value || !value.trim()) {
214
+ throw new Error(`${label} is required`);
215
+ }
216
+ }
217
+ /**
218
+ * Asserts that a value, *if provided*, is not blank.
219
+ *
220
+ * Use on update paths where a field is optional (omitting it leaves the
221
+ * current value unchanged) but explicitly setting it to an empty or
222
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
223
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
224
+ */
225
+ export function assertNotEmptyIfPresent(value, label) {
226
+ if (value === undefined || value === null)
227
+ return;
228
+ if (!value.trim()) {
229
+ throw new Error(`${label} cannot be empty`);
230
+ }
231
+ }
173
232
  /**
174
233
  * Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
175
234
  */
@@ -36,8 +36,8 @@ The returned `ContentType` includes these main properties:
36
36
  | `minUserLevel` | `'contributor'` |
37
37
  | `workflow` | Assigned workflow |
38
38
  | `directEdit` | Direct edit setting |
39
- | `primaryGroup` | Primary group |
40
- | `sharedGroups` | Shared groups |
39
+ | `primaryGroup` | Owning group ID (`0` = none) |
40
+ | `sharedGroups` | Shared group IDs. Cannot contain `primaryGroup`. |
41
41
  | `fields` | Field definitions keyed by name |
42
42
 
43
43
  Each entry in `news.fields` can include:
@@ -328,7 +328,7 @@ await contentType.layouts.create({
328
328
  });
329
329
  ```
330
330
 
331
- Processor options are `'handlebars'`, `'t4-tags'`, and `'programmable-layouts'`. The default is `'handlebars'`. The SDK enforces unique layout names.
331
+ Processor options are `'handlebars'`, `'t4-tags'`, and `'programmable-layouts'`. The default is `'handlebars'`. The SDK enforces unique layout names. `name` is required (throws `Layout name is required` if empty); `code` is optional and may be empty. Renaming a layout to an empty string on update is rejected.
332
332
 
333
333
  ### Direct update
334
334
 
package/docs/content.md CHANGED
@@ -113,6 +113,19 @@ await article.save();
113
113
 
114
114
  `save()` defaults the status to `'pending'` to match T4's approval workflow. Set `item.status = 'approved'` before saving if the item must remain approved.
115
115
 
116
+ `save()` resolves every field type the same way `content.create()` and `content.update()` do — including Repeater fields. A repeater is read back as an array of `{ name, fields }` items (see [Values returned on read](#values-returned-on-read)), and you assign the same friendly shape back:
117
+
118
+ ```typescript
119
+ const deck = await t4.section(482).content.get(9200);
120
+ deck.fields['Slides'] = [
121
+ { name: 'Slide 1', fields: { Heading: 'Welcome' } },
122
+ { name: 'Slide 2', fields: { Heading: 'Agenda' } },
123
+ ];
124
+ await deck.save();
125
+ ```
126
+
127
+ Each repeater item's sub-fields are resolved into the API's element format automatically, so the mutable `get()` → modify → `save()` path and `content.update()` produce identical results.
128
+
116
129
  ## Approve, duplicate, move, or remove
117
130
 
118
131
  ### Approve one item
@@ -61,6 +61,32 @@ The final error can result from a call such as:
61
61
  await t4.users.create({ username: '', ... });
62
62
  ```
63
63
 
64
+ ### Required fields
65
+
66
+ Creating an asset without its required `name` (or other required input) throws before any request is sent, so you get a clear message instead of an opaque API error:
67
+
68
+ ```text
69
+ Error: Section name is required
70
+ Error: Content name is required
71
+ Error: Layout name is required
72
+ Error: Media name is required
73
+ Error: Media file is required
74
+ ```
75
+
76
+ The same protection applies on update: you cannot blank out a name that is already set. Omitting `name` leaves it unchanged, but setting it to an empty or whitespace-only string is rejected — through both the resource `update()` method and the mutable `get()` → modify → `save()` pattern:
77
+
78
+ ```typescript
79
+ const section = await t4.section(482).get();
80
+ section.name = '';
81
+ await section.save();
82
+ // Error: Section name is required
83
+
84
+ await t4.section(482).update({ name: ' ' });
85
+ // Error: Section name cannot be empty
86
+ ```
87
+
88
+ This covers content types, content items, sections, lists, groups, page layouts, media, media types, media categories, navigation objects, and Handlebars helpers/partials.
89
+
64
90
  ## Enable debug logging
65
91
 
66
92
  Set `T4_DEBUG=1` to print HTTP requests and internal warnings:
@@ -73,7 +99,9 @@ Graceful degradation paths, including failed media lookups and group name resolu
73
99
 
74
100
  ## Clear stale cache data
75
101
 
76
- After changing content types, lists, or other configuration, invalidate every SDK cache:
102
+ Content type writes through the SDK — `contentTypes.create()`, `contentTypes.update()`, `contentTypes.delete()`, and `ContentType.save()` (including `addField`/`removeField`) — clear the SDK's caches automatically, so a content type edit is reflected on the next read without any extra step.
103
+
104
+ You still need to clear the cache manually when configuration changes **outside** this SDK instance — for example content type, list, or other changes made in the T4 UI or by another process while your client is running:
77
105
 
78
106
  ```typescript
79
107
  t4.clearCache();
@@ -141,6 +141,10 @@ The SDK caches content type templates, list values, element type definitions, an
141
141
  t4.clearCache();
142
142
  ```
143
143
 
144
+ These caches are shared across the whole client, so reuse a single `T4Client` instance for the life of your task. Traversing the hierarchy — for example fetching and updating content across many sections with `t4.section(id)` — reuses cached element types and content type definitions instead of re-fetching them for each section. Creating a new `T4Client` per section would defeat this, so avoid that in loops.
145
+
146
+ Editing a content type through this client (`contentTypes.update()`, `ContentType.save()`, and so on) clears these caches for you, so your changes show up on the next read. Call `clearCache()` manually only for changes made outside this client instance.
147
+
144
148
  ---
145
149
 
146
150
  **Next:** [Sections](./sections.md)
package/docs/lists.md CHANGED
@@ -17,8 +17,8 @@ const list = await t4.lists.get(71);
17
17
  | `description` | List description |
18
18
  | `isForcedLanguage` | `false` |
19
19
  | `isDefaultLanguage` | `false` |
20
- | `primaryGroup` | `0` |
21
- | `sharedGroups` | `[]` |
20
+ | `primaryGroup` | Owning group ID (`0` = none) |
21
+ | `sharedGroups` | Shared group IDs. Cannot contain `primaryGroup`. |
22
22
  | `items` | Item definitions keyed by name |
23
23
 
24
24
  Items are keyed by name:
@@ -34,6 +34,8 @@ console.log(navigation.type); // 'a-to-z'
34
34
  console.log(navigation.enabled); // true
35
35
  console.log(navigation.cachingEnabled); // false
36
36
  console.log(navigation.previewEnabled); // true
37
+ console.log(navigation.primaryGroup); // 1 (owning group ID; 0 = Global)
38
+ console.log(navigation.sharedGroups); // [34, 40]
37
39
  console.log(navigation.properties); // properties vary by type
38
40
  ```
39
41
 
@@ -46,6 +48,8 @@ Type-specific properties use JavaScript booleans, numbers, and arrays. The SDK o
46
48
  | `enabled` | yes | Whether the navigation is active |
47
49
  | `cachingEnabled` | yes | Whether output caching is enabled |
48
50
  | `previewEnabled` | yes | Whether preview mode is enabled |
51
+ | `primaryGroup` | yes | Owning group ID. `0` means no primary group (Global). |
52
+ | `sharedGroups` | yes | Array of group IDs the object is shared with |
49
53
  | `properties` | yes | Type-specific configuration |
50
54
 
51
55
  ## Create a navigation object
@@ -63,7 +67,16 @@ await t4.navigation.create({
63
67
  });
64
68
  ```
65
69
 
66
- Only `type` and `name` are required. Properties have default values.
70
+ Only `type` and `name` are required. Properties have default values. `create()` also accepts optional `primaryGroup` and `sharedGroups`:
71
+
72
+ ```typescript
73
+ await t4.navigation.create({
74
+ type: 'breadcrumbs',
75
+ name: 'Main Breadcrumbs',
76
+ primaryGroup: 35, // optional; owning group ID
77
+ sharedGroups: [34, 40], // optional; group IDs to share with
78
+ });
79
+ ```
67
80
 
68
81
  ## Update a navigation object
69
82
 
@@ -99,6 +112,22 @@ navigation.properties.beforeHtml = '<div>';
99
112
  await navigation.save();
100
113
  ```
101
114
 
115
+ ## Group and visibility
116
+
117
+ `primaryGroup` is the object's owning group and `sharedGroups` are the groups it is shared with. Both are read on `get()` and written on `save()`, `update()`, and `create()`. Set `primaryGroup` to `0` to remove the owning group (Global):
118
+
119
+ ```typescript
120
+ const navigation = await t4.navigation.get(181);
121
+ navigation.primaryGroup = 0; // remove from its group (Global)
122
+ navigation.sharedGroups = [];
123
+ await navigation.save();
124
+
125
+ // Or through update()
126
+ await t4.navigation.update(181, { primaryGroup: 35, sharedGroups: [34] });
127
+ ```
128
+
129
+ `sharedGroups` cannot contain the `primaryGroup` id: a group cannot be both the owning group and a shared group. The SDK throws a clear error before sending the request (Terminalfour otherwise returns an opaque 500).
130
+
102
131
  ## Delete a navigation object
103
132
 
104
133
  ```typescript