@terminalfour/terminalfour-js 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/content-cache.d.ts +38 -0
- package/dist/cjs/content-cache.js +42 -0
- package/dist/cjs/element-resolver.d.ts +16 -0
- package/dist/cjs/element-resolver.js +77 -1
- package/dist/cjs/models/content-item.js +13 -1
- package/dist/cjs/resources/content-resource.d.ts +7 -11
- package/dist/cjs/resources/content-resource.js +36 -79
- package/dist/cjs/resources/content-type-resource.d.ts +2 -6
- package/dist/cjs/resources/content-type-resource.js +17 -6
- package/dist/cjs/resources/list-resource.d.ts +2 -6
- package/dist/cjs/resources/list-resource.js +8 -6
- package/dist/cjs/resources/navigation-resource.d.ts +17 -0
- package/dist/cjs/resources/navigation-resource.js +13 -2
- package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
- package/dist/cjs/resources/page-layout-resource.js +12 -2
- package/dist/cjs/section-ref.d.ts +3 -1
- package/dist/cjs/section-ref.js +6 -5
- package/dist/cjs/t4-client.d.ts +1 -0
- package/dist/cjs/t4-client.js +6 -1
- package/dist/cjs/utils.d.ts +39 -0
- package/dist/cjs/utils.js +37 -0
- package/dist/esm/content-cache.d.ts +38 -0
- package/dist/esm/content-cache.js +38 -0
- package/dist/esm/element-resolver.d.ts +16 -0
- package/dist/esm/element-resolver.js +77 -1
- package/dist/esm/models/content-item.js +13 -1
- package/dist/esm/resources/content-resource.d.ts +7 -11
- package/dist/esm/resources/content-resource.js +37 -80
- package/dist/esm/resources/content-type-resource.d.ts +2 -6
- package/dist/esm/resources/content-type-resource.js +18 -7
- package/dist/esm/resources/list-resource.d.ts +2 -6
- package/dist/esm/resources/list-resource.js +9 -7
- package/dist/esm/resources/navigation-resource.d.ts +17 -0
- package/dist/esm/resources/navigation-resource.js +13 -2
- package/dist/esm/resources/page-layout-resource.d.ts +11 -0
- package/dist/esm/resources/page-layout-resource.js +13 -3
- package/dist/esm/section-ref.d.ts +3 -1
- package/dist/esm/section-ref.js +6 -5
- package/dist/esm/t4-client.d.ts +1 -0
- package/dist/esm/t4-client.js +6 -1
- package/dist/esm/utils.d.ts +39 -0
- package/dist/esm/utils.js +32 -0
- package/docs/content-types.md +2 -2
- package/docs/content.md +13 -0
- package/docs/error-handling.md +3 -1
- package/docs/getting-started.md +4 -0
- package/docs/lists.md +2 -2
- package/docs/navigation.md +30 -1
- package/docs/page-layouts.md +26 -0
- package/package.json +1 -1
|
@@ -1,35 +1,42 @@
|
|
|
1
|
-
import { resolveLanguage, toTimestamp, STATUS_CODES
|
|
1
|
+
import { resolveLanguage, toTimestamp, STATUS_CODES } from '../utils.js';
|
|
2
2
|
import { createContentItem } from '../models/content-item.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
26
|
+
const sectionKey = `${contentTypeId}:${this.sectionId}`;
|
|
27
|
+
const cached = this.cache.sectionTemplates.get(sectionKey);
|
|
21
28
|
if (cached)
|
|
22
29
|
return cached;
|
|
23
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
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[];
|
|
@@ -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
|
|
60
|
-
primaryGroup: raw.primaryGroup
|
|
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
|
|
657
|
-
primaryGroup:
|
|
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();
|
|
@@ -862,6 +867,9 @@ export class ContentTypeResource {
|
|
|
862
867
|
method: 'DELETE',
|
|
863
868
|
path: `/contenttype/${id}`,
|
|
864
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();
|
|
865
873
|
}
|
|
866
874
|
/** Creates a new content type. */
|
|
867
875
|
async create(data) {
|
|
@@ -871,6 +879,7 @@ export class ContentTypeResource {
|
|
|
871
879
|
if (!data.elements?.length) {
|
|
872
880
|
throw new Error('Content type must have at least one element');
|
|
873
881
|
}
|
|
882
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
874
883
|
// Validate useAsFilename constraints
|
|
875
884
|
const filenameElements = data.elements.filter((el) => el.useAsFilename);
|
|
876
885
|
if (filenameElements.length > 1) {
|
|
@@ -989,11 +998,13 @@ export class ContentTypeResource {
|
|
|
989
998
|
warningMessage: '',
|
|
990
999
|
elementIdforFilename: elementIdForFilename,
|
|
991
1000
|
conditionals: [],
|
|
992
|
-
sharedGroups: (data.sharedGroups
|
|
993
|
-
primaryGroup:
|
|
1001
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
1002
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
994
1003
|
contentTypeElements,
|
|
995
1004
|
},
|
|
996
1005
|
});
|
|
1006
|
+
// New content type may affect cached lookups; invalidate for consistency.
|
|
1007
|
+
invalidateAllCaches();
|
|
997
1008
|
const editorMap = await this.getEditorMap();
|
|
998
1009
|
const result = mapContentType(raw, typeMap, editorMap);
|
|
999
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
|
|
12
|
-
this.sharedGroups = (raw.sharedGroups
|
|
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:
|
|
74
|
-
sharedGroups: this.sharedGroups
|
|
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
|
|
167
|
-
primaryGroup:
|
|
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:
|
|
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
|
|
196
|
-
primaryGroup:
|
|
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
|
-
|
|
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. */
|
package/dist/esm/section-ref.js
CHANGED
|
@@ -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.
|
|
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 });
|
package/dist/esm/t4-client.d.ts
CHANGED
|
@@ -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.
|
package/dist/esm/t4-client.js
CHANGED
|
@@ -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.
|