@terminalfour/terminalfour-js 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -8
- package/dist/cjs/resources/content-type-resource.js +25 -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 +8 -8
- package/dist/esm/resources/content-type-resource.js +26 -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 +20 -3
- 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,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,7 @@ class NavigationObject {
|
|
|
1003
1006
|
}
|
|
1004
1007
|
/** Persists current property values to the server via PUT. */
|
|
1005
1008
|
async save() {
|
|
1009
|
+
(0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
|
|
1006
1010
|
// Apply type-aware write transformation (coerce back to strings, derive hidden fields)
|
|
1007
1011
|
const camelStringProps = transformPropertiesWrite(this.type, this.properties);
|
|
1008
1012
|
// Rebuild properties in API format
|
|
@@ -1022,6 +1026,8 @@ class NavigationObject {
|
|
|
1022
1026
|
isEnabled: this.enabled,
|
|
1023
1027
|
isCachingEnabled: this.cachingEnabled,
|
|
1024
1028
|
isPreviewModeEnabled: this.previewEnabled,
|
|
1029
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
|
|
1030
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
|
|
1025
1031
|
properties: apiProperties,
|
|
1026
1032
|
};
|
|
1027
1033
|
await this._httpClient.request({
|
|
@@ -1239,6 +1245,7 @@ class NavigationResource {
|
|
|
1239
1245
|
throw new Error('Navigation object type is required');
|
|
1240
1246
|
if (!exports.NAVIGATION_TYPE_NAMES[data.type])
|
|
1241
1247
|
throw new Error(`Unknown navigation type "${data.type}"`);
|
|
1248
|
+
(0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
|
|
1242
1249
|
const apiType = SDK_TO_API[data.type];
|
|
1243
1250
|
const properties = await this.buildProperties(data.type, (data.properties ?? {}));
|
|
1244
1251
|
const body = {
|
|
@@ -1248,8 +1255,8 @@ class NavigationResource {
|
|
|
1248
1255
|
name: data.name,
|
|
1249
1256
|
description: data.description ?? '',
|
|
1250
1257
|
navigationType: apiType,
|
|
1251
|
-
sharedGroups:
|
|
1252
|
-
primaryGroup:
|
|
1258
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
|
|
1259
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
|
|
1253
1260
|
properties,
|
|
1254
1261
|
};
|
|
1255
1262
|
// CSS Selector quirk: requires "section-name": "on" at the top level
|
|
@@ -1293,6 +1300,10 @@ class NavigationResource {
|
|
|
1293
1300
|
nav.previewEnabled = data.previewEnabled;
|
|
1294
1301
|
if (data.cachingEnabled !== undefined)
|
|
1295
1302
|
nav.cachingEnabled = data.cachingEnabled;
|
|
1303
|
+
if (data.primaryGroup !== undefined)
|
|
1304
|
+
nav.primaryGroup = data.primaryGroup;
|
|
1305
|
+
if (data.sharedGroups !== undefined)
|
|
1306
|
+
nav.sharedGroups = data.sharedGroups;
|
|
1296
1307
|
// Merge properties rather than replace — callers pass only what changes
|
|
1297
1308
|
if (data.properties !== undefined) {
|
|
1298
1309
|
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,7 @@ class PageLayout {
|
|
|
29
31
|
}
|
|
30
32
|
/** Persists current property values to the server via PUT. */
|
|
31
33
|
async save() {
|
|
34
|
+
(0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
|
|
32
35
|
// Resolve syntax name to ID
|
|
33
36
|
let syntaxId = this._rawData.syntaxType;
|
|
34
37
|
if (this._syntaxMap) {
|
|
@@ -53,6 +56,8 @@ class PageLayout {
|
|
|
53
56
|
fileExtension: this.fileExtension,
|
|
54
57
|
syntaxType: String(syntaxId),
|
|
55
58
|
layoutProcessor: String(processorId),
|
|
59
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
|
|
60
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
|
|
56
61
|
};
|
|
57
62
|
await this._httpClient.request({
|
|
58
63
|
method: 'PUT',
|
|
@@ -136,6 +141,10 @@ class PageLayoutResource {
|
|
|
136
141
|
layout.syntax = data.syntax;
|
|
137
142
|
if (data.processor !== undefined)
|
|
138
143
|
layout.processor = data.processor;
|
|
144
|
+
if (data.primaryGroup !== undefined)
|
|
145
|
+
layout.primaryGroup = data.primaryGroup;
|
|
146
|
+
if (data.sharedGroups !== undefined)
|
|
147
|
+
layout.sharedGroups = data.sharedGroups;
|
|
139
148
|
await layout.save();
|
|
140
149
|
return layout;
|
|
141
150
|
}
|
|
@@ -143,6 +152,7 @@ class PageLayoutResource {
|
|
|
143
152
|
async create(data) {
|
|
144
153
|
if (!data.name?.trim())
|
|
145
154
|
throw new Error('Page layout name is required');
|
|
155
|
+
(0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
|
|
146
156
|
const [syntaxMap, processorMap] = await Promise.all([
|
|
147
157
|
this.getSyntaxMap(),
|
|
148
158
|
this.getProcessorMap(),
|
|
@@ -196,8 +206,8 @@ class PageLayoutResource {
|
|
|
196
206
|
fileExtension: extensionValue,
|
|
197
207
|
syntaxType: syntaxId,
|
|
198
208
|
layoutProcessor: processorId,
|
|
199
|
-
sharedGroups: (
|
|
200
|
-
primaryGroup:
|
|
209
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
|
|
210
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
|
|
201
211
|
},
|
|
202
212
|
});
|
|
203
213
|
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/cjs/section-ref.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
|
@@ -645,7 +646,7 @@ class SectionRef {
|
|
|
645
646
|
if (metaContentTypeId) {
|
|
646
647
|
// Use a ContentResource on the new section to get full element resolution
|
|
647
648
|
// (list values, SS links, file uploads, etc.)
|
|
648
|
-
const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
|
|
649
|
+
const metadataContentResource = new content_resource_js_1.ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
|
|
649
650
|
// Create the metadata content item (ContentResource.create handles the full body)
|
|
650
651
|
const metaItem = await metadataContentResource.create({
|
|
651
652
|
type: metaContentTypeId,
|
|
@@ -734,7 +735,7 @@ class SectionRef {
|
|
|
734
735
|
if (!metaDataTypeId) {
|
|
735
736
|
throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
|
|
736
737
|
}
|
|
737
|
-
const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
|
|
738
|
+
const contentResource = new content_resource_js_1.ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
|
|
738
739
|
const metaContentId = meta?.id ?? 0;
|
|
739
740
|
if (metaContentId > 0) {
|
|
740
741
|
await contentResource.update(metaContentId, { fields: data.customFields });
|
package/dist/cjs/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/cjs/t4-client.js
CHANGED
|
@@ -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.
|
package/dist/cjs/utils.d.ts
CHANGED
|
@@ -68,6 +68,45 @@ 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;
|
|
71
110
|
/** Accepted file input: a file path, URL, Blob, ReadableStream, or { file, filename } object */
|
|
72
111
|
export type FileInput = string | Blob | NodeJS.ReadableStream | {
|
|
73
112
|
file: string | Blob | NodeJS.ReadableStream;
|
package/dist/cjs/utils.js
CHANGED
|
@@ -42,6 +42,11 @@ 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;
|
|
45
50
|
exports.resolveFileToBlob = resolveFileToBlob;
|
|
46
51
|
exports.deriveFilename = deriveFilename;
|
|
47
52
|
exports.debugWarn = debugWarn;
|
|
@@ -221,6 +226,38 @@ function flattenGroups(groups, map = new Map()) {
|
|
|
221
226
|
}
|
|
222
227
|
return map;
|
|
223
228
|
}
|
|
229
|
+
/** Reads the API `primaryGroup` object into a plain group id (0 = none). */
|
|
230
|
+
function readPrimaryGroup(raw) {
|
|
231
|
+
return raw?.group?.id ?? raw?.id ?? 0;
|
|
232
|
+
}
|
|
233
|
+
/** Reads the API `sharedGroups` array into a plain array of group ids. */
|
|
234
|
+
function readSharedGroups(raw) {
|
|
235
|
+
return (raw ?? []).map((g) => g.id);
|
|
236
|
+
}
|
|
237
|
+
/** Writes a plain group id back to the API `primaryGroup` shape (0/falsy = none). */
|
|
238
|
+
function writePrimaryGroup(id) {
|
|
239
|
+
return { id: id || null };
|
|
240
|
+
}
|
|
241
|
+
/** Writes a plain array of group ids back to the API `sharedGroups` shape. */
|
|
242
|
+
function writeSharedGroups(ids) {
|
|
243
|
+
return (ids ?? []).map((id) => ({ id }));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Validates that group/visibility values are acceptable to the T4 API before a
|
|
247
|
+
* write. The API returns an opaque 500 when `sharedGroups` contains the same id
|
|
248
|
+
* as `primaryGroup` (a group cannot be both the owner and a shared group), so we
|
|
249
|
+
* catch it here with a clear message. A `primaryGroup` of 0 means "no owning
|
|
250
|
+
* group" and is never a conflict.
|
|
251
|
+
*/
|
|
252
|
+
function assertGroupsValid(primaryGroup, sharedGroups) {
|
|
253
|
+
if (!primaryGroup)
|
|
254
|
+
return;
|
|
255
|
+
if ((sharedGroups ?? []).includes(primaryGroup)) {
|
|
256
|
+
throw new Error(`sharedGroups cannot contain the primaryGroup id (${primaryGroup}). ` +
|
|
257
|
+
'A group cannot be both the primary (owning) group and a shared group. ' +
|
|
258
|
+
'Remove it from sharedGroups or choose a different primaryGroup.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
224
261
|
/**
|
|
225
262
|
* Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
|
|
226
263
|
*/
|
|
@@ -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;
|
|
@@ -79,11 +79,87 @@ export class ElementResolver {
|
|
|
79
79
|
case 'Keyword Selector':
|
|
80
80
|
return this.resolveKeywordSelector(value, element.listId, language);
|
|
81
81
|
case 'Repeater':
|
|
82
|
-
return value; // handled separately in buildElements
|
|
82
|
+
return value; // handled separately in buildElements / buildRepeaterValue
|
|
83
83
|
default:
|
|
84
84
|
return value;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Builds the T4 elements map from developer-friendly field names and values.
|
|
89
|
+
* Resolves list values, dates, repeaters, etc. automatically.
|
|
90
|
+
*
|
|
91
|
+
* Shared by both write paths: `ContentResource` (create/update) and
|
|
92
|
+
* `ContentItem.save()`. Keeping a single implementation here prevents the two
|
|
93
|
+
* paths from drifting — the reason repeaters previously only resolved on one
|
|
94
|
+
* of them.
|
|
95
|
+
*/
|
|
96
|
+
async buildElements(fields, elements, name, language, sectionId, context) {
|
|
97
|
+
const result = {};
|
|
98
|
+
// Name element
|
|
99
|
+
const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
|
|
100
|
+
if (nameEl) {
|
|
101
|
+
result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
|
|
102
|
+
}
|
|
103
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
104
|
+
const fieldLower = fieldName.toLowerCase();
|
|
105
|
+
const element = elements.find((el) => el.name.toLowerCase() === fieldLower
|
|
106
|
+
|| (el.alias && el.alias.toLowerCase() === fieldLower));
|
|
107
|
+
if (!element) {
|
|
108
|
+
const validNames = elements
|
|
109
|
+
.filter((el) => el.name.toLowerCase() !== 'name')
|
|
110
|
+
.map((el) => `"${el.alias || el.name}"`)
|
|
111
|
+
.join(', ');
|
|
112
|
+
throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
|
|
113
|
+
}
|
|
114
|
+
const key = `${element.name}#${element.id}:${element.type}`;
|
|
115
|
+
// Repeater — special handling (no maxSize validation)
|
|
116
|
+
const typeName = await this.typeRegistry.getNameById(element.type);
|
|
117
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
118
|
+
result[key] = await this.buildRepeaterValue(value, element, language, sectionId);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const resolved = await this.resolveValue(value, element, language, elements, context);
|
|
122
|
+
// Validate maxSize on the resolved value (what actually gets sent to the API)
|
|
123
|
+
if (element.maxSize) {
|
|
124
|
+
const resolvedStr = String(resolved ?? '');
|
|
125
|
+
if (resolvedStr.length > element.maxSize) {
|
|
126
|
+
const friendlyName = element.alias || element.name;
|
|
127
|
+
throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
result[key] = resolved;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Builds a repeater value array from developer-friendly input.
|
|
136
|
+
* Each repeater item gets its own element key resolution using the
|
|
137
|
+
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
138
|
+
*/
|
|
139
|
+
async buildRepeaterValue(items, element, language, sectionId) {
|
|
140
|
+
const config = element.contentTypeElementConfiguration;
|
|
141
|
+
const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
|
|
142
|
+
if (!repeaterElements || repeaterElements.length === 0)
|
|
143
|
+
return items;
|
|
144
|
+
const result = [];
|
|
145
|
+
for (const item of items) {
|
|
146
|
+
const repeaterId = -Math.floor(Math.random() * 100000);
|
|
147
|
+
// Repeater items use their own repeaterId as fromContentId for SS links
|
|
148
|
+
const repeaterContext = {
|
|
149
|
+
fromSectionId: sectionId,
|
|
150
|
+
fromContentId: repeaterId,
|
|
151
|
+
};
|
|
152
|
+
const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, sectionId, repeaterContext);
|
|
153
|
+
result.push({
|
|
154
|
+
repeaterId,
|
|
155
|
+
repeaterContent: {
|
|
156
|
+
name: item.name,
|
|
157
|
+
elements,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
87
163
|
// ── List fetching ──
|
|
88
164
|
async getList(listId, language) {
|
|
89
165
|
const cached = this.listCache.get(listId);
|
|
@@ -526,7 +526,19 @@ export class ContentItem {
|
|
|
526
526
|
}
|
|
527
527
|
let resolved;
|
|
528
528
|
if (templateEl && this._resolver) {
|
|
529
|
-
|
|
529
|
+
// Repeater fields need the same dedicated resolution ContentResource
|
|
530
|
+
// uses on create/update — each item's sub-fields resolved into element
|
|
531
|
+
// keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
|
|
532
|
+
// passes repeater arrays through untouched, so branch here explicitly.
|
|
533
|
+
const typeName = this._typeRegistry
|
|
534
|
+
? await this._typeRegistry.getNameById(templateEl.type)
|
|
535
|
+
: null;
|
|
536
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
537
|
+
resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
|
|
541
|
+
}
|
|
530
542
|
}
|
|
531
543
|
else {
|
|
532
544
|
resolved = value;
|
|
@@ -2,6 +2,7 @@ import { HttpClient } from '../http-client.js';
|
|
|
2
2
|
import { LanguageOption, CreateContentData, UpdateContentData } from '../types.js';
|
|
3
3
|
import { ContentItem } from '../models/content-item.js';
|
|
4
4
|
import { MediaCreateFn } from '../element-resolver.js';
|
|
5
|
+
import { ContentCache } from '../content-cache.js';
|
|
5
6
|
/**
|
|
6
7
|
* Section-scoped resource for content CRUD operations.
|
|
7
8
|
* All requests are scoped to the section ID provided at construction time.
|
|
@@ -10,23 +11,18 @@ export declare class ContentResource {
|
|
|
10
11
|
private readonly httpClient;
|
|
11
12
|
private readonly sectionId;
|
|
12
13
|
private readonly defaultLanguage;
|
|
13
|
-
private readonly
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
|
|
14
|
+
private readonly cache;
|
|
15
|
+
constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
|
|
16
|
+
private get resolver();
|
|
17
|
+
private get typeRegistry();
|
|
18
18
|
private getTemplate;
|
|
19
|
+
/** Fetches the section-independent content type definition, cached by content type ID. */
|
|
20
|
+
private getContentTypeDefinition;
|
|
19
21
|
/**
|
|
20
22
|
* Builds the T4 elements map from developer-friendly field names and values.
|
|
21
23
|
* Resolves list values, dates, repeaters, etc. automatically.
|
|
22
24
|
*/
|
|
23
25
|
private buildElements;
|
|
24
|
-
/**
|
|
25
|
-
* Builds repeater value array from developer-friendly input.
|
|
26
|
-
* Each repeater item gets its own element key resolution using the
|
|
27
|
-
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
28
|
-
*/
|
|
29
|
-
private buildRepeaterValue;
|
|
30
26
|
/** Lists all content items in this section. */
|
|
31
27
|
list(options?: LanguageOption): Promise<ContentItem[]>;
|
|
32
28
|
/** Retrieves a single content item by ID. */
|