@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.
- 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/handlebars.js +1 -0
- package/dist/cjs/models/content-item.js +14 -1
- package/dist/cjs/models/media-category-item.js +2 -0
- package/dist/cjs/models/media-item.js +1 -0
- package/dist/cjs/models/section-item.js +1 -0
- package/dist/cjs/resources/content-resource.d.ts +7 -11
- package/dist/cjs/resources/content-resource.js +38 -79
- package/dist/cjs/resources/content-type-resource.d.ts +2 -6
- package/dist/cjs/resources/content-type-resource.js +20 -6
- package/dist/cjs/resources/group-resource.js +1 -0
- package/dist/cjs/resources/list-resource.d.ts +2 -6
- package/dist/cjs/resources/list-resource.js +9 -6
- package/dist/cjs/resources/media-resource.js +9 -2
- package/dist/cjs/resources/media-type-resource.js +1 -0
- package/dist/cjs/resources/navigation-resource.d.ts +17 -0
- package/dist/cjs/resources/navigation-resource.js +14 -2
- package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
- package/dist/cjs/resources/page-layout-resource.js +13 -2
- package/dist/cjs/section-ref.d.ts +3 -1
- package/dist/cjs/section-ref.js +11 -5
- package/dist/cjs/t4-client.d.ts +1 -0
- package/dist/cjs/t4-client.js +6 -1
- package/dist/cjs/utils.d.ts +56 -0
- package/dist/cjs/utils.js +66 -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/handlebars.js +2 -1
- package/dist/esm/models/content-item.js +15 -2
- package/dist/esm/models/media-category-item.js +2 -0
- package/dist/esm/models/media-item.js +2 -1
- package/dist/esm/models/section-item.js +2 -1
- package/dist/esm/resources/content-resource.d.ts +7 -11
- package/dist/esm/resources/content-resource.js +39 -80
- package/dist/esm/resources/content-type-resource.d.ts +2 -6
- package/dist/esm/resources/content-type-resource.js +21 -7
- package/dist/esm/resources/group-resource.js +2 -1
- package/dist/esm/resources/list-resource.d.ts +2 -6
- package/dist/esm/resources/list-resource.js +10 -7
- package/dist/esm/resources/media-resource.js +10 -3
- package/dist/esm/resources/media-type-resource.js +2 -1
- package/dist/esm/resources/navigation-resource.d.ts +17 -0
- package/dist/esm/resources/navigation-resource.js +14 -2
- package/dist/esm/resources/page-layout-resource.d.ts +11 -0
- package/dist/esm/resources/page-layout-resource.js +14 -3
- package/dist/esm/section-ref.d.ts +3 -1
- package/dist/esm/section-ref.js +12 -6
- package/dist/esm/t4-client.d.ts +1 -0
- package/dist/esm/t4-client.js +6 -1
- package/dist/esm/utils.d.ts +56 -0
- package/dist/esm/utils.js +59 -0
- package/docs/content-types.md +3 -3
- package/docs/content.md +13 -0
- package/docs/error-handling.md +29 -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
|
@@ -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,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ContentCache = void 0;
|
|
4
|
+
const type_registry_js_1 = require("./type-registry.js");
|
|
5
|
+
const element_resolver_js_1 = require("./element-resolver.js");
|
|
6
|
+
const utils_js_1 = require("./utils.js");
|
|
7
|
+
/**
|
|
8
|
+
* Client-level shared caches for content operations.
|
|
9
|
+
*
|
|
10
|
+
* A single instance is created per `T4Client` and threaded into every
|
|
11
|
+
* `ContentResource` (including the short-lived ones created by
|
|
12
|
+
* `SectionRef.get()` / `addSection()`). This ensures that traversing the
|
|
13
|
+
* hierarchy — which creates many `ContentResource` instances via
|
|
14
|
+
* `t4.section(id)` — does not re-fetch instance-wide data on every hop.
|
|
15
|
+
*
|
|
16
|
+
* Two things previously lived on each `ContentResource` and were rebuilt per
|
|
17
|
+
* section, causing repeated API calls (`GET /type/`, `GET /contenttype/{id}`,
|
|
18
|
+
* `GET /content/type/{ct}/{section}`):
|
|
19
|
+
*
|
|
20
|
+
* - The element `TypeRegistry` (`GET /type/`) — instance-wide, so shared here
|
|
21
|
+
* as a single registry and a single `ElementResolver`.
|
|
22
|
+
* - Content type templates — split into:
|
|
23
|
+
* - `contentTypeDefinitions`: the section-independent `GET /contenttype/{id}`
|
|
24
|
+
* response, keyed by content type ID.
|
|
25
|
+
* - `sectionTemplates`: the section-specific `GET /content/type/{ct}/{section}`
|
|
26
|
+
* response (carries channels), keyed by `"{contentTypeId}:{sectionId}"`.
|
|
27
|
+
*
|
|
28
|
+
* All caches respect the global cache epoch, so `T4Client.clearCache()`
|
|
29
|
+
* invalidates them the same way it always has.
|
|
30
|
+
*/
|
|
31
|
+
class ContentCache {
|
|
32
|
+
constructor(httpClient, defaultLanguage, mediaCreateFn) {
|
|
33
|
+
/** `GET /content/type/{ct}/{section}` responses, keyed by `"{ct}:{section}"`. */
|
|
34
|
+
this.sectionTemplates = new utils_js_1.TtlMap();
|
|
35
|
+
/** `GET /contenttype/{id}` responses, keyed by content type ID. */
|
|
36
|
+
this.contentTypeDefinitions = new utils_js_1.TtlMap();
|
|
37
|
+
this.typeRegistry = new type_registry_js_1.TypeRegistry(httpClient);
|
|
38
|
+
this.resolver = new element_resolver_js_1.ElementResolver(httpClient, defaultLanguage, this.typeRegistry, mediaCreateFn);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.ContentCache = ContentCache;
|
|
42
|
+
//# 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;
|
|
@@ -82,11 +82,87 @@ class ElementResolver {
|
|
|
82
82
|
case 'Keyword Selector':
|
|
83
83
|
return this.resolveKeywordSelector(value, element.listId, language);
|
|
84
84
|
case 'Repeater':
|
|
85
|
-
return value; // handled separately in buildElements
|
|
85
|
+
return value; // handled separately in buildElements / buildRepeaterValue
|
|
86
86
|
default:
|
|
87
87
|
return value;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Builds the T4 elements map from developer-friendly field names and values.
|
|
92
|
+
* Resolves list values, dates, repeaters, etc. automatically.
|
|
93
|
+
*
|
|
94
|
+
* Shared by both write paths: `ContentResource` (create/update) and
|
|
95
|
+
* `ContentItem.save()`. Keeping a single implementation here prevents the two
|
|
96
|
+
* paths from drifting — the reason repeaters previously only resolved on one
|
|
97
|
+
* of them.
|
|
98
|
+
*/
|
|
99
|
+
async buildElements(fields, elements, name, language, sectionId, context) {
|
|
100
|
+
const result = {};
|
|
101
|
+
// Name element
|
|
102
|
+
const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
|
|
103
|
+
if (nameEl) {
|
|
104
|
+
result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
|
|
105
|
+
}
|
|
106
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
107
|
+
const fieldLower = fieldName.toLowerCase();
|
|
108
|
+
const element = elements.find((el) => el.name.toLowerCase() === fieldLower
|
|
109
|
+
|| (el.alias && el.alias.toLowerCase() === fieldLower));
|
|
110
|
+
if (!element) {
|
|
111
|
+
const validNames = elements
|
|
112
|
+
.filter((el) => el.name.toLowerCase() !== 'name')
|
|
113
|
+
.map((el) => `"${el.alias || el.name}"`)
|
|
114
|
+
.join(', ');
|
|
115
|
+
throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
|
|
116
|
+
}
|
|
117
|
+
const key = `${element.name}#${element.id}:${element.type}`;
|
|
118
|
+
// Repeater — special handling (no maxSize validation)
|
|
119
|
+
const typeName = await this.typeRegistry.getNameById(element.type);
|
|
120
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
121
|
+
result[key] = await this.buildRepeaterValue(value, element, language, sectionId);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const resolved = await this.resolveValue(value, element, language, elements, context);
|
|
125
|
+
// Validate maxSize on the resolved value (what actually gets sent to the API)
|
|
126
|
+
if (element.maxSize) {
|
|
127
|
+
const resolvedStr = String(resolved ?? '');
|
|
128
|
+
if (resolvedStr.length > element.maxSize) {
|
|
129
|
+
const friendlyName = element.alias || element.name;
|
|
130
|
+
throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
result[key] = resolved;
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Builds a repeater value array from developer-friendly input.
|
|
139
|
+
* Each repeater item gets its own element key resolution using the
|
|
140
|
+
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
141
|
+
*/
|
|
142
|
+
async buildRepeaterValue(items, element, language, sectionId) {
|
|
143
|
+
const config = element.contentTypeElementConfiguration;
|
|
144
|
+
const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
|
|
145
|
+
if (!repeaterElements || repeaterElements.length === 0)
|
|
146
|
+
return items;
|
|
147
|
+
const result = [];
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const repeaterId = -Math.floor(Math.random() * 100000);
|
|
150
|
+
// Repeater items use their own repeaterId as fromContentId for SS links
|
|
151
|
+
const repeaterContext = {
|
|
152
|
+
fromSectionId: sectionId,
|
|
153
|
+
fromContentId: repeaterId,
|
|
154
|
+
};
|
|
155
|
+
const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, sectionId, repeaterContext);
|
|
156
|
+
result.push({
|
|
157
|
+
repeaterId,
|
|
158
|
+
repeaterContent: {
|
|
159
|
+
name: item.name,
|
|
160
|
+
elements,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
90
166
|
// ── List fetching ──
|
|
91
167
|
async getList(listId, language) {
|
|
92
168
|
const cached = this.listCache.get(listId);
|
package/dist/cjs/handlebars.js
CHANGED
|
@@ -25,6 +25,7 @@ class HandlebarsItem {
|
|
|
25
25
|
* Always saves with approved status.
|
|
26
26
|
*/
|
|
27
27
|
async save() {
|
|
28
|
+
(0, utils_js_1.assertRequired)(this.name, 'Name');
|
|
28
29
|
// Update elements with current name and code
|
|
29
30
|
const elements = { ...this._rawDTO.elements };
|
|
30
31
|
for (const key of Object.keys(elements)) {
|
|
@@ -502,6 +502,7 @@ class ContentItem {
|
|
|
502
502
|
* save and approve in one step.
|
|
503
503
|
*/
|
|
504
504
|
async save() {
|
|
505
|
+
(0, utils_js_1.assertRequired)(this.name, 'Content name');
|
|
505
506
|
// Start from the original raw elements (correct API format)
|
|
506
507
|
const rawElements = { ...this._rawDTO.elements };
|
|
507
508
|
// Re-resolve dirty fields through the ElementResolver so friendly values
|
|
@@ -531,7 +532,19 @@ class ContentItem {
|
|
|
531
532
|
}
|
|
532
533
|
let resolved;
|
|
533
534
|
if (templateEl && this._resolver) {
|
|
534
|
-
|
|
535
|
+
// Repeater fields need the same dedicated resolution ContentResource
|
|
536
|
+
// uses on create/update — each item's sub-fields resolved into element
|
|
537
|
+
// keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
|
|
538
|
+
// passes repeater arrays through untouched, so branch here explicitly.
|
|
539
|
+
const typeName = this._typeRegistry
|
|
540
|
+
? await this._typeRegistry.getNameById(templateEl.type)
|
|
541
|
+
: null;
|
|
542
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
543
|
+
resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
|
|
547
|
+
}
|
|
535
548
|
}
|
|
536
549
|
else {
|
|
537
550
|
resolved = value;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MediaCategoryItem = void 0;
|
|
4
|
+
const utils_js_1 = require("../utils.js");
|
|
4
5
|
/**
|
|
5
6
|
* A mutable media category object. Modify properties and call save() to persist.
|
|
6
7
|
*/
|
|
@@ -17,6 +18,7 @@ class MediaCategoryItem {
|
|
|
17
18
|
}
|
|
18
19
|
/** Persists current property values to the server via PUT. */
|
|
19
20
|
async save() {
|
|
21
|
+
(0, utils_js_1.assertRequired)(this.name, 'Media category name');
|
|
20
22
|
const updated = {
|
|
21
23
|
...this._rawData,
|
|
22
24
|
name: this.name,
|
|
@@ -80,6 +80,7 @@ class MediaItem {
|
|
|
80
80
|
* If `content` has changed on non-binary media, the version is bumped.
|
|
81
81
|
*/
|
|
82
82
|
async save() {
|
|
83
|
+
(0, utils_js_1.assertRequired)(this.name, 'Media name');
|
|
83
84
|
const categoryId = this.categories[0];
|
|
84
85
|
if (!categoryId) {
|
|
85
86
|
throw new Error('Cannot save media item — no category assigned.');
|
|
@@ -34,6 +34,7 @@ class SectionItem {
|
|
|
34
34
|
}
|
|
35
35
|
/** Persists current property values to the server via PUT. */
|
|
36
36
|
async save() {
|
|
37
|
+
(0, utils_js_1.assertRequired)(this.name, 'Section name');
|
|
37
38
|
const statusCode = utils_js_1.STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
|
|
38
39
|
const updated = {
|
|
39
40
|
...this._rawData,
|
|
@@ -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. */
|
|
@@ -3,36 +3,43 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ContentResource = void 0;
|
|
4
4
|
const utils_js_1 = require("../utils.js");
|
|
5
5
|
const content_item_js_1 = require("../models/content-item.js");
|
|
6
|
-
const
|
|
7
|
-
const type_registry_js_1 = require("../type-registry.js");
|
|
6
|
+
const content_cache_js_1 = require("../content-cache.js");
|
|
8
7
|
/**
|
|
9
8
|
* Section-scoped resource for content CRUD operations.
|
|
10
9
|
* All requests are scoped to the section ID provided at construction time.
|
|
11
10
|
*/
|
|
12
11
|
class ContentResource {
|
|
13
|
-
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
|
|
14
|
-
/** Cache of content type templates keyed by content type ID */
|
|
15
|
-
this.templateCache = new utils_js_1.TtlMap();
|
|
12
|
+
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
|
|
16
13
|
this.httpClient = httpClient;
|
|
17
14
|
this.sectionId = sectionId;
|
|
18
15
|
this.defaultLanguage = defaultLanguage;
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// A shared cache is threaded in by T4Client so the element TypeRegistry and
|
|
17
|
+
// content type templates survive across sections during a traversal. When
|
|
18
|
+
// constructed standalone (e.g. in tests), fall back to a private cache so
|
|
19
|
+
// behaviour is unchanged.
|
|
20
|
+
this.cache = cache ?? new content_cache_js_1.ContentCache(httpClient, defaultLanguage, mediaCreateFn);
|
|
21
|
+
}
|
|
22
|
+
get resolver() {
|
|
23
|
+
return this.cache.resolver;
|
|
24
|
+
}
|
|
25
|
+
get typeRegistry() {
|
|
26
|
+
return this.cache.typeRegistry;
|
|
21
27
|
}
|
|
22
28
|
async getTemplate(contentTypeId) {
|
|
23
|
-
const
|
|
29
|
+
const sectionKey = `${contentTypeId}:${this.sectionId}`;
|
|
30
|
+
const cached = this.cache.sectionTemplates.get(sectionKey);
|
|
24
31
|
if (cached)
|
|
25
32
|
return cached;
|
|
26
|
-
//
|
|
33
|
+
// The section template (channels etc.) is section-specific; the content type
|
|
34
|
+
// definition (alias, listId, repeater config) is instance-wide. Fetch each
|
|
35
|
+
// through its own shared cache so revisiting a section, or reusing a content
|
|
36
|
+
// type across sections, avoids re-fetching.
|
|
27
37
|
const [template, rawContentType] = await Promise.all([
|
|
28
38
|
this.httpClient.request({
|
|
29
39
|
method: 'GET',
|
|
30
40
|
path: `/content/type/${contentTypeId}/${this.sectionId}`,
|
|
31
41
|
}),
|
|
32
|
-
this.
|
|
33
|
-
method: 'GET',
|
|
34
|
-
path: `/contenttype/${contentTypeId}`,
|
|
35
|
-
}),
|
|
42
|
+
this.getContentTypeDefinition(contentTypeId),
|
|
36
43
|
]);
|
|
37
44
|
// Merge alias and contentTypeElementConfiguration from the full content type
|
|
38
45
|
for (const templateEl of template.contentType.contentTypeElements) {
|
|
@@ -47,79 +54,29 @@ class ContentResource {
|
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
|
-
this.
|
|
57
|
+
this.cache.sectionTemplates.set(sectionKey, template);
|
|
51
58
|
return template;
|
|
52
59
|
}
|
|
60
|
+
/** Fetches the section-independent content type definition, cached by content type ID. */
|
|
61
|
+
async getContentTypeDefinition(contentTypeId) {
|
|
62
|
+
const cached = this.cache.contentTypeDefinitions.get(contentTypeId);
|
|
63
|
+
if (cached)
|
|
64
|
+
return cached;
|
|
65
|
+
const rawContentType = await this.httpClient.request({
|
|
66
|
+
method: 'GET',
|
|
67
|
+
path: `/contenttype/${contentTypeId}`,
|
|
68
|
+
});
|
|
69
|
+
this.cache.contentTypeDefinitions.set(contentTypeId, rawContentType);
|
|
70
|
+
return rawContentType;
|
|
71
|
+
}
|
|
53
72
|
/**
|
|
54
73
|
* Builds the T4 elements map from developer-friendly field names and values.
|
|
55
74
|
* Resolves list values, dates, repeaters, etc. automatically.
|
|
56
75
|
*/
|
|
57
76
|
async buildElements(fields, elements, name, language, context) {
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
if (nameEl) {
|
|
62
|
-
result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
|
|
63
|
-
}
|
|
64
|
-
for (const [fieldName, value] of Object.entries(fields)) {
|
|
65
|
-
const fieldLower = fieldName.toLowerCase();
|
|
66
|
-
const element = elements.find((el) => el.name.toLowerCase() === fieldLower
|
|
67
|
-
|| (el.alias && el.alias.toLowerCase() === fieldLower));
|
|
68
|
-
if (!element) {
|
|
69
|
-
const validNames = elements
|
|
70
|
-
.filter((el) => el.name.toLowerCase() !== 'name')
|
|
71
|
-
.map((el) => `"${el.alias || el.name}"`)
|
|
72
|
-
.join(', ');
|
|
73
|
-
throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
|
|
74
|
-
}
|
|
75
|
-
const key = `${element.name}#${element.id}:${element.type}`;
|
|
76
|
-
// Repeater — special handling (no maxSize validation)
|
|
77
|
-
const typeName = await this.typeRegistry.getNameById(element.type);
|
|
78
|
-
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
79
|
-
result[key] = await this.buildRepeaterValue(value, element, language);
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const resolved = await this.resolver.resolveValue(value, element, language, elements, context);
|
|
83
|
-
// Validate maxSize on the resolved value (what actually gets sent to the API)
|
|
84
|
-
if (element.maxSize) {
|
|
85
|
-
const resolvedStr = String(resolved ?? '');
|
|
86
|
-
if (resolvedStr.length > element.maxSize) {
|
|
87
|
-
const friendlyName = element.alias || element.name;
|
|
88
|
-
throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
result[key] = resolved;
|
|
92
|
-
}
|
|
93
|
-
return result;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Builds repeater value array from developer-friendly input.
|
|
97
|
-
* Each repeater item gets its own element key resolution using the
|
|
98
|
-
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
99
|
-
*/
|
|
100
|
-
async buildRepeaterValue(items, element, language) {
|
|
101
|
-
const config = element.contentTypeElementConfiguration;
|
|
102
|
-
const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
|
|
103
|
-
if (!repeaterElements || repeaterElements.length === 0)
|
|
104
|
-
return items;
|
|
105
|
-
const result = [];
|
|
106
|
-
for (const item of items) {
|
|
107
|
-
const repeaterId = -Math.floor(Math.random() * 100000);
|
|
108
|
-
// Repeater items use their own repeaterId as fromContentId for SS links
|
|
109
|
-
const repeaterContext = {
|
|
110
|
-
fromSectionId: this.sectionId,
|
|
111
|
-
fromContentId: repeaterId,
|
|
112
|
-
};
|
|
113
|
-
const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, repeaterContext);
|
|
114
|
-
result.push({
|
|
115
|
-
repeaterId,
|
|
116
|
-
repeaterContent: {
|
|
117
|
-
name: item.name,
|
|
118
|
-
elements,
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
return result;
|
|
77
|
+
// Delegates to the shared implementation on ElementResolver so this path
|
|
78
|
+
// and ContentItem.save() resolve fields (repeaters included) identically.
|
|
79
|
+
return this.resolver.buildElements(fields, elements, name, language, this.sectionId, context);
|
|
123
80
|
}
|
|
124
81
|
/** Lists all content items in this section. */
|
|
125
82
|
async list(options) {
|
|
@@ -143,6 +100,7 @@ class ContentResource {
|
|
|
143
100
|
}
|
|
144
101
|
/** Creates a new content item in this section. */
|
|
145
102
|
async create(data, options) {
|
|
103
|
+
(0, utils_js_1.assertRequired)(data.name, 'Content name');
|
|
146
104
|
const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
|
|
147
105
|
const template = await this.getTemplate(data.type);
|
|
148
106
|
const contentId = -Math.floor(Math.random() * 1000000);
|
|
@@ -178,6 +136,7 @@ class ContentResource {
|
|
|
178
136
|
}
|
|
179
137
|
/** Updates an existing content item's fields. */
|
|
180
138
|
async update(id, data, options) {
|
|
139
|
+
(0, utils_js_1.assertNotEmptyIfPresent)(data.name, 'Content name');
|
|
181
140
|
const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
|
|
182
141
|
// 1. Fetch the existing content (full body needed for the POST)
|
|
183
142
|
const existing = await this.httpClient.request({
|
|
@@ -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[];
|
|
@@ -59,8 +59,8 @@ function mapContentType(raw, typeMap, editorMap) {
|
|
|
59
59
|
description: (0, utils_js_1.decodeHtmlEntities)(raw.description ?? ''),
|
|
60
60
|
minUserLevel: utils_js_1.AUTH_LEVEL_MAP[raw.minAuthLevel ?? 2] ?? `unknown (${raw.minAuthLevel})`,
|
|
61
61
|
workflow: raw.workflow ?? 0,
|
|
62
|
-
sharedGroups: (
|
|
63
|
-
primaryGroup:
|
|
62
|
+
sharedGroups: (0, utils_js_1.readSharedGroups)(raw.sharedGroups),
|
|
63
|
+
primaryGroup: (0, utils_js_1.readPrimaryGroup)(raw.primaryGroup),
|
|
64
64
|
directEdit: raw.enableDirectEdit ?? true,
|
|
65
65
|
fields: fieldsRecord,
|
|
66
66
|
},
|
|
@@ -249,6 +249,7 @@ class ContentType {
|
|
|
249
249
|
return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
|
|
250
250
|
},
|
|
251
251
|
update: async (layoutName, updateData) => {
|
|
252
|
+
(0, utils_js_1.assertNotEmptyIfPresent)(updateData.name, 'Layout name');
|
|
252
253
|
const all = await fetchLayouts();
|
|
253
254
|
const match = all.find((l) => l.name === layoutName);
|
|
254
255
|
if (!match)
|
|
@@ -289,6 +290,7 @@ class ContentType {
|
|
|
289
290
|
return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
|
|
290
291
|
},
|
|
291
292
|
create: async (createData) => {
|
|
293
|
+
(0, utils_js_1.assertRequired)(createData.name, 'Layout name');
|
|
292
294
|
// Check name uniqueness
|
|
293
295
|
const existing = await fetchLayouts();
|
|
294
296
|
if (existing.some((l) => l.name === createData.name)) {
|
|
@@ -590,6 +592,8 @@ class ContentType {
|
|
|
590
592
|
}
|
|
591
593
|
/** Persists current property values to the server via PUT. */
|
|
592
594
|
async save() {
|
|
595
|
+
(0, utils_js_1.assertRequired)(this.name, 'Content type name');
|
|
596
|
+
(0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
|
|
593
597
|
const authLevel = String(utils_js_1.AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
|
|
594
598
|
// Sync field changes back to raw contentTypeElements
|
|
595
599
|
let rawElements = (this._rawData.contentTypeElements ?? []);
|
|
@@ -657,8 +661,8 @@ class ContentType {
|
|
|
657
661
|
minAuthLevel: authLevel,
|
|
658
662
|
workflow: String(this.workflow),
|
|
659
663
|
enableDirectEdit: this.directEdit,
|
|
660
|
-
sharedGroups: this.sharedGroups
|
|
661
|
-
primaryGroup:
|
|
664
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
|
|
665
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
|
|
662
666
|
contentTypeElements: rawElements,
|
|
663
667
|
};
|
|
664
668
|
// Resolve useAsFilename → elementIdforFilename
|
|
@@ -683,6 +687,10 @@ class ContentType {
|
|
|
683
687
|
path: `/contenttype/${this.id}`,
|
|
684
688
|
body: updated,
|
|
685
689
|
});
|
|
690
|
+
// The content type definition changed, so any cached copy (content type
|
|
691
|
+
// templates/definitions, element type maps, etc.) is now stale. Invalidate
|
|
692
|
+
// all caches via the global epoch so subsequent reads re-fetch.
|
|
693
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
686
694
|
// Update raw data for next save
|
|
687
695
|
this._rawData = updated;
|
|
688
696
|
this._removedElementIds.clear();
|
|
@@ -867,6 +875,9 @@ class ContentTypeResource {
|
|
|
867
875
|
method: 'DELETE',
|
|
868
876
|
path: `/contenttype/${id}`,
|
|
869
877
|
});
|
|
878
|
+
// A deleted content type may be cached; invalidate all caches so stale
|
|
879
|
+
// definitions/templates aren't served after the delete.
|
|
880
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
870
881
|
}
|
|
871
882
|
/** Creates a new content type. */
|
|
872
883
|
async create(data) {
|
|
@@ -876,6 +887,7 @@ class ContentTypeResource {
|
|
|
876
887
|
if (!data.elements?.length) {
|
|
877
888
|
throw new Error('Content type must have at least one element');
|
|
878
889
|
}
|
|
890
|
+
(0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
|
|
879
891
|
// Validate useAsFilename constraints
|
|
880
892
|
const filenameElements = data.elements.filter((el) => el.useAsFilename);
|
|
881
893
|
if (filenameElements.length > 1) {
|
|
@@ -994,11 +1006,13 @@ class ContentTypeResource {
|
|
|
994
1006
|
warningMessage: '',
|
|
995
1007
|
elementIdforFilename: elementIdForFilename,
|
|
996
1008
|
conditionals: [],
|
|
997
|
-
sharedGroups: (
|
|
998
|
-
primaryGroup:
|
|
1009
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
|
|
1010
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
|
|
999
1011
|
contentTypeElements,
|
|
1000
1012
|
},
|
|
1001
1013
|
});
|
|
1014
|
+
// New content type may affect cached lookups; invalidate for consistency.
|
|
1015
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
1002
1016
|
const editorMap = await this.getEditorMap();
|
|
1003
1017
|
const result = mapContentType(raw, typeMap, editorMap);
|
|
1004
1018
|
await this.resolveListNames([result.data]);
|
|
@@ -43,6 +43,7 @@ class Group {
|
|
|
43
43
|
}
|
|
44
44
|
/** Persists current property values to the server via PUT. */
|
|
45
45
|
async save() {
|
|
46
|
+
(0, utils_js_1.assertRequired)(this.name, 'Group name');
|
|
46
47
|
// Start from existing raw members
|
|
47
48
|
let rawMembers = [...(this._rawData.members ?? [])];
|
|
48
49
|
// Remove members
|
|
@@ -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
|
}>;
|