@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
|
@@ -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);
|
|
@@ -531,7 +531,19 @@ class ContentItem {
|
|
|
531
531
|
}
|
|
532
532
|
let resolved;
|
|
533
533
|
if (templateEl && this._resolver) {
|
|
534
|
-
|
|
534
|
+
// Repeater fields need the same dedicated resolution ContentResource
|
|
535
|
+
// uses on create/update — each item's sub-fields resolved into element
|
|
536
|
+
// keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
|
|
537
|
+
// passes repeater arrays through untouched, so branch here explicitly.
|
|
538
|
+
const typeName = this._typeRegistry
|
|
539
|
+
? await this._typeRegistry.getNameById(templateEl.type)
|
|
540
|
+
: null;
|
|
541
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
542
|
+
resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
|
|
546
|
+
}
|
|
535
547
|
}
|
|
536
548
|
else {
|
|
537
549
|
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. */
|
|
@@ -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) {
|
|
@@ -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
|
},
|
|
@@ -590,6 +590,7 @@ class ContentType {
|
|
|
590
590
|
}
|
|
591
591
|
/** Persists current property values to the server via PUT. */
|
|
592
592
|
async save() {
|
|
593
|
+
(0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
|
|
593
594
|
const authLevel = String(utils_js_1.AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
|
|
594
595
|
// Sync field changes back to raw contentTypeElements
|
|
595
596
|
let rawElements = (this._rawData.contentTypeElements ?? []);
|
|
@@ -657,8 +658,8 @@ class ContentType {
|
|
|
657
658
|
minAuthLevel: authLevel,
|
|
658
659
|
workflow: String(this.workflow),
|
|
659
660
|
enableDirectEdit: this.directEdit,
|
|
660
|
-
sharedGroups: this.sharedGroups
|
|
661
|
-
primaryGroup:
|
|
661
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(this.sharedGroups),
|
|
662
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(this.primaryGroup),
|
|
662
663
|
contentTypeElements: rawElements,
|
|
663
664
|
};
|
|
664
665
|
// Resolve useAsFilename → elementIdforFilename
|
|
@@ -683,6 +684,10 @@ class ContentType {
|
|
|
683
684
|
path: `/contenttype/${this.id}`,
|
|
684
685
|
body: updated,
|
|
685
686
|
});
|
|
687
|
+
// The content type definition changed, so any cached copy (content type
|
|
688
|
+
// templates/definitions, element type maps, etc.) is now stale. Invalidate
|
|
689
|
+
// all caches via the global epoch so subsequent reads re-fetch.
|
|
690
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
686
691
|
// Update raw data for next save
|
|
687
692
|
this._rawData = updated;
|
|
688
693
|
this._removedElementIds.clear();
|
|
@@ -867,6 +872,9 @@ class ContentTypeResource {
|
|
|
867
872
|
method: 'DELETE',
|
|
868
873
|
path: `/contenttype/${id}`,
|
|
869
874
|
});
|
|
875
|
+
// A deleted content type may be cached; invalidate all caches so stale
|
|
876
|
+
// definitions/templates aren't served after the delete.
|
|
877
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
870
878
|
}
|
|
871
879
|
/** Creates a new content type. */
|
|
872
880
|
async create(data) {
|
|
@@ -876,6 +884,7 @@ class ContentTypeResource {
|
|
|
876
884
|
if (!data.elements?.length) {
|
|
877
885
|
throw new Error('Content type must have at least one element');
|
|
878
886
|
}
|
|
887
|
+
(0, utils_js_1.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
|
|
879
888
|
// Validate useAsFilename constraints
|
|
880
889
|
const filenameElements = data.elements.filter((el) => el.useAsFilename);
|
|
881
890
|
if (filenameElements.length > 1) {
|
|
@@ -994,11 +1003,13 @@ class ContentTypeResource {
|
|
|
994
1003
|
warningMessage: '',
|
|
995
1004
|
elementIdforFilename: elementIdForFilename,
|
|
996
1005
|
conditionals: [],
|
|
997
|
-
sharedGroups: (
|
|
998
|
-
primaryGroup:
|
|
1006
|
+
sharedGroups: (0, utils_js_1.writeSharedGroups)(data.sharedGroups),
|
|
1007
|
+
primaryGroup: (0, utils_js_1.writePrimaryGroup)(data.primaryGroup ?? 0),
|
|
999
1008
|
contentTypeElements,
|
|
1000
1009
|
},
|
|
1001
1010
|
});
|
|
1011
|
+
// New content type may affect cached lookups; invalidate for consistency.
|
|
1012
|
+
(0, utils_js_1.invalidateAllCaches)();
|
|
1002
1013
|
const editorMap = await this.getEditorMap();
|
|
1003
1014
|
const result = mapContentType(raw, typeMap, editorMap);
|
|
1004
1015
|
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
|
}>;
|
|
@@ -11,8 +11,8 @@ class List {
|
|
|
11
11
|
this.description = (0, utils_js_2.decodeHtmlEntities)(raw.description ?? '');
|
|
12
12
|
this.isForcedLanguage = raw.isForcedLanguage ?? false;
|
|
13
13
|
this.isDefaultLanguage = raw.isDefaultLanguage ?? false;
|
|
14
|
-
this.primaryGroup =
|
|
15
|
-
this.sharedGroups = (
|
|
14
|
+
this.primaryGroup = (0, utils_js_2.readPrimaryGroup)(raw.primaryGroup);
|
|
15
|
+
this.sharedGroups = (0, utils_js_2.readSharedGroups)(raw.sharedGroups);
|
|
16
16
|
this.items = {};
|
|
17
17
|
for (const item of (raw.items ?? []).sort((a, b) => a.sequence - b.sequence)) {
|
|
18
18
|
const friendlyName = (0, utils_js_2.decodeHtmlEntities)(item.name);
|
|
@@ -59,6 +59,7 @@ class List {
|
|
|
59
59
|
if (this.isForcedLanguage && this.isDefaultLanguage) {
|
|
60
60
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
61
61
|
}
|
|
62
|
+
(0, utils_js_2.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
|
|
62
63
|
const items = Object.values(this.items).map((item, i) => ({
|
|
63
64
|
id: String(item._rawId ?? 0),
|
|
64
65
|
name: item.name,
|
|
@@ -73,8 +74,8 @@ class List {
|
|
|
73
74
|
description: this.description,
|
|
74
75
|
isForcedLanguage: this.isForcedLanguage,
|
|
75
76
|
isDefaultLanguage: this.isDefaultLanguage,
|
|
76
|
-
primaryGroup:
|
|
77
|
-
sharedGroups: this.sharedGroups
|
|
77
|
+
primaryGroup: (0, utils_js_2.writePrimaryGroup)(this.primaryGroup),
|
|
78
|
+
sharedGroups: (0, utils_js_2.writeSharedGroups)(this.sharedGroups),
|
|
78
79
|
items,
|
|
79
80
|
};
|
|
80
81
|
await this._httpClient.request({
|
|
@@ -149,6 +150,7 @@ class ListResource {
|
|
|
149
150
|
if (data.isForcedLanguage && data.isDefaultLanguage) {
|
|
150
151
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
151
152
|
}
|
|
153
|
+
(0, utils_js_2.assertGroupsValid)(data.primaryGroup ?? 0, data.sharedGroups);
|
|
152
154
|
const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
|
|
153
155
|
const items = (data.items ?? []).map((item, i) => ({
|
|
154
156
|
id: '0',
|
|
@@ -167,8 +169,8 @@ class ListResource {
|
|
|
167
169
|
items,
|
|
168
170
|
isForcedLanguage: data.isForcedLanguage ?? false,
|
|
169
171
|
isDefaultLanguage: data.isDefaultLanguage ?? false,
|
|
170
|
-
sharedGroups: (
|
|
171
|
-
primaryGroup:
|
|
172
|
+
sharedGroups: (0, utils_js_2.writeSharedGroups)(data.sharedGroups),
|
|
173
|
+
primaryGroup: (0, utils_js_2.writePrimaryGroup)(data.primaryGroup ?? 0),
|
|
172
174
|
sortType: 0,
|
|
173
175
|
},
|
|
174
176
|
});
|
|
@@ -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
|
/**
|