@sitecore-content-sdk/angular 0.1.0-canary.20260609132302
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/LICENSE.txt +202 -0
- package/README.md +33 -0
- package/cli-node/config-cli/define-cli-config.d.ts +11 -0
- package/cli-node/config-cli/define-cli-config.d.ts.map +1 -0
- package/cli-node/config-cli/define-cli-config.js +68 -0
- package/cli-node/config-cli/define-cli-config.spec.d.ts +2 -0
- package/cli-node/config-cli/define-cli-config.spec.d.ts.map +1 -0
- package/cli-node/config-cli/define-cli-config.spec.js +83 -0
- package/cli-node/config-cli/index.d.ts +2 -0
- package/cli-node/config-cli/index.d.ts.map +1 -0
- package/cli-node/config-cli/index.js +5 -0
- package/cli-node/tools/generate-map.d.ts +9 -0
- package/cli-node/tools/generate-map.d.ts.map +1 -0
- package/cli-node/tools/generate-map.js +90 -0
- package/cli-node/tools/generate-map.spec.d.ts +2 -0
- package/cli-node/tools/generate-map.spec.d.ts.map +1 -0
- package/cli-node/tools/generate-map.spec.js +207 -0
- package/cli-node/tools/index.d.ts +2 -0
- package/cli-node/tools/index.d.ts.map +1 -0
- package/cli-node/tools/index.js +5 -0
- package/dist/README.md +33 -0
- package/dist/fesm2022/sitecore-content-sdk-angular.mjs +2912 -0
- package/dist/fesm2022/sitecore-content-sdk-angular.mjs.map +1 -0
- package/dist/types/sitecore-content-sdk-angular.d.ts +1446 -0
- package/dist/types/sitecore-content-sdk-angular.d.ts.map +1 -0
- package/package.json +113 -0
|
@@ -0,0 +1,2912 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, PLATFORM_ID, Injectable, makeStateKey, TransferState, REQUEST, DestroyRef, makeEnvironmentProviders, REQUEST_CONTEXT, computed, input, Component, Injector, viewChild, ViewContainerRef, effect, isDevMode, afterNextRender, ViewChild, ElementRef, Renderer2, Directive, HostListener, SecurityContext, VERSION } from '@angular/core';
|
|
3
|
+
import { defineConfig as defineConfig$1 } from '@sitecore-content-sdk/content/config';
|
|
4
|
+
import { isDynamicPlaceholder, getDynamicPlaceholderPattern, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout';
|
|
5
|
+
export { EditMode, LayoutService, LayoutServicePageState, getChildPlaceholder, getContentStylesheetLink, getDesignLibraryStylesheetLinks, getFieldValue } from '@sitecore-content-sdk/content/layout';
|
|
6
|
+
export { isEditorActive, resetEditorChromes } from '@sitecore-content-sdk/content/editing';
|
|
7
|
+
export { DefaultRetryStrategy, ErrorPage, GraphQLRequestClient, SitecoreClient } from '@sitecore-content-sdk/content/client';
|
|
8
|
+
import { getLocaleRewrite } from '@sitecore-content-sdk/content/i18n';
|
|
9
|
+
export { getLocaleRewrite } from '@sitecore-content-sdk/content/i18n';
|
|
10
|
+
import { mediaApi } from '@sitecore-content-sdk/content/media';
|
|
11
|
+
export { mediaApi } from '@sitecore-content-sdk/content/media';
|
|
12
|
+
export { SitePathService } from '@sitecore-content-sdk/content/site';
|
|
13
|
+
export { ClientError, MemoryCacheClient, NativeDataFetcher, constants, enableDebug } from '@sitecore-content-sdk/core';
|
|
14
|
+
import { RedirectCommand, Router, ActivationStart, NavigationEnd, DefaultUrlSerializer } from '@angular/router';
|
|
15
|
+
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
|
16
|
+
import { firstValueFrom, filter, map, startWith, of } from 'rxjs';
|
|
17
|
+
import { HttpClient } from '@angular/common/http';
|
|
18
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
19
|
+
import { createStorage } from 'unstorage';
|
|
20
|
+
import memoryDriver from 'unstorage/drivers/memory';
|
|
21
|
+
import { HIDDEN_RENDERING_NAME, form } from '@sitecore-content-sdk/content';
|
|
22
|
+
import { DomSanitizer } from '@angular/platform-browser';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Injection token for the Sitecore configuration.
|
|
26
|
+
* Provided by `provideSitecoreAngular({ sitecoreConfig, sitecoreClient })`. Inject this to read config app-wide.
|
|
27
|
+
* `AngularSitecoreConfig` extends `SitecoreConfig`, so consumers that previously typed the
|
|
28
|
+
* value as `SitecoreConfig` remain structurally compatible.
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
const SITECORE_CONFIG_TOKEN = new InjectionToken('SITECORE_CONFIG_TOKEN');
|
|
32
|
+
/**
|
|
33
|
+
* Injection token for the SitecoreClient instance.
|
|
34
|
+
* Provided by `provideSitecoreAngular({ sitecoreConfig, sitecoreClient })` with the app-supplied client instance.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
const SITECORE_CLIENT_TOKEN = new InjectionToken('SITECORE_CLIENT_TOKEN');
|
|
38
|
+
const NOT_FOUND_ROUTE_TOKEN = new InjectionToken('NOT_FOUND_ROUTE_TOKEN');
|
|
39
|
+
/**
|
|
40
|
+
* Injection token for the error route.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const ERROR_ROUTE_TOKEN = new InjectionToken('ERROR_ROUTE_TOKEN');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolves layout/page data for a route path using a {@link SitecoreClient} and Sitecore config.
|
|
47
|
+
* Import your `sitecore.config` default and shared client (e.g. `getClient()`) from the app;
|
|
48
|
+
* this stays usable from route loaders without Angular injection context.
|
|
49
|
+
*
|
|
50
|
+
* Future: add helpers for personalization and multisite alongside this call.
|
|
51
|
+
* @param {string} path - Route path (e.g. `'/'` or `'/about'`).
|
|
52
|
+
* @param {AngularSitecoreConfig} sitecoreConfig - Resolved Sitecore configuration (e.g. default export from `sitecore.config.ts`).
|
|
53
|
+
* @param {SitecoreClient} client - Sitecore client instance (e.g. from a module singleton).
|
|
54
|
+
* @param {{ locale?: string; site?: string }} [options] - Optional `locale` / `site` overrides.
|
|
55
|
+
* @param {string} [options.locale] - Optional locale override.
|
|
56
|
+
* @param {string} [options.site] - Optional site override.
|
|
57
|
+
* @returns {Promise<Page | null>} Page layout data, or `null` if not found.
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
async function resolveSitecorePage(path, sitecoreConfig, client, options) {
|
|
61
|
+
const pageOptions = {};
|
|
62
|
+
if (options?.locale) {
|
|
63
|
+
pageOptions.locale = options.locale || sitecoreConfig.defaultLanguage;
|
|
64
|
+
}
|
|
65
|
+
if (options?.site) {
|
|
66
|
+
pageOptions.site = options.site || sitecoreConfig.defaultSite;
|
|
67
|
+
}
|
|
68
|
+
return client.getPage(path, pageOptions);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads `process.env` when running under Node; otherwise returns an empty object.
|
|
73
|
+
* @returns {Record<string, string | undefined>} Environment map for merging into config.
|
|
74
|
+
*/
|
|
75
|
+
function getProcessEnv() {
|
|
76
|
+
// Use globalThis so we do not need @types/node (lib tsconfig uses "types": []).
|
|
77
|
+
const env = globalThis.process
|
|
78
|
+
?.env;
|
|
79
|
+
return env ? env : {};
|
|
80
|
+
}
|
|
81
|
+
/** Defaults applied to `angular.loadersCache` when input omits fields. */
|
|
82
|
+
const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 };
|
|
83
|
+
/**
|
|
84
|
+
* Ensures `defaultLanguage` is present in the locales list (prepended when missing) and
|
|
85
|
+
* returns an empty-input fallback of `[defaultLanguage]`.
|
|
86
|
+
* @param {string[]} input - Locales from `sitecore.config` (may be empty).
|
|
87
|
+
* @param {string} defaultLanguage - Resolved `defaultLanguage` from the base config.
|
|
88
|
+
* @returns {string[]} Locales with `defaultLanguage` guaranteed.
|
|
89
|
+
*/
|
|
90
|
+
function resolveLocales(input, defaultLanguage) {
|
|
91
|
+
if (!input || input.length === 0) {
|
|
92
|
+
return [defaultLanguage];
|
|
93
|
+
}
|
|
94
|
+
if (input.includes(defaultLanguage)) {
|
|
95
|
+
return [...input];
|
|
96
|
+
}
|
|
97
|
+
return [defaultLanguage, ...input];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Merges `clientEnv` (browser-safe `environment*.ts`) with `process.env` for server-only variables,
|
|
101
|
+
* then delegates to the base content `defineConfig` and adds the Angular-specific config layer.
|
|
102
|
+
*
|
|
103
|
+
* - `angular.locales` is the single source of truth for the locale list; `defaultLanguage` is
|
|
104
|
+
* added when missing.
|
|
105
|
+
* - `redirects.locales` is overwritten from `angular.locales` so the redirects proxy stays in sync.
|
|
106
|
+
*
|
|
107
|
+
* On Node/SSR, load `.env` in the app entry before importing `sitecore.config` (see
|
|
108
|
+
* `load-env.ts` in the sample).
|
|
109
|
+
* @param {AngularSitecoreConfigInput} [config] - Base Sitecore configuration input.
|
|
110
|
+
* @param {Record<string, string | undefined>} [clientEnv] - Browser-safe env from `environment*.ts`.
|
|
111
|
+
* @returns {AngularSitecoreConfig} Fully merged Sitecore configuration for Angular.
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
function defineConfig(config = {}, clientEnv = {}) {
|
|
115
|
+
const { angular, ...baseInput } = config;
|
|
116
|
+
const scConfig = defineConfig$1(baseInput, {
|
|
117
|
+
...clientEnv,
|
|
118
|
+
...getProcessEnv(),
|
|
119
|
+
});
|
|
120
|
+
const locales = resolveLocales(angular?.locales ?? [], scConfig.defaultLanguage);
|
|
121
|
+
scConfig.redirects.locales = locales;
|
|
122
|
+
const loadersCache = {
|
|
123
|
+
enabled: angular?.loadersCache?.enabled ?? DEFAULT_ISR_CACHE.enabled,
|
|
124
|
+
revalidate: angular?.loadersCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate,
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
...scConfig,
|
|
128
|
+
angular: { locales, loadersCache },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Optional endpoint path for loader data fetch (e.g. '/_data' or '/api/data').
|
|
134
|
+
* When null or undefined, LOADER_DATA_ENDPOINT is used.
|
|
135
|
+
* @public
|
|
136
|
+
*/
|
|
137
|
+
const FETCH_DATA_ENDPOINT = new InjectionToken('FETCH_DATA_ENDPOINT');
|
|
138
|
+
const LOADER_REGISTRY = new InjectionToken('LOADER_REGISTRY');
|
|
139
|
+
/**
|
|
140
|
+
* Registers the app's loader registry for DI. Pass the loaders your app uses
|
|
141
|
+
* (e.g. page, '404', '500'). Use the **same object** with
|
|
142
|
+
*createLoaderDataServiceMiddleware in `server.ts` so SSR and CSR
|
|
143
|
+
* navigations resolve the same loader functions.
|
|
144
|
+
* @param {LoaderRegistry} loaders - Map of loader id to loader function
|
|
145
|
+
* @public
|
|
146
|
+
*/
|
|
147
|
+
const provideLoaderRegistry = (loaders) => {
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
provide: LOADER_REGISTRY,
|
|
151
|
+
useValue: { ...loaders },
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Symbol used to tag resolver functions with their loader ID.
|
|
157
|
+
* This allows the prefetch service to identify loader resolvers in the route tree.
|
|
158
|
+
* @internal
|
|
159
|
+
*/
|
|
160
|
+
const LOADER_ID = Symbol('loaderId');
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Default path for the data endpoint used by loaders.
|
|
164
|
+
* This path will be requested by the client-side loader resolver to fetch data for the current route.
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
const LOADER_DATA_ENDPOINT = '/_data';
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Staging key for prefetched loader responses (browser-only, consume-once).
|
|
171
|
+
* @param {string} loaderId - Loader identifier
|
|
172
|
+
* @param {string} url - Request URL
|
|
173
|
+
* @returns Staging key string
|
|
174
|
+
*/
|
|
175
|
+
function requestKey(loaderId, url) {
|
|
176
|
+
return `loader:${loaderId}:${url}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Loader data client for browser loader data resolution. POSTs to the `/_data` endpoint and holds
|
|
180
|
+
* short-lived prefetched responses for parallel navigation prefetching.
|
|
181
|
+
* Not aware of the server-side {@link LoaderCache}.
|
|
182
|
+
* @public
|
|
183
|
+
*/
|
|
184
|
+
class ClientLoaderDataService {
|
|
185
|
+
prefetchedResponses = new Map();
|
|
186
|
+
pending = new Map();
|
|
187
|
+
http = inject(HttpClient);
|
|
188
|
+
platformId = inject(PLATFORM_ID);
|
|
189
|
+
fetchDataEndpoint = inject(FETCH_DATA_ENDPOINT, { optional: true }) ?? LOADER_DATA_ENDPOINT;
|
|
190
|
+
/**
|
|
191
|
+
* Prefetch loader data for the given request without consuming staged responses.
|
|
192
|
+
* If a response is already staged or a request is pending, does nothing.
|
|
193
|
+
* Otherwise starts a fetch and stores the result for a later getData() call.
|
|
194
|
+
* Used by PreLoaderDataService to warm responses for all loaders in a route in parallel.
|
|
195
|
+
* @param {LoaderDataRequest} loaderRequest - The loader data request
|
|
196
|
+
*/
|
|
197
|
+
prefetch(loaderRequest) {
|
|
198
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const key = requestKey(loaderRequest.loaderId, loaderRequest.url);
|
|
202
|
+
if (this.prefetchedResponses.has(key) || this.pending.has(key)) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const promise = this.fetchData(loaderRequest);
|
|
206
|
+
this.pending.set(key, promise);
|
|
207
|
+
promise.then(() => {
|
|
208
|
+
// Result is already stored in prefetchedResponses by fetchData
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Get data for the given request, using staged prefetched responses or fetching if needed.
|
|
213
|
+
* If a request is already pending for this URL/loader combination,
|
|
214
|
+
* waits for it to complete instead of making a duplicate request.
|
|
215
|
+
* Consumes (removes) staged responses after retrieval.
|
|
216
|
+
* @param {LoaderDataRequest} request - The loader data request
|
|
217
|
+
* @returns {Promise<LoaderApiResponse>} Promise resolving to the API response
|
|
218
|
+
*/
|
|
219
|
+
async getData(request) {
|
|
220
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: 'error',
|
|
223
|
+
status: 500,
|
|
224
|
+
message: 'ClientLoaderDataService only works in browser',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const key = requestKey(request.loaderId, request.url);
|
|
228
|
+
const staged = this.prefetchedResponses.get(key);
|
|
229
|
+
if (staged !== undefined) {
|
|
230
|
+
this.prefetchedResponses.delete(key);
|
|
231
|
+
return staged;
|
|
232
|
+
}
|
|
233
|
+
// Wait for pending loader data request if one exists
|
|
234
|
+
const pendingRequest = this.pending.get(key);
|
|
235
|
+
if (pendingRequest) {
|
|
236
|
+
return pendingRequest;
|
|
237
|
+
}
|
|
238
|
+
// Make new request; add to pending so concurrent callers reuse the same promise
|
|
239
|
+
const pendingFetchData = this.fetchData(request);
|
|
240
|
+
this.pending.set(key, pendingFetchData);
|
|
241
|
+
return pendingFetchData;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Fetch data from the configured data endpoint.
|
|
245
|
+
* Callers (getData, prefetch) add the returned promise to pending; it is removed
|
|
246
|
+
* in finally when the promise settles.
|
|
247
|
+
* @param {LoaderDataRequest} request - The loader data request
|
|
248
|
+
* @returns {Promise<LoaderApiResponse>} Promise resolving to the API response
|
|
249
|
+
*/
|
|
250
|
+
async fetchData(request) {
|
|
251
|
+
const key = requestKey(request.loaderId, request.url);
|
|
252
|
+
const endpoint = this.fetchDataEndpoint;
|
|
253
|
+
const reqBody = {
|
|
254
|
+
loaderId: request.loaderId,
|
|
255
|
+
url: request.url,
|
|
256
|
+
params: request.params ?? {},
|
|
257
|
+
query: request.query ?? {},
|
|
258
|
+
cacheOptions: request.cacheOptions,
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
const resp = await firstValueFrom(this.http.post(endpoint, reqBody, { cache: 'no-store' }));
|
|
262
|
+
if (!resp) {
|
|
263
|
+
const message = `No response from ${endpoint}`;
|
|
264
|
+
return { kind: 'error', status: 500, message };
|
|
265
|
+
}
|
|
266
|
+
if (resp.kind === 'data') {
|
|
267
|
+
this.prefetchedResponses.set(key, resp);
|
|
268
|
+
}
|
|
269
|
+
else if (resp.kind === 'redirect') {
|
|
270
|
+
this.prefetchedResponses.set(key, resp);
|
|
271
|
+
}
|
|
272
|
+
return resp;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
const message = error instanceof Error ? error.message : 'Fetch failed';
|
|
276
|
+
return { kind: 'error', status: 500, message };
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
this.pending.delete(key);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
static ɵfac = function ClientLoaderDataService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ClientLoaderDataService)(); };
|
|
283
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: ClientLoaderDataService, factory: ClientLoaderDataService.ɵfac, providedIn: 'root' });
|
|
284
|
+
}
|
|
285
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ClientLoaderDataService, [{
|
|
286
|
+
type: Injectable,
|
|
287
|
+
args: [{
|
|
288
|
+
providedIn: 'root',
|
|
289
|
+
}]
|
|
290
|
+
}], null, null); })();
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Apply a redirect: internal URLs → RedirectCommand; external URLs → full page navigation.
|
|
294
|
+
* Use in resolvers and in the navigation error handler (fallback) so redirect behavior is consistent.
|
|
295
|
+
* Redirects are not errors; this helper is the single place that defines how to perform them.
|
|
296
|
+
* @param {Router} router - Angular Router (for internal redirects)
|
|
297
|
+
* @param {string} location - Target URL (path or full URL)
|
|
298
|
+
* @returns RedirectCommand for internal, void after window.location.assign for external
|
|
299
|
+
* @public
|
|
300
|
+
*/
|
|
301
|
+
function applyRedirect(router, location) {
|
|
302
|
+
// TODO: implement server-side redirect with custom status code when implementing SXA redirects proxy
|
|
303
|
+
const isExternal = /^https?:\/\//i.test(location);
|
|
304
|
+
if (isExternal) {
|
|
305
|
+
if (typeof window !== 'undefined') {
|
|
306
|
+
window.location.assign(location);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
return new RedirectCommand(router.parseUrl(location), {});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Parse query parameters from URL
|
|
314
|
+
* @param {string} url - The URL to parse
|
|
315
|
+
* @returns {Record<string, string | string[] | undefined>} The query parameters
|
|
316
|
+
*/
|
|
317
|
+
function parseQueryFromUrl(url) {
|
|
318
|
+
try {
|
|
319
|
+
const urlObj = new URL(url);
|
|
320
|
+
const query = {};
|
|
321
|
+
urlObj.searchParams.forEach((value, key) => {
|
|
322
|
+
const existing = query[key];
|
|
323
|
+
if (existing) {
|
|
324
|
+
if (Array.isArray(existing)) {
|
|
325
|
+
existing.push(value);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
query[key] = [existing, value];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
query[key] = value;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
return query;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return {};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Convert Headers object to plain object
|
|
343
|
+
* @param {Headers} headers - The Headers object to convert
|
|
344
|
+
* @returns {Record<string, string | string[] | undefined>} The headers
|
|
345
|
+
*/
|
|
346
|
+
function headersToObject(headers) {
|
|
347
|
+
const result = {};
|
|
348
|
+
headers.forEach((value, key) => {
|
|
349
|
+
result[key.toLowerCase()] = value;
|
|
350
|
+
});
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Parse cookies from a cookie header string
|
|
355
|
+
* @param {string | null} cookieHeader - The cookie header string to parse
|
|
356
|
+
* @returns {Record<string, string>} The cookies
|
|
357
|
+
*/
|
|
358
|
+
function parseCookieHeader(cookieHeader) {
|
|
359
|
+
if (!cookieHeader)
|
|
360
|
+
return {};
|
|
361
|
+
const cookies = {};
|
|
362
|
+
cookieHeader.split(';').forEach((cookie) => {
|
|
363
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
364
|
+
if (name) {
|
|
365
|
+
cookies[name] = rest.join('=');
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
return cookies;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Extracts request context from a request object.
|
|
372
|
+
* Supports both Fetch API Request objects (from Angular's REQUEST token) and Express-like request objects.
|
|
373
|
+
* @param {Request | ExpressLikeRequest} req - The request object (Fetch API Request or Express-like object)
|
|
374
|
+
* @returns {RequestContext} The request context
|
|
375
|
+
* @example
|
|
376
|
+
* ```typescript
|
|
377
|
+
* import { extractRequestContext } from '@sitecore-content-sdk/angular/server';
|
|
378
|
+
*
|
|
379
|
+
* // From Express request
|
|
380
|
+
* const requestContext = extractRequestContext(expressReq);
|
|
381
|
+
*
|
|
382
|
+
* // From Fetch API Request (Angular's REQUEST token)
|
|
383
|
+
* const requestContext = extractRequestContext(request);
|
|
384
|
+
* ```
|
|
385
|
+
* @public
|
|
386
|
+
*/
|
|
387
|
+
function extractRequestContext(req) {
|
|
388
|
+
// Check if it's a Fetch API Request object
|
|
389
|
+
if (req instanceof Request) {
|
|
390
|
+
const headers = headersToObject(req.headers);
|
|
391
|
+
const cookies = parseCookieHeader(req.headers.get('cookie'));
|
|
392
|
+
const query = parseQueryFromUrl(req.url);
|
|
393
|
+
// Extract hostname from URL
|
|
394
|
+
let hostname;
|
|
395
|
+
try {
|
|
396
|
+
hostname = new URL(req.url).hostname;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// URL parsing failed, hostname will be resolved from headers
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
hostname,
|
|
403
|
+
headers,
|
|
404
|
+
cookies,
|
|
405
|
+
query,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const hostHeader = req.headers?.host;
|
|
409
|
+
const hostname = pickHostnameFromHostHeader(Array.isArray(hostHeader) ? hostHeader[0] : hostHeader);
|
|
410
|
+
return {
|
|
411
|
+
hostname,
|
|
412
|
+
headers: req.headers,
|
|
413
|
+
cookies: req.cookies,
|
|
414
|
+
query: req.query,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Pick the hostname from the host header
|
|
419
|
+
* @param {string | undefined} host - The host header
|
|
420
|
+
* @returns {string | undefined} The hostname
|
|
421
|
+
*/
|
|
422
|
+
function pickHostnameFromHostHeader(host) {
|
|
423
|
+
if (!host)
|
|
424
|
+
return undefined;
|
|
425
|
+
const colon = host.indexOf(':');
|
|
426
|
+
return colon === -1 ? host : host.slice(0, colon);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const DEFAULT_NOT_FOUND_ROUTE = '/404';
|
|
430
|
+
const DEFAULT_ERROR_ROUTE = '/500';
|
|
431
|
+
/**
|
|
432
|
+
* Type guard for redirect results returned by loaders.
|
|
433
|
+
* @param {unknown} v - Value to check
|
|
434
|
+
* @internal
|
|
435
|
+
*/
|
|
436
|
+
function isLoaderRedirectResult(v) {
|
|
437
|
+
return (typeof v === 'object' &&
|
|
438
|
+
v !== null &&
|
|
439
|
+
'loaderRedirectTarget' in v &&
|
|
440
|
+
typeof v.loaderRedirectTarget === 'string');
|
|
441
|
+
}
|
|
442
|
+
class NotFoundNavigationError extends Error {
|
|
443
|
+
constructor(message = 'Not Found') {
|
|
444
|
+
super(message);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
class LoaderHttpError extends Error {
|
|
448
|
+
status;
|
|
449
|
+
constructor(status, message = 'Content SDK Loader Error') {
|
|
450
|
+
super(message);
|
|
451
|
+
this.status = status;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Parses a normalized pathname into its first segment, remainder path, and query/fragment suffix.
|
|
457
|
+
* Groups: (1) first segment, (2) rest of path including leading slash, (3) ?query and/or #fragment.
|
|
458
|
+
*/
|
|
459
|
+
const PATH_PARTS_REGEX = /^\/([^/?#]*)(\/[^?#]*)?([?#].*)?$/;
|
|
460
|
+
/**
|
|
461
|
+
* Extracts a configured locale from the first segment of a URL pathname.
|
|
462
|
+
* Returns `{ locale: null, nonLocalePath: pathname, queryFragment: query or fragment string }` when the first segment is not a configured locale.
|
|
463
|
+
* @param {string} pathname - URL pathname, with or without leading `/`.
|
|
464
|
+
* @param {string[]} locales - Configured locales.
|
|
465
|
+
* @returns {LocaleExtractionResult} Detected locale and the rest of the path.
|
|
466
|
+
* @public
|
|
467
|
+
*/
|
|
468
|
+
function splitLocaleFromPath(pathname, locales) {
|
|
469
|
+
if (!pathname) {
|
|
470
|
+
return { locale: null, nonLocalePath: '/' };
|
|
471
|
+
}
|
|
472
|
+
const normalized = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
473
|
+
const [, firstSegment = '', restPath = '', queryFragment = undefined] = normalized.match(PATH_PARTS_REGEX) ?? [];
|
|
474
|
+
if (firstSegment && locales.includes(firstSegment)) {
|
|
475
|
+
return { locale: firstSegment, nonLocalePath: restPath || '/', queryFragment };
|
|
476
|
+
}
|
|
477
|
+
return { locale: null, nonLocalePath: `/${firstSegment}${restPath}`, queryFragment };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Creates an Angular {@link UrlMatcher} that consumes a configured-locale segment from
|
|
481
|
+
* the start of a route. When the first URL segment matches one of scConfig's `locales`, it is consumed
|
|
482
|
+
* and exposed as the `locale` route param. Otherwise zero segments are consumed and the
|
|
483
|
+
* route still matches (so error routes and the catchall handle both prefixed and unprefixed
|
|
484
|
+
* URLs from the same route tree).
|
|
485
|
+
* @param {string[]} locales - Configured locales.
|
|
486
|
+
* @returns {UrlMatcher} Angular URL matcher for locale-prefixed route trees.
|
|
487
|
+
* @public
|
|
488
|
+
*/
|
|
489
|
+
function scLocaleMatcher(locales) {
|
|
490
|
+
return (segments) => {
|
|
491
|
+
if (segments.length > 0 && locales.includes(segments[0].path)) {
|
|
492
|
+
return { consumed: [segments[0]], posParams: { locale: segments[0] } };
|
|
493
|
+
}
|
|
494
|
+
return { consumed: [] };
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Resolves the initial URL pathname from the current execution environment.
|
|
499
|
+
* Returns `'/'` when neither REQUEST nor `window.location` is available.
|
|
500
|
+
* @param {Request | null} req - SSR REQUEST token value, when present.
|
|
501
|
+
* @param {boolean} isBrowser - Whether the current platform is the browser.
|
|
502
|
+
* @returns {string} URL pathname suitable for locale extraction.
|
|
503
|
+
*/
|
|
504
|
+
function resolveCurrentPath(req, isBrowser) {
|
|
505
|
+
if (req) {
|
|
506
|
+
try {
|
|
507
|
+
return new URL(req.url).pathname;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// fall through to browser/default
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (isBrowser && typeof window !== 'undefined' && window.location) {
|
|
514
|
+
return window.location.pathname;
|
|
515
|
+
}
|
|
516
|
+
return '/';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Normalizes a URL path (strip leading slash and query) for comparison.
|
|
521
|
+
* @param {string} url - URL or path string
|
|
522
|
+
* @returns Normalized path segment
|
|
523
|
+
*/
|
|
524
|
+
function normalizePath(url) {
|
|
525
|
+
return url.replace(/^\//, '').split('?')[0];
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Resolves a navigation error to a RedirectCommand or void.
|
|
529
|
+
* Handles loader exceptions (NotFoundNavigationError and other errors) and prevents redirect loops
|
|
530
|
+
* when the failed navigation was already to the not-found route or the error route.
|
|
531
|
+
* Must be called from an injection context (uses NOT_FOUND_ROUTE_TOKEN, ERROR_ROUTE_TOKEN, Router).
|
|
532
|
+
*
|
|
533
|
+
* **HTTP status codes (SSR):** RedirectCommand only triggers navigation to the not-found or error
|
|
534
|
+
* route; it does not set the HTTP response status. To return 404 or 500 when those pages are
|
|
535
|
+
* rendered on the server, configure your app so the server sends the correct status. For example,
|
|
536
|
+
* in `app.routes.server.ts` add ServerRoute entries for your not-found and error paths with
|
|
537
|
+
* `status: 404` and `status: 500` (see Angular "Setting headers and status codes" in the SSR guide).
|
|
538
|
+
* Alternatively, inject `RESPONSE_INIT` in your NotFoundComponent and ErrorComponent and set the
|
|
539
|
+
* status when running on the server.
|
|
540
|
+
* @param {Error} err - The error from the navigation (e.g. NotFoundNavigationError or LoaderHttpError)
|
|
541
|
+
* @param {string} failedUrl - URL that failed to load
|
|
542
|
+
* @param {string} notFoundRoute - Path for the not-found page (e.g. '/404')
|
|
543
|
+
* @param {string} errorRoute - Path for the error page (e.g. '/500')
|
|
544
|
+
* @param {Router} router - Angular Router instance
|
|
545
|
+
* @returns RedirectCommand to redirect, or void to cancel and avoid a loop
|
|
546
|
+
* @public
|
|
547
|
+
*/
|
|
548
|
+
function redirectOnNavigationError(err, failedUrl, notFoundRoute, errorRoute, router) {
|
|
549
|
+
console.log('Navigation error occurred on url: ' + failedUrl, err.message);
|
|
550
|
+
const kind = err instanceof NotFoundNavigationError ? 'notFound' : 'error';
|
|
551
|
+
const failedPath = normalizePath(failedUrl);
|
|
552
|
+
const notFoundPath = normalizePath(notFoundRoute);
|
|
553
|
+
const errorPath = normalizePath(errorRoute);
|
|
554
|
+
if (kind === 'notFound') {
|
|
555
|
+
if (failedPath === notFoundPath) {
|
|
556
|
+
console.log('RouteErrorHandler: Not found route was not found. Avoiding redirect loop.');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const urlTree = router.parseUrl(notFoundRoute);
|
|
560
|
+
return new RedirectCommand(urlTree);
|
|
561
|
+
}
|
|
562
|
+
// kind === 'error'
|
|
563
|
+
if (failedPath === errorPath) {
|
|
564
|
+
console.log('RouteErrorHandler: Error route threw its own error. Avoiding redirect loop.');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const urlTree = router.parseUrl(errorRoute);
|
|
568
|
+
return new RedirectCommand(urlTree);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Returns a navigation error handler for use with withNavigationErrorHandler.
|
|
572
|
+
* Delegates to {@link redirectOnNavigationError}.
|
|
573
|
+
* @returns A handler compatible with `provideRouter(routes, withNavigationErrorHandler(...))`
|
|
574
|
+
* @public
|
|
575
|
+
*/
|
|
576
|
+
function handleNavigationError() {
|
|
577
|
+
return (e) => {
|
|
578
|
+
const err = e?.error ?? e;
|
|
579
|
+
const failedUrl = e.url ?? '';
|
|
580
|
+
const notFoundRoute = inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE;
|
|
581
|
+
const errorRoute = inject(ERROR_ROUTE_TOKEN, { optional: true }) || DEFAULT_ERROR_ROUTE;
|
|
582
|
+
const locales = inject(SITECORE_CONFIG_TOKEN, { optional: true })?.angular?.locales ?? [];
|
|
583
|
+
const router = inject(Router);
|
|
584
|
+
const { locale } = splitLocaleFromPath(failedUrl, locales);
|
|
585
|
+
const targetNotFound = locale ? `/${locale}${notFoundRoute}` : notFoundRoute;
|
|
586
|
+
const targetError = locale ? `/${locale}${errorRoute}` : errorRoute;
|
|
587
|
+
return redirectOnNavigationError(err, failedUrl, targetNotFound, targetError, router);
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Injection token for SSR loader data resolution.
|
|
593
|
+
* Must be provided via `provideServerLoaderRunner` in server application config.
|
|
594
|
+
* @public
|
|
595
|
+
*/
|
|
596
|
+
const SERVER_LOADER_RUNNER = new InjectionToken('SERVER_LOADER_RUNNER');
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Create a state key for the loader
|
|
600
|
+
* @param {string} loaderId - The loader ID
|
|
601
|
+
* @param {string} url - The URL
|
|
602
|
+
* @returns {StateKey} The state key
|
|
603
|
+
*/
|
|
604
|
+
function stateKey(loaderId, url) {
|
|
605
|
+
return makeStateKey(`loader:${loaderId}:${url}`);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Merges params from all ancestor route segments and defaults `locale` from the resolved
|
|
609
|
+
* Sitecore config when missing. Loaders always see a concrete `params.locale` whether or
|
|
610
|
+
* not the locale matcher captured one from the URL.
|
|
611
|
+
* @param {ActivatedRouteSnapshot} route - The current route snapshot.
|
|
612
|
+
* @param {string} [defaultLanguage] - Default language to fall back to.
|
|
613
|
+
* @returns {Params} Merged params with a guaranteed `locale` when `defaultLanguage` is set.
|
|
614
|
+
*/
|
|
615
|
+
function buildLoaderParams(route, defaultLanguage) {
|
|
616
|
+
const merged = route.pathFromRoot.reduce((acc, r) => ({ ...acc, ...r.params }), {});
|
|
617
|
+
if (!merged.locale && defaultLanguage) {
|
|
618
|
+
merged.locale = defaultLanguage;
|
|
619
|
+
}
|
|
620
|
+
return merged;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Browser-only: load data from transfer state or ClientLoaderDataService.
|
|
624
|
+
* Injects TransferState, ClientLoaderDataService. Called by the resolver when isPlatformBrowser.
|
|
625
|
+
* @param {object} options - The options for the resolveOnBrowser function
|
|
626
|
+
* @param {ActivatedRouteSnapshot} options.route - The current route snapshot
|
|
627
|
+
* @param {RouterStateSnapshot} options.state - The router state snapshot
|
|
628
|
+
* @param {string} options.loaderId - loader ID to resolve, used for transfer state key and ClientLoaderDataService call
|
|
629
|
+
* @param {Router} options.router - The Angular router instance
|
|
630
|
+
* @param {string} [options.defaultLanguage] - Default language for locale fallback in params
|
|
631
|
+
* @param {LoaderCacheConfig} [options.cacheOptions] - Cache options for the loader
|
|
632
|
+
* @returns {Promise<unknown | RedirectCommand>} The resolved data or redirect command
|
|
633
|
+
*/
|
|
634
|
+
async function resolveOnBrowser({ route, state, loaderId, router, defaultLanguage, cacheOptions, }) {
|
|
635
|
+
const transferState = inject(TransferState);
|
|
636
|
+
const browserLoaderData = inject(ClientLoaderDataService);
|
|
637
|
+
const url = state.url;
|
|
638
|
+
const key = stateKey(loaderId, url);
|
|
639
|
+
if (transferState.hasKey(key)) {
|
|
640
|
+
const data = transferState.get(key, null);
|
|
641
|
+
transferState.remove(key);
|
|
642
|
+
return data;
|
|
643
|
+
}
|
|
644
|
+
const allParams = buildLoaderParams(route, defaultLanguage);
|
|
645
|
+
const resp = await browserLoaderData.getData({
|
|
646
|
+
url,
|
|
647
|
+
loaderId,
|
|
648
|
+
params: allParams,
|
|
649
|
+
query: route.queryParams,
|
|
650
|
+
cacheOptions,
|
|
651
|
+
});
|
|
652
|
+
if (resp.kind === 'error') {
|
|
653
|
+
throw new LoaderHttpError(resp.status, resp.message);
|
|
654
|
+
}
|
|
655
|
+
if (resp.kind === 'notFound') {
|
|
656
|
+
throw new NotFoundNavigationError();
|
|
657
|
+
}
|
|
658
|
+
if (resp.kind === 'redirect') {
|
|
659
|
+
return applyRedirect(router, resp.redirect.loaderRedirectTarget);
|
|
660
|
+
}
|
|
661
|
+
return resp.data;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Create a loader resolver function that resolver loader data with optional cache options on server or browser.
|
|
665
|
+
* @param {LoaderId} loaderId - The loader ID
|
|
666
|
+
* @param {PerRouteLoaderCacheConfig} [cacheOptions] - The cache options
|
|
667
|
+
* @returns {ResolveFn<unknown>} loader resolver function
|
|
668
|
+
*/
|
|
669
|
+
const loaderResolver = (loaderId, cacheOptions) => {
|
|
670
|
+
const resolver = async (route, state) => {
|
|
671
|
+
const transferState = inject(TransferState);
|
|
672
|
+
const platformId = inject(PLATFORM_ID);
|
|
673
|
+
const request = inject(REQUEST, { optional: true });
|
|
674
|
+
const notFoundRoute = inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE;
|
|
675
|
+
const errorRoute = inject(ERROR_ROUTE_TOKEN, { optional: true }) || DEFAULT_ERROR_ROUTE;
|
|
676
|
+
const router = inject(Router);
|
|
677
|
+
const defaultLanguage = inject(SITECORE_CONFIG_TOKEN, { optional: true })?.defaultLanguage;
|
|
678
|
+
const url = state.url;
|
|
679
|
+
const key = stateKey(loaderId, url);
|
|
680
|
+
if (isPlatformBrowser(platformId)) {
|
|
681
|
+
try {
|
|
682
|
+
return await resolveOnBrowser({
|
|
683
|
+
route,
|
|
684
|
+
state,
|
|
685
|
+
loaderId,
|
|
686
|
+
router,
|
|
687
|
+
defaultLanguage,
|
|
688
|
+
cacheOptions,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
catch (e) {
|
|
692
|
+
// special handling for browser, as navigation error for handleNavigationError is only generated on server
|
|
693
|
+
return redirectOnNavigationError(e, url, notFoundRoute, errorRoute, router);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const serverLoaderRunner = inject(SERVER_LOADER_RUNNER, { optional: true });
|
|
697
|
+
if (!serverLoaderRunner) {
|
|
698
|
+
throw new Error('SSR loader resolution requires provideServerLoaderRunner() in server application providers');
|
|
699
|
+
}
|
|
700
|
+
const angularRequestContext = request ? extractRequestContext(request) : undefined;
|
|
701
|
+
const result = await serverLoaderRunner.resolve({
|
|
702
|
+
loaderId,
|
|
703
|
+
url,
|
|
704
|
+
params: buildLoaderParams(route, defaultLanguage),
|
|
705
|
+
query: route.queryParams,
|
|
706
|
+
angularRequestContext,
|
|
707
|
+
cacheOptions,
|
|
708
|
+
});
|
|
709
|
+
if (result.kind === 'redirect') {
|
|
710
|
+
return applyRedirect(router, result.redirect.loaderRedirectTarget);
|
|
711
|
+
}
|
|
712
|
+
if (result.kind === 'error') {
|
|
713
|
+
const cause = result.cause;
|
|
714
|
+
if (cause instanceof NotFoundNavigationError)
|
|
715
|
+
throw cause;
|
|
716
|
+
if (cause instanceof LoaderHttpError)
|
|
717
|
+
throw cause;
|
|
718
|
+
throw new LoaderHttpError(result.status, result.message);
|
|
719
|
+
}
|
|
720
|
+
transferState.set(key, result.data);
|
|
721
|
+
return result.data;
|
|
722
|
+
};
|
|
723
|
+
resolver[LOADER_ID] = loaderId;
|
|
724
|
+
return resolver;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* ClientPreLoaderDataService kicks off loader data fetches for all loaders in the current route
|
|
729
|
+
* and its parent routes in parallel, so that when Angular runs resolvers sequentially,
|
|
730
|
+
* resolvers get staged prefetched responses or join already-pending requests instead of waiting.
|
|
731
|
+
*
|
|
732
|
+
* Subscribes to the router's ActivationStart event and prefetches for the
|
|
733
|
+
* ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader
|
|
734
|
+
* resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then
|
|
735
|
+
* calls ClientLoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches
|
|
736
|
+
* run in parallel; results are stored in ClientLoaderDataService prefetchedResponses for getData() to consume.
|
|
737
|
+
* @public
|
|
738
|
+
*/
|
|
739
|
+
class ClientPreLoaderDataService {
|
|
740
|
+
loaderData = inject(ClientLoaderDataService);
|
|
741
|
+
platformId = inject(PLATFORM_ID);
|
|
742
|
+
router = inject(Router);
|
|
743
|
+
destroyRef = inject(DestroyRef);
|
|
744
|
+
constructor() {
|
|
745
|
+
this.router.events
|
|
746
|
+
.pipe(filter((e) => e instanceof ActivationStart), takeUntilDestroyed(this.destroyRef))
|
|
747
|
+
.subscribe((event) => {
|
|
748
|
+
const snapshot = event.snapshot;
|
|
749
|
+
if (!snapshot.children?.length) {
|
|
750
|
+
this.prefetchForRoute(snapshot, this.router.routerState.snapshot);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Prefetch loader data for all loaders in the route tree.
|
|
756
|
+
* Call this at the start of browser resolver execution so all loaders for the route
|
|
757
|
+
* are kicked off in parallel before resolvers run sequentially.
|
|
758
|
+
* No-op on server.
|
|
759
|
+
* @param {ActivatedRouteSnapshot} route - Current route (pathFromRoot gives current and parent routes)
|
|
760
|
+
* @param {RouterStateSnapshot} state - Current router state (use state.url for the navigation URL)
|
|
761
|
+
*/
|
|
762
|
+
async prefetchForRoute(route, state) {
|
|
763
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const loaders = this.collectLoaders(route, state);
|
|
767
|
+
for (const loaderData of loaders) {
|
|
768
|
+
this.loaderData.prefetch(loaderData);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Collect LoaderDataRequest for each resolver that has LOADER_ID on the current route
|
|
773
|
+
* and its parent routes (pathFromRoot). Deduplicates by (loaderId, url).
|
|
774
|
+
* @param {ActivatedRouteSnapshot} route - The current route
|
|
775
|
+
* @param {RouterStateSnapshot} state - The router state snapshot, used for url and params
|
|
776
|
+
*/
|
|
777
|
+
collectLoaders(route, state) {
|
|
778
|
+
const loaderDataRequests = [];
|
|
779
|
+
const breadcrump = route.pathFromRoot ?? [];
|
|
780
|
+
for (const route of breadcrump) {
|
|
781
|
+
if (!route)
|
|
782
|
+
continue;
|
|
783
|
+
const resolveDefinition = route.routeConfig?.resolve;
|
|
784
|
+
if (resolveDefinition) {
|
|
785
|
+
for (const resolver of Object.values(resolveDefinition)) {
|
|
786
|
+
if (typeof resolver === 'function' &&
|
|
787
|
+
LOADER_ID in resolver &&
|
|
788
|
+
typeof resolver[LOADER_ID] === 'string') {
|
|
789
|
+
const loaderId = resolver[LOADER_ID];
|
|
790
|
+
const url = state.url;
|
|
791
|
+
const params = (route.pathFromRoot ?? []).reduce((acc, r) => ({ ...acc, ...(r?.params ?? {}) }), {});
|
|
792
|
+
loaderDataRequests.push({
|
|
793
|
+
loaderId,
|
|
794
|
+
url,
|
|
795
|
+
params,
|
|
796
|
+
query: (route.queryParams ?? {}),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return loaderDataRequests;
|
|
803
|
+
}
|
|
804
|
+
static ɵfac = function ClientPreLoaderDataService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ClientPreLoaderDataService)(); };
|
|
805
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: ClientPreLoaderDataService, factory: ClientPreLoaderDataService.ɵfac, providedIn: 'root' });
|
|
806
|
+
}
|
|
807
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ClientPreLoaderDataService, [{
|
|
808
|
+
type: Injectable,
|
|
809
|
+
args: [{
|
|
810
|
+
providedIn: 'root',
|
|
811
|
+
}]
|
|
812
|
+
}], () => [], null); })();
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Provides Sitecore Angular SDK services to the application.
|
|
816
|
+
* Call this in your `app.config.ts` `providers` array.
|
|
817
|
+
* @param {AngularCSDKAppInit} init SDK configuration
|
|
818
|
+
* @param {AngularCSDKAppInit} init.sitecoreConfig - Sitecore configuration
|
|
819
|
+
* @param {AngularCSDKAppInit} init.sitecoreClient - Sitecore client
|
|
820
|
+
* @param {AngularCSDKAppInit} init.notFoundRoute - Not found route
|
|
821
|
+
* @param {AngularCSDKAppInit} init.errorRoute - Error route
|
|
822
|
+
* @returns {EnvironmentProviders} Angular environment providers
|
|
823
|
+
* @example
|
|
824
|
+
* // app.config.ts
|
|
825
|
+
* import scConfig from '../sitecore.config';
|
|
826
|
+
* import { getClient } from '../content-sdk/client/sitecore-client';
|
|
827
|
+
* export const appConfig: ApplicationConfig = {
|
|
828
|
+
* providers: [
|
|
829
|
+
* provideSitecoreAngular({ sitecoreConfig: scConfig, sitecoreClient: getClient() }),
|
|
830
|
+
* ],
|
|
831
|
+
* };
|
|
832
|
+
* @public
|
|
833
|
+
*/
|
|
834
|
+
function provideSitecoreAngular(init) {
|
|
835
|
+
const providers = [];
|
|
836
|
+
if (init.sitecoreConfig !== undefined || init.sitecoreClient !== undefined) {
|
|
837
|
+
if (init.sitecoreConfig === undefined || init.sitecoreClient === undefined) {
|
|
838
|
+
throw new Error('provideSitecoreAngular: `sitecoreConfig` and `sitecoreClient` must both be provided together.');
|
|
839
|
+
}
|
|
840
|
+
providers.push({ provide: SITECORE_CONFIG_TOKEN, useValue: init.sitecoreConfig });
|
|
841
|
+
providers.push({ provide: SITECORE_CLIENT_TOKEN, useValue: init.sitecoreClient });
|
|
842
|
+
}
|
|
843
|
+
if (init.notFoundRoute) {
|
|
844
|
+
providers.push({ provide: NOT_FOUND_ROUTE_TOKEN, useValue: init.notFoundRoute });
|
|
845
|
+
}
|
|
846
|
+
if (init.errorRoute) {
|
|
847
|
+
providers.push({ provide: ERROR_ROUTE_TOKEN, useValue: init.errorRoute });
|
|
848
|
+
}
|
|
849
|
+
return makeEnvironmentProviders(providers);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Default global revalidate TTL (seconds) when {@link LoaderCacheConfig.revalidate} is omitted. @internal */
|
|
853
|
+
const DEFAULT_CACHE_TTL = 300;
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Approximate serialized byte size of a cache value (demo/admin helper).
|
|
857
|
+
* Only used for demo purposes. Remove before release.
|
|
858
|
+
* TODO: Remove before release.
|
|
859
|
+
* @param {unknown} value - Value to measure.
|
|
860
|
+
* @returns {number} JSON string length, or `0` when serialization fails.
|
|
861
|
+
*/
|
|
862
|
+
function approxByteSize(value) {
|
|
863
|
+
try {
|
|
864
|
+
return JSON.stringify(value).length;
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return 0;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Removes the query string from a URL path.
|
|
872
|
+
* @param {string} url - URL or path that may include `?query`.
|
|
873
|
+
* @returns {string} Pathname without query string.
|
|
874
|
+
* @internal
|
|
875
|
+
*/
|
|
876
|
+
function stripQuery(url) {
|
|
877
|
+
const i = url.indexOf('?');
|
|
878
|
+
return i === -1 ? url : url.slice(0, i);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Converts a loader URL to the `pathKey` segment used in OSR-aligned cache keys.
|
|
882
|
+
* Strips query strings, trims slashes, sanitizes segments, and removes a leading
|
|
883
|
+
* locale prefix when it matches `params.locale`. Home resolves to `'_'`.
|
|
884
|
+
* @param {string} url - Loader URL (may include query string).
|
|
885
|
+
* @param {string} [locale] - Optional locale used to strip a leading `/locale` prefix.
|
|
886
|
+
* @returns {string} Sanitized path key (`'_'` for home).
|
|
887
|
+
* @example
|
|
888
|
+
* ```ts
|
|
889
|
+
* urlToPathKey('/'); // '_'
|
|
890
|
+
* urlToPathKey('/About Us'); // 'about_us'
|
|
891
|
+
* urlToPathKey('/en/about', 'en'); // 'about'
|
|
892
|
+
* ```
|
|
893
|
+
* @internal
|
|
894
|
+
*/
|
|
895
|
+
function urlToPathKey(url, locale) {
|
|
896
|
+
const pathname = stripQuery(url || '/').replace(/^\/+|\/+$/g, '');
|
|
897
|
+
let segments = pathname ? pathname.split('/').filter(Boolean) : [];
|
|
898
|
+
if (locale && segments[0]?.toLowerCase() === locale.toLowerCase()) {
|
|
899
|
+
segments = segments.slice(1);
|
|
900
|
+
}
|
|
901
|
+
if (segments.length === 0) {
|
|
902
|
+
return '_';
|
|
903
|
+
}
|
|
904
|
+
return segments.map((segment) => sanitizeSitecoreCacheSegment(segment)).join('/');
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Derives {@link CacheKeyDimensions} from a loader context.
|
|
908
|
+
* Used by {@link buildCacheKey} and admin tooling.
|
|
909
|
+
* @param {string} loaderId - Loader id being resolved.
|
|
910
|
+
* @param {LoaderContext} ctx - Loader context (URL + route params).
|
|
911
|
+
* @returns {CacheKeyDimensions} Parsed cache key dimensions.
|
|
912
|
+
* @internal
|
|
913
|
+
*/
|
|
914
|
+
function dimensionsFromContext(loaderId, ctx) {
|
|
915
|
+
const params = (ctx.params ?? {});
|
|
916
|
+
const site = params?.site || 'default';
|
|
917
|
+
const locale = params?.locale || 'en';
|
|
918
|
+
const pathKey = urlToPathKey(ctx.url || '/', locale);
|
|
919
|
+
return {
|
|
920
|
+
site,
|
|
921
|
+
locale,
|
|
922
|
+
variantId: 'default',
|
|
923
|
+
loaderId,
|
|
924
|
+
pathKey,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Strips `driver` from {@link GlobalLoaderCacheConfig} before passing config to backends.
|
|
929
|
+
* @param {GlobalLoaderCacheConfig} config - Global cache config from {@link createLoaderCache}.
|
|
930
|
+
* @returns {LoaderCacheConfig} Backend-safe config without the unstorage driver instance.
|
|
931
|
+
* @internal
|
|
932
|
+
*/
|
|
933
|
+
function resolveConfig(config) {
|
|
934
|
+
const clonedConfig = { ...config };
|
|
935
|
+
delete clonedConfig.driver;
|
|
936
|
+
return clonedConfig;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Applies defaults for every {@link LoaderCacheConfig} field.
|
|
940
|
+
* @param {LoaderCacheConfig} [config] - Partial config from `createLoaderCache()` or a backend constructor.
|
|
941
|
+
* @returns {Required<LoaderCacheConfig>} Fully populated config used by cache backends.
|
|
942
|
+
* @internal
|
|
943
|
+
*/
|
|
944
|
+
function applyLoaderCacheConfigDefaults(config = {}) {
|
|
945
|
+
return {
|
|
946
|
+
revalidate: config.revalidate ?? DEFAULT_CACHE_TTL,
|
|
947
|
+
enabled: config.enabled ?? true,
|
|
948
|
+
defaultSiteName: config.defaultSiteName ?? 'default',
|
|
949
|
+
tags: config.tags ?? [],
|
|
950
|
+
sites: config.sites ?? [],
|
|
951
|
+
defaultLocale: config.defaultLocale ?? 'en',
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Maps a stored entry to the three-outcome read result used by {@link ServerLoaderRunner} (Phase 3 SWR).
|
|
956
|
+
* @param {string} cacheKey - Key being read.
|
|
957
|
+
* @param {LoaderCacheEntry | null | undefined} entry - Stored entry, if any.
|
|
958
|
+
* @param {number} [now] - Current timestamp for TTL comparison (defaults to `Date.now()`).
|
|
959
|
+
* @returns {LoaderCacheReadResult} Hit, stale, or miss classification.
|
|
960
|
+
* @internal
|
|
961
|
+
*/
|
|
962
|
+
function evaluateCacheRead(cacheKey, entry, now = Date.now()) {
|
|
963
|
+
if (!entry) {
|
|
964
|
+
return { kind: 'miss', cacheKey };
|
|
965
|
+
}
|
|
966
|
+
if (entry.stale || (entry.expiresAt !== null && entry.expiresAt <= now)) {
|
|
967
|
+
return { kind: 'stale', value: entry.value, cacheKey };
|
|
968
|
+
}
|
|
969
|
+
return { kind: 'hit', value: entry.value, cacheKey };
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Sanitizes a segment for Sitecore cache keys and tags (lowercase, separators → `_`).
|
|
973
|
+
* @param {string} value - Raw segment from site, locale, path, or loader id.
|
|
974
|
+
* @returns {string} Sanitized segment safe for keys and tags.
|
|
975
|
+
* @internal
|
|
976
|
+
*/
|
|
977
|
+
function sanitizeSitecoreCacheSegment(value) {
|
|
978
|
+
return value
|
|
979
|
+
.trim()
|
|
980
|
+
.toLowerCase()
|
|
981
|
+
.replace(/[/:\s]+/g, '_');
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Normalizes a Sitecore item GUID for cache keys/tags (lowercase, no braces).
|
|
985
|
+
* @param {string} itemId - Raw Sitecore item id or GUID.
|
|
986
|
+
* @returns {string} Normalized id segment.
|
|
987
|
+
* @internal
|
|
988
|
+
*/
|
|
989
|
+
function normalizeSitecoreItemIdForCacheKey(itemId) {
|
|
990
|
+
return itemId.trim().toLowerCase().replace(/[{}]/g, '');
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Deduplicates strings while preserving first-seen order.
|
|
994
|
+
* @param {string[]} values - Tag or key candidates.
|
|
995
|
+
* @returns {string[]} Deduplicated list.
|
|
996
|
+
* @internal
|
|
997
|
+
*/
|
|
998
|
+
function dedupeCacheStrings(values) {
|
|
999
|
+
const dedupedSet = new Set(values);
|
|
1000
|
+
return Array.from(dedupedSet);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Sitecore OSR namespace prefix shared with Next.js (`sc:`).
|
|
1005
|
+
* All loader cache keys and invalidation tags use this prefix.
|
|
1006
|
+
* @internal
|
|
1007
|
+
*/
|
|
1008
|
+
const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc';
|
|
1009
|
+
/**
|
|
1010
|
+
* Builds an item-scoped revalidation tag: `sc:item:<id>:<locale>:<version>`.
|
|
1011
|
+
* @param {BuildSitecoreItemCacheTagParams} params - Item id, locale, and optional version.
|
|
1012
|
+
* @returns {string} Sitecore item cache tag.
|
|
1013
|
+
* @internal
|
|
1014
|
+
*/
|
|
1015
|
+
function buildSitecoreItemCacheTag(params) {
|
|
1016
|
+
const id = normalizeSitecoreItemIdForCacheKey(params.itemId);
|
|
1017
|
+
const locale = sanitizeSitecoreCacheSegment(params.locale);
|
|
1018
|
+
const ver = params.version !== undefined && Number.isFinite(params.version)
|
|
1019
|
+
? `v${Math.trunc(params.version)}`
|
|
1020
|
+
: 'latest';
|
|
1021
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Builds a Next.js-compatible dictionary tag: `sc:dict:<site>:<locale>`.
|
|
1025
|
+
* Used for dictionary loader entries and cross-stack webhook fan-out.
|
|
1026
|
+
* @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments.
|
|
1027
|
+
* @returns {string} Dictionary cache tag.
|
|
1028
|
+
* @internal
|
|
1029
|
+
*/
|
|
1030
|
+
function buildSitecoreDictionaryCacheTag(params) {
|
|
1031
|
+
const site = sanitizeSitecoreCacheSegment(params.site);
|
|
1032
|
+
const locale = sanitizeSitecoreCacheSegment(params.locale);
|
|
1033
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Builds an item tag from layout route data when `itemId` is present.
|
|
1037
|
+
* Returns `null` when the route has no item id (non-content routes).
|
|
1038
|
+
* @param {RouteData | null | undefined} route - Layout route metadata.
|
|
1039
|
+
* @param {string} fallbackLocale - Locale used when `route.itemLanguage` is absent.
|
|
1040
|
+
* @returns {string | null} Item cache tag, or `null` when no item id is available.
|
|
1041
|
+
* @internal
|
|
1042
|
+
*/
|
|
1043
|
+
function buildSitecoreItemCacheTagFromRouteData(route, fallbackLocale) {
|
|
1044
|
+
if (!route?.itemId) {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
const locale = route.itemLanguage
|
|
1048
|
+
? sanitizeSitecoreCacheSegment(route.itemLanguage)
|
|
1049
|
+
: sanitizeSitecoreCacheSegment(fallbackLocale);
|
|
1050
|
+
const id = normalizeSitecoreItemIdForCacheKey(route.itemId);
|
|
1051
|
+
const ver = route.itemVersion !== undefined && Number.isFinite(route.itemVersion)
|
|
1052
|
+
? `v${Math.trunc(route.itemVersion)}`
|
|
1053
|
+
: 'latest';
|
|
1054
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Builds loader-cache dictionary self-tags for webhook fan-out across sites.
|
|
1058
|
+
* Produces `sc:loader:dictionary:<site>:<locale>` tags, deduped in first-seen order.
|
|
1059
|
+
* When a site has no `language`, `baseLocale` is used.
|
|
1060
|
+
* @param {BuildLoaderDictionaryCacheTagsFromSitesParams} params - Sites and fallback locale.
|
|
1061
|
+
* @returns {string[]} Deduplicated loader dictionary cache tags.
|
|
1062
|
+
* @internal
|
|
1063
|
+
*/
|
|
1064
|
+
function buildLoaderDictionaryCacheTagsFromSites(params) {
|
|
1065
|
+
const seen = new Set();
|
|
1066
|
+
const out = [];
|
|
1067
|
+
for (const site of params.sites) {
|
|
1068
|
+
const locale = site.language?.trim() ? site.language : params.baseLocale;
|
|
1069
|
+
const tag = buildLoaderDictionaryCacheTag({ site: site.name, locale });
|
|
1070
|
+
if (!seen.has(tag)) {
|
|
1071
|
+
seen.add(tag);
|
|
1072
|
+
out.push(tag);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Loader-cache self-tag for the dictionary loader: `sc:loader:dictionary:<site>:<locale>`.
|
|
1079
|
+
* @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments.
|
|
1080
|
+
* @returns {string} Loader dictionary self-tag (same shape as the cache key).
|
|
1081
|
+
* @internal
|
|
1082
|
+
*/
|
|
1083
|
+
function buildLoaderDictionaryCacheTag(params) {
|
|
1084
|
+
const site = sanitizeSitecoreCacheSegment(params.site);
|
|
1085
|
+
const locale = sanitizeSitecoreCacheSegment(params.locale);
|
|
1086
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:${site}:${locale}`;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Site-wide fan-out tag: `sc:site:<site>`.
|
|
1090
|
+
* Invalidating this tag marks every cached entry for the site stale.
|
|
1091
|
+
* @param {string} site - Site name segment.
|
|
1092
|
+
* @returns {string} Site fan-out cache tag.
|
|
1093
|
+
* @internal
|
|
1094
|
+
*/
|
|
1095
|
+
function buildSitecoreSiteCacheTag(site) {
|
|
1096
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:${sanitizeSitecoreCacheSegment(site)}`;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Locale-wide fan-out tag: `sc:locale:<locale>`.
|
|
1100
|
+
* @param {string} locale - Locale segment.
|
|
1101
|
+
* @returns {string} Locale fan-out cache tag.
|
|
1102
|
+
* @internal
|
|
1103
|
+
*/
|
|
1104
|
+
function buildSitecoreLocaleCacheTag(locale) {
|
|
1105
|
+
return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:${sanitizeSitecoreCacheSegment(locale)}`;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Builds the full tag set written alongside a loader cache entry (Phase 3 OSR alignment).
|
|
1109
|
+
* Always includes self-tag, `sc:site:<site>`, and `sc:locale:<locale>`. Conditionally adds
|
|
1110
|
+
* `sc:item:…` for page loaders and `sc:dict:…` for dictionary loaders. Custom tags are deduped.
|
|
1111
|
+
* @param {string} loaderId - Loader that produced the value.
|
|
1112
|
+
* @param {CacheKeyDimensions} dimensions - Key dimensions from {@link buildCacheKey}.
|
|
1113
|
+
* @param {string} cacheKey - Stored cache key (also used as a self-tag).
|
|
1114
|
+
* @param {unknown} [loaderValue] - Loader payload (page layout is inspected for item tags).
|
|
1115
|
+
* @param {string[]} [customTags] - Optional per-route tags from `loaderResolver(id, { tags })`.
|
|
1116
|
+
* @returns {string[]} Tag set to persist with the cache entry.
|
|
1117
|
+
* @internal
|
|
1118
|
+
*/
|
|
1119
|
+
function buildLoaderCacheTags(loaderId, dimensions, cacheKey, loaderValue, customTags = []) {
|
|
1120
|
+
const tags = [
|
|
1121
|
+
cacheKey,
|
|
1122
|
+
buildSitecoreSiteCacheTag(dimensions.site),
|
|
1123
|
+
buildSitecoreLocaleCacheTag(dimensions.locale),
|
|
1124
|
+
...customTags,
|
|
1125
|
+
];
|
|
1126
|
+
if (loaderId === 'page') {
|
|
1127
|
+
const itemTag = buildPageItemTag(loaderValue, dimensions.locale);
|
|
1128
|
+
if (itemTag) {
|
|
1129
|
+
tags.push(itemTag);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (loaderId === 'dictionary') {
|
|
1133
|
+
tags.push(buildSitecoreDictionaryCacheTag({ site: dimensions.site, locale: dimensions.locale }));
|
|
1134
|
+
}
|
|
1135
|
+
return dedupeCacheStrings(tags);
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Extracts a page item tag from a loader payload when layout route data is present.
|
|
1139
|
+
* @param {unknown} value - Loader result (expected to be a page shape).
|
|
1140
|
+
* @param {string} fallbackLocale - Locale used when route language is absent.
|
|
1141
|
+
* @returns {string | null} Item cache tag, or `null` when no item id is available.
|
|
1142
|
+
* @internal
|
|
1143
|
+
*/
|
|
1144
|
+
function buildPageItemTag(value, fallbackLocale) {
|
|
1145
|
+
if (!value || typeof value !== 'object') {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
const page = value;
|
|
1149
|
+
return buildSitecoreItemCacheTagFromRouteData(page.layout?.sitecore?.route, fallbackLocale);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Prefix for OSR-aligned loader cache keys (`sc:loader:…`). @internal */
|
|
1153
|
+
const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`;
|
|
1154
|
+
/**
|
|
1155
|
+
* Compose the canonical cache key and dimension tuple for a loader invocation.
|
|
1156
|
+
* @param {string} loaderId - Registered loader id (`page`, `dictionary`, etc.).
|
|
1157
|
+
* @param {LoaderContext} ctx - Loader context (URL, route params, query).
|
|
1158
|
+
* @returns {{ key: string, dimensions: CacheKeyDimensions }} Cache key and parsed dimensions.
|
|
1159
|
+
* @example
|
|
1160
|
+
* ```ts
|
|
1161
|
+
* buildCacheKey('page', { url: '/about', params: { site: 'demo', locale: 'en' }, query: {} });
|
|
1162
|
+
* // → { key: 'sc:loader:page:demo:en:default:about', dimensions: { … } }
|
|
1163
|
+
* ```
|
|
1164
|
+
* @internal
|
|
1165
|
+
*/
|
|
1166
|
+
function buildCacheKey(loaderId, ctx) {
|
|
1167
|
+
const dimensions = dimensionsFromContext(loaderId, ctx);
|
|
1168
|
+
const key = serializeLoaderCacheKey(dimensions);
|
|
1169
|
+
return { key, dimensions };
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Serializes cache key dimensions into the public `sc:loader:…` format.
|
|
1173
|
+
* Dispatches to {@link buildPageCacheKey}, {@link buildDictionaryCacheKey}, or
|
|
1174
|
+
* {@link buildGenericLoaderCacheKey} based on `dimensions.loaderId`.
|
|
1175
|
+
* @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions.
|
|
1176
|
+
* @returns {string} OSR-aligned cache key.
|
|
1177
|
+
* @internal
|
|
1178
|
+
*/
|
|
1179
|
+
function serializeLoaderCacheKey(dimensions) {
|
|
1180
|
+
if (dimensions.loaderId === 'page') {
|
|
1181
|
+
return buildPageCacheKey(dimensions);
|
|
1182
|
+
}
|
|
1183
|
+
if (dimensions.loaderId === 'dictionary') {
|
|
1184
|
+
return buildDictionaryCacheKey(dimensions);
|
|
1185
|
+
}
|
|
1186
|
+
return buildGenericLoaderCacheKey(dimensions);
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Page loader key: `sc:loader:page:<site>:<locale>:<variantId>:<pathKey>`.
|
|
1190
|
+
* @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions.
|
|
1191
|
+
* @returns {string} Page loader cache key.
|
|
1192
|
+
* @internal
|
|
1193
|
+
*/
|
|
1194
|
+
function buildPageCacheKey(dimensions) {
|
|
1195
|
+
const site = sanitizeSitecoreCacheSegment(dimensions.site);
|
|
1196
|
+
const locale = sanitizeSitecoreCacheSegment(dimensions.locale);
|
|
1197
|
+
const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId);
|
|
1198
|
+
return `${CACHE_KEY_PREFIX}:page:${site}:${locale}:${variantId}:${dimensions.pathKey}`;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Dictionary loader key: `sc:loader:dictionary:<site>:<locale>` (one entry per site/locale).
|
|
1202
|
+
* @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions.
|
|
1203
|
+
* @returns {string} Dictionary loader cache key.
|
|
1204
|
+
* @internal
|
|
1205
|
+
*/
|
|
1206
|
+
function buildDictionaryCacheKey(dimensions) {
|
|
1207
|
+
const site = sanitizeSitecoreCacheSegment(dimensions.site);
|
|
1208
|
+
const locale = sanitizeSitecoreCacheSegment(dimensions.locale);
|
|
1209
|
+
return `${CACHE_KEY_PREFIX}:dictionary:${site}:${locale}`;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Generic loader key: `sc:loader:<loaderId>:<site>:<locale>:<variantId>:<pathKey>`.
|
|
1213
|
+
* Used for loaders other than `page` and `dictionary` (for example `404`, `500`).
|
|
1214
|
+
* @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions.
|
|
1215
|
+
* @returns {string} Generic loader cache key.
|
|
1216
|
+
* @internal
|
|
1217
|
+
*/
|
|
1218
|
+
function buildGenericLoaderCacheKey(dimensions) {
|
|
1219
|
+
const loaderId = sanitizeSitecoreCacheSegment(dimensions.loaderId);
|
|
1220
|
+
const site = sanitizeSitecoreCacheSegment(dimensions.site);
|
|
1221
|
+
const locale = sanitizeSitecoreCacheSegment(dimensions.locale);
|
|
1222
|
+
const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId);
|
|
1223
|
+
return `${CACHE_KEY_PREFIX}:${loaderId}:${site}:${locale}:${variantId}:${dimensions.pathKey}`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Server-side cache aware loader data resolver.
|
|
1228
|
+
* LoaderResolver is exposed to both server and browser. This layer ensures browser safety and acts as connecting layer to cache.
|
|
1229
|
+
*
|
|
1230
|
+
* Resolution order when a {@link LoaderCache} is attached:
|
|
1231
|
+
* 1. **hit** — return cached value immediately.
|
|
1232
|
+
* 2. **stale** — return cached value immediately and schedule a background refresh
|
|
1233
|
+
* (coalesced per cache key via `pendingCacheOps`).
|
|
1234
|
+
* 3. **miss** — run the loader, persist the result with OSR tags, return data.
|
|
1235
|
+
*
|
|
1236
|
+
* Redirect responses are never cached. Per-route LoaderCacheConfig overrides
|
|
1237
|
+
* from `loaderResolver(id, cacheOptions)` control TTL, tags, and opt-in caching when
|
|
1238
|
+
* the global cache is disabled.
|
|
1239
|
+
* @public
|
|
1240
|
+
*/
|
|
1241
|
+
class ServerLoaderRunner {
|
|
1242
|
+
registry;
|
|
1243
|
+
cache;
|
|
1244
|
+
/** Process-wide coalescing for stale-while-revalidate background refreshes. */
|
|
1245
|
+
static pendingCacheOps = new Set();
|
|
1246
|
+
/**
|
|
1247
|
+
* @param {LoaderRegistry} registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware.
|
|
1248
|
+
* @param {LoaderCache | undefined} cache - Optional cache instance from createLoaderCache.
|
|
1249
|
+
*/
|
|
1250
|
+
constructor(registry, cache) {
|
|
1251
|
+
this.registry = registry;
|
|
1252
|
+
this.cache = cache;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Resolve loader data with optional cache read-through and SWR refresh.
|
|
1256
|
+
* @param {LoaderApiRequest} request - Loader id, URL, params, optional request context and cache overrides.
|
|
1257
|
+
* @returns {Promise<LoaderDataResult>} Data, redirect, or error result for the middleware / SSR resolver.
|
|
1258
|
+
*/
|
|
1259
|
+
async resolve(request) {
|
|
1260
|
+
const { loaderId, url, params, query, angularRequestContext, cacheOptions } = request;
|
|
1261
|
+
const loader = this.registry[loaderId];
|
|
1262
|
+
if (!loader) {
|
|
1263
|
+
return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` };
|
|
1264
|
+
}
|
|
1265
|
+
const ctx = { url, params, query, requestContext: angularRequestContext };
|
|
1266
|
+
const cacheable = this.cache && (cacheOptions?.enabled ?? this.cache.enabled());
|
|
1267
|
+
if (cacheable) {
|
|
1268
|
+
const { key } = buildCacheKey(loaderId, ctx);
|
|
1269
|
+
const read = await this.cache.get(key);
|
|
1270
|
+
if (read.kind === 'hit') {
|
|
1271
|
+
return { kind: 'data', data: read.value };
|
|
1272
|
+
}
|
|
1273
|
+
if (read.kind === 'stale') {
|
|
1274
|
+
this.scheduleBackgroundRefresh(request, ctx, key, cacheOptions);
|
|
1275
|
+
return { kind: 'data', data: read.value };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return this.runLoader({ request, ctx, cacheable: !!cacheable, cacheOptions });
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Fire-and-forget SWR refresh; skipped when a refresh is already in flight for the key.
|
|
1282
|
+
* @param {LoaderApiRequest} request - The loader request
|
|
1283
|
+
* @param {LoaderContext} ctx - The loader context
|
|
1284
|
+
* @param {string} cacheKey - The cache key
|
|
1285
|
+
* @param {LoaderApiRequest['cacheOptions']} cacheOptions - The cache options
|
|
1286
|
+
*/
|
|
1287
|
+
scheduleBackgroundRefresh(request, ctx, cacheKey, cacheOptions) {
|
|
1288
|
+
if (ServerLoaderRunner.pendingCacheOps.has(cacheKey)) {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
ServerLoaderRunner.pendingCacheOps.add(cacheKey);
|
|
1292
|
+
void this.runLoader({
|
|
1293
|
+
request,
|
|
1294
|
+
ctx,
|
|
1295
|
+
cacheable: true,
|
|
1296
|
+
cacheOptions,
|
|
1297
|
+
knownCacheKey: cacheKey,
|
|
1298
|
+
}).then(() => {
|
|
1299
|
+
ServerLoaderRunner.pendingCacheOps.delete(cacheKey);
|
|
1300
|
+
}, () => {
|
|
1301
|
+
ServerLoaderRunner.pendingCacheOps.delete(cacheKey);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
async runLoader({ request, ctx, cacheable, cacheOptions, knownCacheKey, }) {
|
|
1305
|
+
const { loaderId } = request;
|
|
1306
|
+
const loader = this.registry[loaderId];
|
|
1307
|
+
let value;
|
|
1308
|
+
try {
|
|
1309
|
+
value = await loader(ctx);
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
const message = err instanceof Error ? err.message : 'Loader failed';
|
|
1313
|
+
return {
|
|
1314
|
+
kind: 'error',
|
|
1315
|
+
status: 500,
|
|
1316
|
+
message,
|
|
1317
|
+
...(err instanceof Error ? { cause: err } : {}),
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
if (isLoaderRedirectResult(value)) {
|
|
1321
|
+
return { kind: 'redirect', redirect: value };
|
|
1322
|
+
}
|
|
1323
|
+
if (cacheable && this.cache) {
|
|
1324
|
+
const { key, dimensions } = buildCacheKey(loaderId, ctx);
|
|
1325
|
+
const cacheKey = knownCacheKey ?? key;
|
|
1326
|
+
const tags = buildLoaderCacheTags(loaderId, dimensions, cacheKey, value, cacheOptions?.tags ?? []);
|
|
1327
|
+
const ttl = cacheOptions?.revalidate ?? this.cache.ttl;
|
|
1328
|
+
try {
|
|
1329
|
+
await this.cache.set(cacheKey, value, ttl, tags);
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
console.warn('[sitecore-loader-cache] background refresh failed to write cache entry:', err instanceof Error ? err.message : err);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return { kind: 'data', data: value };
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Wires SSR {@link SERVER_LOADER_RUNNER} to ServerLoaderRunner
|
|
1341
|
+
* using the shared {@link LOADER_REGISTRY}. Include in server application providers
|
|
1342
|
+
* alongside provideLoaderRegistry.
|
|
1343
|
+
* @returns Environment providers for SSR loader data resolution
|
|
1344
|
+
* @public
|
|
1345
|
+
*/
|
|
1346
|
+
function provideServerLoaderRunner() {
|
|
1347
|
+
return makeEnvironmentProviders([
|
|
1348
|
+
{
|
|
1349
|
+
provide: SERVER_LOADER_RUNNER,
|
|
1350
|
+
useFactory: () => {
|
|
1351
|
+
const registry = inject(LOADER_REGISTRY);
|
|
1352
|
+
return {
|
|
1353
|
+
resolve(request) {
|
|
1354
|
+
const ssrContext = inject(REQUEST_CONTEXT, { optional: true });
|
|
1355
|
+
const cache = ssrContext?.cache;
|
|
1356
|
+
return new ServerLoaderRunner(registry, cache).resolve(request);
|
|
1357
|
+
},
|
|
1358
|
+
};
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
]);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Map loader resolution result to wire-level API response.
|
|
1366
|
+
* @param {LoaderDataResult} result - Loader result from the shared registry
|
|
1367
|
+
* @returns {LoaderApiResponse} Wire envelope for the client
|
|
1368
|
+
*/
|
|
1369
|
+
function toApiResponse(result) {
|
|
1370
|
+
if (result.kind === 'redirect') {
|
|
1371
|
+
return {
|
|
1372
|
+
kind: 'redirect',
|
|
1373
|
+
redirect: {
|
|
1374
|
+
loaderRedirectTarget: result.redirect.loaderRedirectTarget,
|
|
1375
|
+
status: result.redirect.status,
|
|
1376
|
+
},
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
if (result.kind === 'error') {
|
|
1380
|
+
const cause = result.cause;
|
|
1381
|
+
if (cause instanceof NotFoundNavigationError) {
|
|
1382
|
+
return { kind: 'notFound', status: 404 };
|
|
1383
|
+
}
|
|
1384
|
+
if (cause instanceof LoaderHttpError) {
|
|
1385
|
+
return { kind: 'error', status: cause.status, message: cause.message };
|
|
1386
|
+
}
|
|
1387
|
+
return { kind: 'error', status: result.status, message: result.message };
|
|
1388
|
+
}
|
|
1389
|
+
return { kind: 'data', data: result.data };
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Send the loader response to Express
|
|
1393
|
+
* @param {ExpressResponse} res - Express response
|
|
1394
|
+
* @param {LoaderApiResponse} result - Loader API payload to JSON-encode
|
|
1395
|
+
*/
|
|
1396
|
+
function sendResponse(res, result) {
|
|
1397
|
+
res.json(result);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Parse POST body or GET query into LoaderApiRequest, or return a validation error.
|
|
1401
|
+
* @param {ExpressRequest} req - Incoming Express request
|
|
1402
|
+
*/
|
|
1403
|
+
function parseLoaderRequest(req) {
|
|
1404
|
+
if (req.method === 'POST') {
|
|
1405
|
+
const body = req.body;
|
|
1406
|
+
if (!body?.loaderId)
|
|
1407
|
+
return { status: 400, message: 'Missing loaderId' };
|
|
1408
|
+
return body;
|
|
1409
|
+
}
|
|
1410
|
+
if (req.method === 'GET') {
|
|
1411
|
+
const loaderId = String(req.query?.loaderId ?? '');
|
|
1412
|
+
if (!loaderId)
|
|
1413
|
+
return { status: 400, message: 'Missing loaderId' };
|
|
1414
|
+
const query = {};
|
|
1415
|
+
for (const [key, value] of Object.entries(req.query ?? {})) {
|
|
1416
|
+
if (key !== 'loaderId' && key !== 'url' && typeof value === 'string')
|
|
1417
|
+
query[key] = value;
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
loaderId,
|
|
1421
|
+
url: String(req.query?.url ?? ''),
|
|
1422
|
+
params: {},
|
|
1423
|
+
query,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
return { status: 405, message: 'Method not allowed' };
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Create an Express middleware for the data endpoint.
|
|
1430
|
+
* This middleware handles both GET and POST requests at the configured endpoint path.
|
|
1431
|
+
*
|
|
1432
|
+
* The endpoint path must match the client: provide the same value to the Angular app via
|
|
1433
|
+
* FETCH_DATA_ENDPOINT (e.g. in app.config.ts). There is no Angular DI in Node/Express,
|
|
1434
|
+
* so you pass the endpoint here when calling this function (e.g. from server.ts).
|
|
1435
|
+
* @param {ExpressDataHandlerOptions} options - Handler options: loaders and optional endpoint (defaults to {@link LOADER_DATA_ENDPOINT})
|
|
1436
|
+
* @returns Express middleware that handles the data endpoint
|
|
1437
|
+
* @example
|
|
1438
|
+
* ```typescript
|
|
1439
|
+
* import { createExpressDataMiddleware, LOADER_DATA_ENDPOINT } from '@sitecore-content-sdk/angular';
|
|
1440
|
+
*
|
|
1441
|
+
* // Pass the same LOADERS object used with provideLoaderRegistry(LOADERS)
|
|
1442
|
+
* app.use(createExpressDataMiddleware({ loaders: LOADERS }));
|
|
1443
|
+
*
|
|
1444
|
+
* // Or pass the same endpoint you provide to the Angular app (FETCH_DATA_ENDPOINT)
|
|
1445
|
+
* const dataEndpoint = process.env.DATA_ENDPOINT ?? LOADER_DATA_ENDPOINT;
|
|
1446
|
+
* app.use(createExpressDataMiddleware({ loaders: LOADERS, endpoint: dataEndpoint }));
|
|
1447
|
+
* ```
|
|
1448
|
+
* @public
|
|
1449
|
+
*/
|
|
1450
|
+
function createLoaderDataServiceMiddleware(options) {
|
|
1451
|
+
const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options;
|
|
1452
|
+
const serverLoaderData = new ServerLoaderRunner(loaders, cache);
|
|
1453
|
+
return async (req, res, next) => {
|
|
1454
|
+
if (req.path !== endpoint) {
|
|
1455
|
+
next();
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
const parsed = parseLoaderRequest(req);
|
|
1460
|
+
if ('loaderId' in parsed) {
|
|
1461
|
+
// Per refactor plan A2: extract once at the boundary; ride on the payload.
|
|
1462
|
+
// POST body's `angularRequestContext` is ignored — server-derived data
|
|
1463
|
+
// (hostname, headers) must come from the actual request, not from a
|
|
1464
|
+
// payload the browser could spoof.
|
|
1465
|
+
parsed.angularRequestContext = extractRequestContext(req);
|
|
1466
|
+
const result = toApiResponse(await serverLoaderData.resolve(parsed));
|
|
1467
|
+
sendResponse(res, result);
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
res
|
|
1471
|
+
.status(parsed.status)
|
|
1472
|
+
.json({ kind: 'error', status: parsed.status, message: parsed.message });
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
catch (error) {
|
|
1476
|
+
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
1477
|
+
res.status(500).json({ kind: 'error', status: 500, message });
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/** @public */
|
|
1482
|
+
const createExpressDataMiddleware = createLoaderDataServiceMiddleware;
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Strips Experience Edge style suffixes from an `identifier`.
|
|
1486
|
+
* @param {string} identifier - Raw identifier from a webhook update row.
|
|
1487
|
+
* @public
|
|
1488
|
+
*/
|
|
1489
|
+
function extractSitecoreEdgeContentId(identifier) {
|
|
1490
|
+
if (!identifier || typeof identifier !== 'string') {
|
|
1491
|
+
return '';
|
|
1492
|
+
}
|
|
1493
|
+
const trimmed = identifier.trim();
|
|
1494
|
+
return trimmed.replace(/-(?:media|layout)$/i, '');
|
|
1495
|
+
}
|
|
1496
|
+
const FULL_TAG_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:`;
|
|
1497
|
+
/**
|
|
1498
|
+
* Maps an Experience Edge webhook JSON body to Sitecore cache tag strings.
|
|
1499
|
+
*
|
|
1500
|
+
* Accepts fully qualified `sc:…` tags in `body.tags`, raw content identifiers
|
|
1501
|
+
* (with optional `-media`/`-layout` suffixes), and `updates[]` rows with
|
|
1502
|
+
* `identifier` + `entity_culture`.
|
|
1503
|
+
* @param {SitecoreEdgeRevalidateRequestBody | null | undefined} body - Parsed webhook JSON body.
|
|
1504
|
+
* @param {CollectSitecoreTagsFromEdgeBodyOptions} options - Locale fallback when an update omits `entity_culture`.
|
|
1505
|
+
* @returns {string[]} Deduplicated Sitecore cache tags ready for `LoaderCache.invalidate`.
|
|
1506
|
+
* @public
|
|
1507
|
+
*/
|
|
1508
|
+
function collectSitecoreTagsFromEdgeRevalidateRequestBody(body, options) {
|
|
1509
|
+
const { defaultLocale } = options;
|
|
1510
|
+
const out = [];
|
|
1511
|
+
for (const tag of body?.tags ?? []) {
|
|
1512
|
+
if (typeof tag !== 'string') {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
if (!tag) {
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (tag.startsWith(FULL_TAG_PREFIX)) {
|
|
1519
|
+
out.push(tag);
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
const id = extractSitecoreEdgeContentId(tag);
|
|
1523
|
+
if (id) {
|
|
1524
|
+
out.push(buildSitecoreItemCacheTag({ itemId: id, locale: defaultLocale }));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
for (const u of body?.updates ?? []) {
|
|
1529
|
+
const id = extractSitecoreEdgeContentId(u?.identifier ?? '');
|
|
1530
|
+
if (!id) {
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
const locale = u?.entity_culture?.trim() || defaultLocale;
|
|
1534
|
+
out.push(buildSitecoreItemCacheTag({ itemId: id, locale }));
|
|
1535
|
+
}
|
|
1536
|
+
return dedupeCacheStrings(out).filter(Boolean);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Reads `process.env` when running under Node; otherwise returns an empty object.
|
|
1541
|
+
* process.env is only available on the server in Angular
|
|
1542
|
+
* @param {string} name - The name of the environment variable to read.
|
|
1543
|
+
* @returns {Record<string, string | undefined>} Environment map for merging into config.
|
|
1544
|
+
* @internal
|
|
1545
|
+
*/
|
|
1546
|
+
function readProcessEnv(name) {
|
|
1547
|
+
// Use globalThis so we do not need @types/node (lib tsconfig uses "types": []).
|
|
1548
|
+
const env = globalThis.process
|
|
1549
|
+
?.env;
|
|
1550
|
+
if (env) {
|
|
1551
|
+
return env[name];
|
|
1552
|
+
}
|
|
1553
|
+
return undefined;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET';
|
|
1557
|
+
const DEFAULT_SECRET_HEADER = 'x-revalidate-secret';
|
|
1558
|
+
const DEFAULT_ENDPOINT$1 = '/api/revalidate';
|
|
1559
|
+
/**
|
|
1560
|
+
* Returns a non-empty trimmed secret, or `undefined` when unset or whitespace-only.
|
|
1561
|
+
* @param {string | undefined} secretOption - Explicit secret from handler options.
|
|
1562
|
+
* @param {string | undefined} envValue - Secret from `process.env` (e.g. `SITECORE_REVALIDATE_SECRET`).
|
|
1563
|
+
* @returns {string | undefined} The resolved secret
|
|
1564
|
+
* @internal
|
|
1565
|
+
*/
|
|
1566
|
+
function resolveConfiguredRevalidateSecret(secretOption, envValue) {
|
|
1567
|
+
const raw = secretOption !== undefined ? secretOption : envValue;
|
|
1568
|
+
const trimmed = raw?.trim();
|
|
1569
|
+
return trimmed || undefined;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Express middleware aligned with Next.js `createSitecoreRevalidateRouteHandler`.
|
|
1573
|
+
*
|
|
1574
|
+
* Handles `POST /api/revalidate` (configurable via `endpoint`):
|
|
1575
|
+
* - Authenticates with `SITECORE_REVALIDATE_SECRET` / `x-revalidate-secret` when configured.
|
|
1576
|
+
* - Parses Experience Edge webhook bodies via {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}.
|
|
1577
|
+
* - Optionally appends dictionary loader tags for each configured site.
|
|
1578
|
+
* - Calls `LoaderCache.invalidate` (marks entries stale; does not delete).
|
|
1579
|
+
*
|
|
1580
|
+
* Response shape: `{ revalidated, tagsCount, marked, invocation_id, continues, durationMs }`.
|
|
1581
|
+
* @param {SitecoreRevalidateMiddlewareOptions} options - The options for the middleware
|
|
1582
|
+
* @returns {ExpressMiddleware} The middleware function
|
|
1583
|
+
* @public
|
|
1584
|
+
*/
|
|
1585
|
+
function createSitecoreRevalidateMiddleware(options) {
|
|
1586
|
+
const { cache, secret, defaultLocale = 'en', sites, endpoint = DEFAULT_ENDPOINT$1 } = options;
|
|
1587
|
+
const dictionaryTags = sites !== undefined
|
|
1588
|
+
? buildLoaderDictionaryCacheTagsFromSites({ sites, baseLocale: defaultLocale })
|
|
1589
|
+
: [];
|
|
1590
|
+
return async (req, res, next) => {
|
|
1591
|
+
if (req.method !== 'POST' || req.path !== endpoint) {
|
|
1592
|
+
next();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const startTimestamp = Date.now();
|
|
1596
|
+
try {
|
|
1597
|
+
const configuredSecret = resolveConfiguredRevalidateSecret(secret, readProcessEnv(DEFAULT_SECRET_ENV_VAR));
|
|
1598
|
+
const headers = req.headers;
|
|
1599
|
+
const providedSecret = headers[DEFAULT_SECRET_HEADER];
|
|
1600
|
+
const headerValue = Array.isArray(providedSecret) ? providedSecret[0] : providedSecret;
|
|
1601
|
+
if (headerValue !== configuredSecret) {
|
|
1602
|
+
res.status(401).json({ error: 'Unauthorized.' });
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const body = req.body;
|
|
1606
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
1607
|
+
res.status(400).json({ error: 'Request body must be a JSON object.' });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const webhookBody = body;
|
|
1611
|
+
const tags = dedupeCacheStrings([
|
|
1612
|
+
...collectSitecoreTagsFromEdgeRevalidateRequestBody(webhookBody, { defaultLocale }),
|
|
1613
|
+
...dictionaryTags,
|
|
1614
|
+
]);
|
|
1615
|
+
if (tags.length === 0) {
|
|
1616
|
+
res.status(400).json({
|
|
1617
|
+
error: 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.',
|
|
1618
|
+
});
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
const marked = await cache.invalidate({ tags });
|
|
1622
|
+
res.status(200).json({
|
|
1623
|
+
revalidated: true,
|
|
1624
|
+
tagsCount: tags.length,
|
|
1625
|
+
marked,
|
|
1626
|
+
invocation_id: webhookBody.invocation_id ?? null,
|
|
1627
|
+
continues: webhookBody.continues ?? false,
|
|
1628
|
+
durationMs: Date.now() - startTimestamp,
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
catch (error) {
|
|
1632
|
+
console.error('Sitecore revalidate middleware failed:', error);
|
|
1633
|
+
res.status(500).json({ error: 'Internal Server Error.' });
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/** Prefix for tag-index keys in unstorage (entries use `sc:loader:…` keys directly). */
|
|
1639
|
+
const TAG_INDEX_PREFIX = 'tag:';
|
|
1640
|
+
/**
|
|
1641
|
+
* Unstorage-backed {@link LoaderCache} for persistent or shared storage.
|
|
1642
|
+
* Two key spaces share one driver: `{cacheKey}` entries and `tag:{tag}` index arrays.
|
|
1643
|
+
* Semantics match {@link InMemoryLoaderCache}: `invalidate` marks stale; `get` uses
|
|
1644
|
+
* {@link evaluateCacheRead} for hit/stale/miss.
|
|
1645
|
+
* @internal
|
|
1646
|
+
*/
|
|
1647
|
+
class UnstorageLoaderCache {
|
|
1648
|
+
storage;
|
|
1649
|
+
_config;
|
|
1650
|
+
/**
|
|
1651
|
+
* @param {Driver} driver - Unstorage driver instance from the app (`server.ts`).
|
|
1652
|
+
* @param {LoaderCacheConfig} [config] - Resolved cache configuration.
|
|
1653
|
+
*/
|
|
1654
|
+
constructor(driver, config = {}) {
|
|
1655
|
+
this.storage = createStorage({ driver });
|
|
1656
|
+
this._config = applyLoaderCacheConfigDefaults(config);
|
|
1657
|
+
}
|
|
1658
|
+
/** @inheritdoc */
|
|
1659
|
+
get ttl() {
|
|
1660
|
+
return this._config.revalidate;
|
|
1661
|
+
}
|
|
1662
|
+
/** @inheritdoc */
|
|
1663
|
+
get config() {
|
|
1664
|
+
return this._config;
|
|
1665
|
+
}
|
|
1666
|
+
/** @inheritdoc */
|
|
1667
|
+
async get(cacheKey) {
|
|
1668
|
+
const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey));
|
|
1669
|
+
return evaluateCacheRead(cacheKey, entry ?? null);
|
|
1670
|
+
}
|
|
1671
|
+
/** @inheritdoc */
|
|
1672
|
+
async set(cacheKey, value, ttlSeconds, tags) {
|
|
1673
|
+
const existing = await this.storage.getItem(this.cacheStorageKey(cacheKey));
|
|
1674
|
+
if (existing) {
|
|
1675
|
+
await this.unlinkTags(cacheKey, existing.tags);
|
|
1676
|
+
}
|
|
1677
|
+
const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
|
|
1678
|
+
const entry = {
|
|
1679
|
+
value,
|
|
1680
|
+
tags: [...tags],
|
|
1681
|
+
storedAt: Date.now(),
|
|
1682
|
+
expiresAt,
|
|
1683
|
+
stale: false,
|
|
1684
|
+
};
|
|
1685
|
+
await this.storage.setItem(this.cacheStorageKey(cacheKey), entry);
|
|
1686
|
+
await this.linkTags(cacheKey, tags);
|
|
1687
|
+
}
|
|
1688
|
+
/** @inheritdoc */
|
|
1689
|
+
async invalidate(filter) {
|
|
1690
|
+
const tags = filter.tags ?? [];
|
|
1691
|
+
if (tags.length === 0) {
|
|
1692
|
+
return 0;
|
|
1693
|
+
}
|
|
1694
|
+
const keys = await this.resolveCacheKeysFromTags(tags);
|
|
1695
|
+
let marked = 0;
|
|
1696
|
+
for (const cacheKey of keys) {
|
|
1697
|
+
const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey));
|
|
1698
|
+
if (!entry) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (!entry.stale) {
|
|
1702
|
+
await this.storage.setItem(this.cacheStorageKey(cacheKey), { ...entry, stale: true });
|
|
1703
|
+
}
|
|
1704
|
+
marked++;
|
|
1705
|
+
}
|
|
1706
|
+
return marked;
|
|
1707
|
+
}
|
|
1708
|
+
/** @inheritdoc */
|
|
1709
|
+
async delete(cacheKey) {
|
|
1710
|
+
const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey));
|
|
1711
|
+
if (!entry) {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
await this.unlinkTags(cacheKey, entry.tags);
|
|
1715
|
+
await this.storage.removeItem(this.cacheStorageKey(cacheKey));
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
/** @inheritdoc */
|
|
1719
|
+
async flush() {
|
|
1720
|
+
await this.storage.clear();
|
|
1721
|
+
}
|
|
1722
|
+
/** @inheritdoc */
|
|
1723
|
+
async entries() {
|
|
1724
|
+
const keys = await this.storage.getKeys(CACHE_KEY_PREFIX);
|
|
1725
|
+
const out = [];
|
|
1726
|
+
for (const cacheKey of keys) {
|
|
1727
|
+
const entry = await this.storage.getItem(cacheKey);
|
|
1728
|
+
if (!entry) {
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
out.push({
|
|
1732
|
+
key: cacheKey,
|
|
1733
|
+
tags: [...entry.tags],
|
|
1734
|
+
storedAt: entry.storedAt,
|
|
1735
|
+
expiresAt: entry.expiresAt,
|
|
1736
|
+
stale: entry.stale,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
return out;
|
|
1740
|
+
}
|
|
1741
|
+
/** @inheritdoc */
|
|
1742
|
+
enabled() {
|
|
1743
|
+
return this._config.enabled;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Cache entry storage key (OSR-aligned `sc:loader:…`).
|
|
1747
|
+
* @param {string} cacheKey - Public loader cache key.
|
|
1748
|
+
* @returns {string} Unstorage key for the entry payload.
|
|
1749
|
+
*/
|
|
1750
|
+
cacheStorageKey(cacheKey) {
|
|
1751
|
+
return cacheKey;
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Tag index storage key (`tag:{tag}`).
|
|
1755
|
+
* @param {string} tag - OSR cache tag.
|
|
1756
|
+
* @returns {string} Unstorage key for the tag index bucket.
|
|
1757
|
+
*/
|
|
1758
|
+
tagStorageKey(tag) {
|
|
1759
|
+
return `${TAG_INDEX_PREFIX}${tag}`;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Links a cache key into each tag bucket.
|
|
1763
|
+
* @param {string} cacheKey - Cache entry key.
|
|
1764
|
+
* @param {string[]} tags - Tags to link.
|
|
1765
|
+
*/
|
|
1766
|
+
async linkTags(cacheKey, tags) {
|
|
1767
|
+
for (const tag of tags) {
|
|
1768
|
+
const storageKey = this.tagStorageKey(tag);
|
|
1769
|
+
const current = (await this.storage.getItem(storageKey)) ?? [];
|
|
1770
|
+
if (!current.includes(cacheKey)) {
|
|
1771
|
+
await this.storage.setItem(storageKey, [...current, cacheKey]);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Unlinks a cache key from each tag bucket.
|
|
1777
|
+
* @param {string} cacheKey - Cache entry key.
|
|
1778
|
+
* @param {string[]} tags - Tags to unlink.
|
|
1779
|
+
*/
|
|
1780
|
+
async unlinkTags(cacheKey, tags) {
|
|
1781
|
+
for (const tag of tags) {
|
|
1782
|
+
const storageKey = this.tagStorageKey(tag);
|
|
1783
|
+
const current = (await this.storage.getItem(storageKey)) ?? [];
|
|
1784
|
+
const next = current.filter((k) => k !== cacheKey);
|
|
1785
|
+
if (next.length === 0) {
|
|
1786
|
+
await this.storage.removeItem(storageKey);
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
await this.storage.setItem(storageKey, next);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/** @param {string[]} tags - Tags to resolve. @returns {Promise<Set<string>>} Matching cache keys. */
|
|
1794
|
+
async resolveCacheKeysFromTags(tags) {
|
|
1795
|
+
const out = new Set();
|
|
1796
|
+
for (const tag of tags) {
|
|
1797
|
+
const keys = (await this.storage.getItem(this.tagStorageKey(tag))) ?? [];
|
|
1798
|
+
for (const key of keys) {
|
|
1799
|
+
out.add(key);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return out;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Public factory for the loader cache with unstorage backing.
|
|
1808
|
+
* Uses the memory driver by default.
|
|
1809
|
+
*
|
|
1810
|
+
* Drivers are best imported and constructed in the app's `server.ts` and passed here as an instance.
|
|
1811
|
+
* Callers depend on the {@link LoaderCache} interface; concrete classes are not exported.
|
|
1812
|
+
* @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver.
|
|
1813
|
+
* @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics.
|
|
1814
|
+
* @example
|
|
1815
|
+
* ```ts
|
|
1816
|
+
* const cache = createLoaderCache({
|
|
1817
|
+
* revalidate: config.angular.loadersCache.revalidate,
|
|
1818
|
+
* enabled: config.angular.loadersCache.enabled,
|
|
1819
|
+
* defaultSiteName: config.defaultSite,
|
|
1820
|
+
* driver: fsDriver({ base: './.cache/loaders' }),
|
|
1821
|
+
* });
|
|
1822
|
+
* ```
|
|
1823
|
+
* @public
|
|
1824
|
+
*/
|
|
1825
|
+
function createLoaderCache(config = {}) {
|
|
1826
|
+
const resolved = resolveConfig(config);
|
|
1827
|
+
const driver = config.driver ?? memoryDriver();
|
|
1828
|
+
return new UnstorageLoaderCache(driver, resolved);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const DEFAULT_ENDPOINT = '/api/_cache';
|
|
1832
|
+
/**
|
|
1833
|
+
* Lightweight admin surface for the loader cache:
|
|
1834
|
+
* GET <endpoint>/entries → list entries (metadata only, no values)
|
|
1835
|
+
* POST <endpoint>/invalidate → mark stale by tags (JSON body)
|
|
1836
|
+
* POST <endpoint>/flush → flush every entry
|
|
1837
|
+
* GET <endpoint>/config → resolved config (for the demo UI)
|
|
1838
|
+
* @public
|
|
1839
|
+
*/
|
|
1840
|
+
function createCacheAdminMiddleware(options) {
|
|
1841
|
+
const { cache } = options;
|
|
1842
|
+
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
1843
|
+
const auth = options.auth ?? (() => true);
|
|
1844
|
+
return async (req, res, next) => {
|
|
1845
|
+
if (!req.path.startsWith(endpoint + '/')) {
|
|
1846
|
+
next();
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (!auth(req)) {
|
|
1850
|
+
res.status(403).json({ error: 'forbidden' });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const action = req.path.slice(endpoint.length + 1);
|
|
1854
|
+
try {
|
|
1855
|
+
if (action === 'entries' && req.method === 'GET') {
|
|
1856
|
+
const entries = await cache.entries();
|
|
1857
|
+
res.status(200).json({ entries, now: Date.now() });
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (action === 'config' && req.method === 'GET') {
|
|
1861
|
+
res.status(200).json({ ...cache.config });
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (action === 'invalidate' && req.method === 'POST') {
|
|
1865
|
+
const body = (req.body ?? {});
|
|
1866
|
+
const hasTags = Array.isArray(body.tags) && body.tags.length > 0;
|
|
1867
|
+
if (!hasTags) {
|
|
1868
|
+
res.status(400).json({ error: 'non-empty `tags` array is required' });
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
const marked = await cache.invalidate(body);
|
|
1872
|
+
res.status(200).json({ marked });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (action === 'flush' && req.method === 'POST') {
|
|
1876
|
+
await cache.flush();
|
|
1877
|
+
res.status(200).json({ ok: true });
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
res.status(404).json({ error: `unknown cache admin action: ${action}` });
|
|
1881
|
+
}
|
|
1882
|
+
catch (err) {
|
|
1883
|
+
const message = err instanceof Error ? err.message : 'cache admin error';
|
|
1884
|
+
res.status(500).json({ error: message });
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Configuration
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Walk the activated route tree and merge `data` from every snapshot node.
|
|
1893
|
+
* @param {Router} router - Angular Router instance
|
|
1894
|
+
* @returns {SitecoreRouteData} Merged route data
|
|
1895
|
+
*/
|
|
1896
|
+
function getMergedRouteData(router) {
|
|
1897
|
+
const merged = {};
|
|
1898
|
+
const stack = [router.routerState.snapshot.root];
|
|
1899
|
+
while (stack.length > 0) {
|
|
1900
|
+
const route = stack.pop();
|
|
1901
|
+
Object.assign(merged, route.data);
|
|
1902
|
+
stack.push(...route.children);
|
|
1903
|
+
}
|
|
1904
|
+
return merged;
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Request-scoped Sitecore context derived reactively from the Angular Router.
|
|
1908
|
+
*
|
|
1909
|
+
* - `page` / `dictionary` — from route resolve data (`loaderResolver('page'|'dictionary')`)
|
|
1910
|
+
* - `urlLocale` — from current pathname (SSR REQUEST / window.location, then NavigationEnd)
|
|
1911
|
+
* - `isEditing` / `effectiveLocale` — computed from page + urlLocale + config
|
|
1912
|
+
*
|
|
1913
|
+
* No manual `setPage` / `setDictionary` / `setLocale` wiring required in app components.
|
|
1914
|
+
* @public
|
|
1915
|
+
*/
|
|
1916
|
+
class SitecoreContextService {
|
|
1917
|
+
/** Current Sitecore page data (layout + mode). */
|
|
1918
|
+
page = computed(() => this.routeData()?.page ?? null, ...(ngDevMode ? [{ debugName: "page" }] : /* istanbul ignore next */ []));
|
|
1919
|
+
/** Current Sitecore dictionary data. */
|
|
1920
|
+
dictionary = computed(() => this.routeData()?.dictionary ?? null, ...(ngDevMode ? [{ debugName: "dictionary" }] : /* istanbul ignore next */ []));
|
|
1921
|
+
/** Whether the current page is in editing mode. */
|
|
1922
|
+
isEditing = computed(() => this.page()?.mode?.isEditing ?? false, ...(ngDevMode ? [{ debugName: "isEditing" }] : /* istanbul ignore next */ []));
|
|
1923
|
+
/**
|
|
1924
|
+
* Locale extracted from the current URL; `null` when no configured-locale prefix
|
|
1925
|
+
* or when locales are not configured.
|
|
1926
|
+
*/
|
|
1927
|
+
urlLocale = computed(() => {
|
|
1928
|
+
if (this.locales.length === 0) {
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
return splitLocaleFromPath(this.pathname(), this.locales).locale;
|
|
1932
|
+
}, ...(ngDevMode ? [{ debugName: "urlLocale" }] : /* istanbul ignore next */ []));
|
|
1933
|
+
/**
|
|
1934
|
+
* Effective locale for data fetching: `page.locale ?? urlLocale ?? defaultLanguage`.
|
|
1935
|
+
*/
|
|
1936
|
+
effectiveLocale = computed(() => this.page()?.locale ?? this.urlLocale() ?? this.defaultLanguage, ...(ngDevMode ? [{ debugName: "effectiveLocale" }] : /* istanbul ignore next */ []));
|
|
1937
|
+
router = inject(Router);
|
|
1938
|
+
config = inject(SITECORE_CONFIG_TOKEN, { optional: true });
|
|
1939
|
+
platformId = inject(PLATFORM_ID);
|
|
1940
|
+
req = inject(REQUEST, { optional: true });
|
|
1941
|
+
isBrowser = isPlatformBrowser(this.platformId);
|
|
1942
|
+
locales = this.config?.angular?.locales ?? [];
|
|
1943
|
+
defaultLanguage = this.config?.defaultLanguage ?? 'en';
|
|
1944
|
+
/** Merged resolve data; updates on every completed navigation. */
|
|
1945
|
+
routeData = toSignal(this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map(() => getMergedRouteData(this.router)), startWith(getMergedRouteData(this.router))), { initialValue: getMergedRouteData(this.router) });
|
|
1946
|
+
/**
|
|
1947
|
+
* Current pathname without query string.
|
|
1948
|
+
* SSR/bootstrap: REQUEST or window.location; client: NavigationEnd.urlAfterRedirects.
|
|
1949
|
+
*/
|
|
1950
|
+
pathname = toSignal(this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map((e) => e.urlAfterRedirects.split('?')[0]), startWith(resolveCurrentPath(this.req ?? null, this.isBrowser))), { initialValue: resolveCurrentPath(this.req ?? null, this.isBrowser) });
|
|
1951
|
+
static ɵfac = function SitecoreContextService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || SitecoreContextService)(); };
|
|
1952
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: SitecoreContextService, factory: SitecoreContextService.ɵfac, providedIn: 'root' });
|
|
1953
|
+
}
|
|
1954
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(SitecoreContextService, [{
|
|
1955
|
+
type: Injectable,
|
|
1956
|
+
args: [{ providedIn: 'root' }]
|
|
1957
|
+
}], null, null); })();
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* `ngx-translate` loader using Sitecore dictionary from {@link SitecoreContextService}.
|
|
1961
|
+
* Requires a `dictionaryLoader` resolver on the active route — without it, `dictionary()`
|
|
1962
|
+
* is `null` and translations resolve to `{}`.
|
|
1963
|
+
* @public
|
|
1964
|
+
*/
|
|
1965
|
+
class SitecoreTranslateLoader {
|
|
1966
|
+
context = inject(SitecoreContextService);
|
|
1967
|
+
/**
|
|
1968
|
+
* Returns the translation based on the dictionary in the context from {@link SitecoreContextService}.
|
|
1969
|
+
* @returns {Observable<Record<string, string>>} Observable of translation dictionary.
|
|
1970
|
+
*/
|
|
1971
|
+
getTranslation() {
|
|
1972
|
+
const dictionary = this.context.dictionary();
|
|
1973
|
+
return of(dictionary ?? {});
|
|
1974
|
+
}
|
|
1975
|
+
static ɵfac = function SitecoreTranslateLoader_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || SitecoreTranslateLoader)(); };
|
|
1976
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: SitecoreTranslateLoader, factory: SitecoreTranslateLoader.ɵfac });
|
|
1977
|
+
}
|
|
1978
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(SitecoreTranslateLoader, [{
|
|
1979
|
+
type: Injectable
|
|
1980
|
+
}], null, null); })();
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Locale-aware {@link UrlSerializer} replacement. Extends {@link DefaultUrlSerializer} and
|
|
1984
|
+
* prepends the current URL locale (from the request pathname) to every serialized
|
|
1985
|
+
* URL. Angular's built-in `[routerLink]` computes hrefs via `router.serializeUrl()`, which
|
|
1986
|
+
* delegates to the DI-injected `UrlSerializer.serialize()` — so replacing the binding makes
|
|
1987
|
+
* every routerLink href locale-aware with no directive changes.
|
|
1988
|
+
*
|
|
1989
|
+
* Behavior:
|
|
1990
|
+
* - When `currentLocale` is `null` (URL has no configured locale prefix), serialization is
|
|
1991
|
+
* unchanged.
|
|
1992
|
+
* - When the serialized URL already starts with a configured locale segment, serialization
|
|
1993
|
+
* is unchanged (mirrors ScLinkDirective idempotency under repeated cycles).
|
|
1994
|
+
* - Otherwise the locale segment is prepended to the serialized URL.
|
|
1995
|
+
*
|
|
1996
|
+
* Parsing is inherited from the default — this serializer does **not** strip locale on
|
|
1997
|
+
* parse. The locale matcher (`scLocaleMatcher`) consumes the locale segment from the
|
|
1998
|
+
* route tree instead.
|
|
1999
|
+
* @public
|
|
2000
|
+
*/
|
|
2001
|
+
class LocaleUrlSerializer extends DefaultUrlSerializer {
|
|
2002
|
+
locales = inject(SITECORE_CONFIG_TOKEN, { optional: true })?.angular?.locales ?? [];
|
|
2003
|
+
req = inject(REQUEST, { optional: true });
|
|
2004
|
+
platformId = inject(PLATFORM_ID);
|
|
2005
|
+
serialize(tree) {
|
|
2006
|
+
const base = super.serialize(tree);
|
|
2007
|
+
if (this.locales.length > 0 && splitLocaleFromPath(base, this.locales).locale) {
|
|
2008
|
+
return base;
|
|
2009
|
+
}
|
|
2010
|
+
const isBrowser = isPlatformBrowser(this.platformId);
|
|
2011
|
+
const currentLocale = splitLocaleFromPath(resolveCurrentPath(this.req, isBrowser), this.locales).locale;
|
|
2012
|
+
if (!currentLocale) {
|
|
2013
|
+
return base;
|
|
2014
|
+
}
|
|
2015
|
+
return getLocaleRewrite(base, currentLocale);
|
|
2016
|
+
}
|
|
2017
|
+
static ɵfac = /*@__PURE__*/ (() => { let ɵLocaleUrlSerializer_BaseFactory; return function LocaleUrlSerializer_Factory(__ngFactoryType__) { return (ɵLocaleUrlSerializer_BaseFactory || (ɵLocaleUrlSerializer_BaseFactory = i0.ɵɵgetInheritedFactory(LocaleUrlSerializer)))(__ngFactoryType__ || LocaleUrlSerializer); }; })();
|
|
2018
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: LocaleUrlSerializer, factory: LocaleUrlSerializer.ɵfac });
|
|
2019
|
+
}
|
|
2020
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(LocaleUrlSerializer, [{
|
|
2021
|
+
type: Injectable
|
|
2022
|
+
}], null, null); })();
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* SXA uses a special export name to identify the "default" variant.
|
|
2026
|
+
* @public
|
|
2027
|
+
*/
|
|
2028
|
+
const DEFAULT_EXPORT_NAME = 'Default';
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* Injection token for the Sitecore component map.
|
|
2032
|
+
* Provide this at the application level via `provideSitecoreAngular` or
|
|
2033
|
+
* directly as `{ provide: SITECORE_COMPONENT_MAP, useValue: yourMap }`.
|
|
2034
|
+
* @public
|
|
2035
|
+
*/
|
|
2036
|
+
const SITECORE_COMPONENT_MAP = new InjectionToken('SITECORE_COMPONENT_MAP');
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Get the renderings for the specified placeholder from the rendering layout data.
|
|
2040
|
+
* Includes dynamic placeholder handling aligned with React's implementation.
|
|
2041
|
+
* @param {ComponentRendering | RouteData} rendering - rendering data
|
|
2042
|
+
* @param {string} name - placeholder name
|
|
2043
|
+
* @param {boolean} isEditing - whether editing mode is active
|
|
2044
|
+
* @returns {ComponentRendering[]} Child renderings for the placeholder.
|
|
2045
|
+
*/
|
|
2046
|
+
const getPlaceholderRenderings = (rendering, name, isEditing) => {
|
|
2047
|
+
let phName = name.slice();
|
|
2048
|
+
let placeholdersForRead;
|
|
2049
|
+
if (rendering?.placeholders) {
|
|
2050
|
+
if (isEditing) {
|
|
2051
|
+
Object.keys(rendering.placeholders).forEach((key) => {
|
|
2052
|
+
const patternPlaceholder = isDynamicPlaceholder(key)
|
|
2053
|
+
? getDynamicPlaceholderPattern(key)
|
|
2054
|
+
: null;
|
|
2055
|
+
if (patternPlaceholder && patternPlaceholder.test(phName)) {
|
|
2056
|
+
phName = key;
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
placeholdersForRead = rendering.placeholders;
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
placeholdersForRead = { ...rendering.placeholders };
|
|
2063
|
+
Object.entries(rendering.placeholders).forEach(([key, value]) => {
|
|
2064
|
+
const patternPlaceholder = isDynamicPlaceholder(key)
|
|
2065
|
+
? getDynamicPlaceholderPattern(key)
|
|
2066
|
+
: null;
|
|
2067
|
+
if (patternPlaceholder && patternPlaceholder.test(phName)) {
|
|
2068
|
+
placeholdersForRead[phName] = value;
|
|
2069
|
+
delete placeholdersForRead[key];
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
let result = null;
|
|
2075
|
+
if (rendering && placeholdersForRead && Object.keys(placeholdersForRead).length > 0) {
|
|
2076
|
+
result = placeholdersForRead[phName] ?? null;
|
|
2077
|
+
}
|
|
2078
|
+
if (!result) {
|
|
2079
|
+
console.warn(`Placeholder '${phName}' was not found in the current rendering data`, JSON.stringify(rendering, null, 2));
|
|
2080
|
+
return [];
|
|
2081
|
+
}
|
|
2082
|
+
return result;
|
|
2083
|
+
};
|
|
2084
|
+
/**
|
|
2085
|
+
* Get SXA specific params from Sitecore rendering params.
|
|
2086
|
+
* @param {ComponentRendering} rendering - Rendering object.
|
|
2087
|
+
* @returns {{ Styles: string } | undefined} Converted SXA params, or `undefined` when none apply.
|
|
2088
|
+
*/
|
|
2089
|
+
const getSXAParams = (rendering) => {
|
|
2090
|
+
if (!rendering.params)
|
|
2091
|
+
return { Styles: '' };
|
|
2092
|
+
const { GridParameters, Styles } = rendering.params;
|
|
2093
|
+
if (GridParameters || Styles) {
|
|
2094
|
+
return { Styles: `${GridParameters || ''} ${Styles || ''}` };
|
|
2095
|
+
}
|
|
2096
|
+
return undefined;
|
|
2097
|
+
};
|
|
2098
|
+
/**
|
|
2099
|
+
* Merge placeholder-level fields/params with per-component fields/params.
|
|
2100
|
+
* @param {{ [key: string]: unknown } | undefined} placeholderFields - Placeholder-level fields.
|
|
2101
|
+
* @param {{ [key: string]: string } | undefined} placeholderParams - Placeholder-level params.
|
|
2102
|
+
* @param {ComponentRendering} componentRendering - The component rendering data.
|
|
2103
|
+
* @returns {ChildComponentProps} Merged child component props.
|
|
2104
|
+
*/
|
|
2105
|
+
function getChildComponentProps(placeholderFields, placeholderParams, componentRendering) {
|
|
2106
|
+
const fields = { ...(placeholderFields || {}), ...(componentRendering.fields || {}) };
|
|
2107
|
+
const params = { ...(placeholderParams || {}), ...(componentRendering.params || {}) };
|
|
2108
|
+
const sxa = getSXAParams(componentRendering);
|
|
2109
|
+
return {
|
|
2110
|
+
fields,
|
|
2111
|
+
params: {
|
|
2112
|
+
...params,
|
|
2113
|
+
...(sxa || {}),
|
|
2114
|
+
},
|
|
2115
|
+
rendering: componentRendering,
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Resolve a component type for a rendering definition.
|
|
2120
|
+
* Handles hidden renderings, missing components, variant selection, and map lookup.
|
|
2121
|
+
* FEaaS/BYOC are intentionally not handled; they fall through to missingComponent.
|
|
2122
|
+
* @param {ComponentRendering} renderingDefinition - The rendering to resolve.
|
|
2123
|
+
* @param {string} placeholderName - Current placeholder name (for logging).
|
|
2124
|
+
* @param {ComponentMap | undefined} componentMap - The app component map.
|
|
2125
|
+
* @param {Type<unknown> | undefined} hiddenRenderingComponent - Optional override for hidden renderings.
|
|
2126
|
+
* @param {Type<unknown> | undefined} missingComponentComponent - Optional override for missing/unknown components.
|
|
2127
|
+
* @returns {ComponentForRendering} Resolved component info.
|
|
2128
|
+
*/
|
|
2129
|
+
const resolveComponentForRendering = (renderingDefinition, placeholderName, componentMap, hiddenRenderingComponent, missingComponentComponent) => {
|
|
2130
|
+
if (renderingDefinition.componentName === HIDDEN_RENDERING_NAME) {
|
|
2131
|
+
return {
|
|
2132
|
+
component: hiddenRenderingComponent ?? null,
|
|
2133
|
+
isEmpty: true,
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
if (!renderingDefinition.componentName) {
|
|
2137
|
+
return {
|
|
2138
|
+
component: null,
|
|
2139
|
+
isEmpty: true,
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
let entry;
|
|
2143
|
+
const hasComponentMap = !!(componentMap && componentMap.size > 0);
|
|
2144
|
+
if (!hasComponentMap) {
|
|
2145
|
+
console.warn(`No components were available in component map to service request for component ${renderingDefinition.componentName}`);
|
|
2146
|
+
}
|
|
2147
|
+
else {
|
|
2148
|
+
entry = componentMap.get(renderingDefinition.componentName);
|
|
2149
|
+
}
|
|
2150
|
+
if (!entry) {
|
|
2151
|
+
console.error(`Placeholder ${placeholderName} contains unknown component ${renderingDefinition.componentName}. Ensure that an Angular component exists for it, and that it is registered in your component map.`);
|
|
2152
|
+
return {
|
|
2153
|
+
component: missingComponentComponent ?? null,
|
|
2154
|
+
isEmpty: true,
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
// If entry is a direct component class (function / class constructor), return it
|
|
2158
|
+
if (typeof entry === 'function') {
|
|
2159
|
+
return { component: entry, isEmpty: false };
|
|
2160
|
+
}
|
|
2161
|
+
// AngularCsdkComponent (SXA variants): pick export by FieldNames
|
|
2162
|
+
const exportName = renderingDefinition.params?.FieldNames;
|
|
2163
|
+
const resolved = exportName && exportName !== DEFAULT_EXPORT_NAME
|
|
2164
|
+
? entry[exportName]
|
|
2165
|
+
: entry.default || entry.Default;
|
|
2166
|
+
if (!resolved || typeof resolved !== 'function') {
|
|
2167
|
+
const variantLabel = exportName && exportName !== DEFAULT_EXPORT_NAME ? ` (${exportName})` : '';
|
|
2168
|
+
console.error(`Placeholder ${placeholderName} contains unknown component ${renderingDefinition.componentName}${variantLabel}. Ensure that an Angular component exists for it, and that it is registered in your component map.`);
|
|
2169
|
+
return {
|
|
2170
|
+
component: missingComponentComponent ?? null,
|
|
2171
|
+
isEmpty: true,
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
return { component: resolved, isEmpty: false };
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Default component rendered when a Sitecore rendering has no matching entry in the component map.
|
|
2179
|
+
* @public
|
|
2180
|
+
*/
|
|
2181
|
+
class ScMissingComponentComponent {
|
|
2182
|
+
rendering = input(...(ngDevMode ? [undefined, { debugName: "rendering" }] : /* istanbul ignore next */ []));
|
|
2183
|
+
fields = input(...(ngDevMode ? [undefined, { debugName: "fields" }] : /* istanbul ignore next */ []));
|
|
2184
|
+
params = input(...(ngDevMode ? [undefined, { debugName: "params" }] : /* istanbul ignore next */ []));
|
|
2185
|
+
componentName = () => {
|
|
2186
|
+
const r = this.rendering();
|
|
2187
|
+
return r?.componentName || 'Unnamed Component';
|
|
2188
|
+
};
|
|
2189
|
+
static ɵfac = function ScMissingComponentComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScMissingComponentComponent)(); };
|
|
2190
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ScMissingComponentComponent, selectors: [["sc-missing-component"]], inputs: { rendering: [1, "rendering"], fields: [1, "fields"], params: [1, "params"] }, decls: 5, vars: 1, consts: [[2, "background", "darkorange", "outline", "5px solid orange", "padding", "10px", "color", "white", "max-width", "500px"]], template: function ScMissingComponentComponent_Template(rf, ctx) { if (rf & 1) {
|
|
2191
|
+
i0.ɵɵdomElementStart(0, "div", 0)(1, "h2");
|
|
2192
|
+
i0.ɵɵtext(2);
|
|
2193
|
+
i0.ɵɵdomElementEnd();
|
|
2194
|
+
i0.ɵɵdomElementStart(3, "p");
|
|
2195
|
+
i0.ɵɵtext(4, "Content SDK component is missing an Angular implementation. See the developer console for more information.");
|
|
2196
|
+
i0.ɵɵdomElementEnd()();
|
|
2197
|
+
} if (rf & 2) {
|
|
2198
|
+
i0.ɵɵadvance(2);
|
|
2199
|
+
i0.ɵɵtextInterpolate(ctx.componentName());
|
|
2200
|
+
} }, encapsulation: 2 });
|
|
2201
|
+
}
|
|
2202
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScMissingComponentComponent, [{
|
|
2203
|
+
type: Component,
|
|
2204
|
+
args: [{
|
|
2205
|
+
selector: 'sc-missing-component',
|
|
2206
|
+
template: `
|
|
2207
|
+
<div style="background: darkorange; outline: 5px solid orange; padding: 10px; color: white; max-width: 500px;">
|
|
2208
|
+
<h2>{{ componentName() }}</h2>
|
|
2209
|
+
<p>Content SDK component is missing an Angular implementation. See the developer console for more information.</p>
|
|
2210
|
+
</div>
|
|
2211
|
+
`,
|
|
2212
|
+
}]
|
|
2213
|
+
}], null, { rendering: [{ type: i0.Input, args: [{ isSignal: true, alias: "rendering", required: false }] }], fields: [{ type: i0.Input, args: [{ isSignal: true, alias: "fields", required: false }] }], params: [{ type: i0.Input, args: [{ isSignal: true, alias: "params", required: false }] }] }); })();
|
|
2214
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ScMissingComponentComponent, { className: "ScMissingComponentComponent", filePath: "components/sc-missing-component.component.ts", lineNumber: 17 }); })();
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Default component rendered for hidden Sitecore renderings.
|
|
2218
|
+
* @public
|
|
2219
|
+
*/
|
|
2220
|
+
class ScHiddenRenderingComponent {
|
|
2221
|
+
rendering = input(...(ngDevMode ? [undefined, { debugName: "rendering" }] : /* istanbul ignore next */ []));
|
|
2222
|
+
fields = input(...(ngDevMode ? [undefined, { debugName: "fields" }] : /* istanbul ignore next */ []));
|
|
2223
|
+
params = input(...(ngDevMode ? [undefined, { debugName: "params" }] : /* istanbul ignore next */ []));
|
|
2224
|
+
styles = {
|
|
2225
|
+
background: 'repeating-linear-gradient(135deg, #fff, #fff 10px, #f0f0f0 10px, #f0f0f0 20px)',
|
|
2226
|
+
minHeight: '30px',
|
|
2227
|
+
border: '1px dashed #ccc',
|
|
2228
|
+
padding: '10px',
|
|
2229
|
+
opacity: 0.7,
|
|
2230
|
+
};
|
|
2231
|
+
static ɵfac = function ScHiddenRenderingComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScHiddenRenderingComponent)(); };
|
|
2232
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ScHiddenRenderingComponent, selectors: [["sc-hidden-rendering"]], inputs: { rendering: [1, "rendering"], fields: [1, "fields"], params: [1, "params"] }, decls: 2, vars: 2, template: function ScHiddenRenderingComponent_Template(rf, ctx) { if (rf & 1) {
|
|
2233
|
+
i0.ɵɵdomElementStart(0, "div");
|
|
2234
|
+
i0.ɵɵtext(1, "The component is hidden");
|
|
2235
|
+
i0.ɵɵdomElementEnd();
|
|
2236
|
+
} if (rf & 2) {
|
|
2237
|
+
i0.ɵɵstyleMap(ctx.styles);
|
|
2238
|
+
} }, encapsulation: 2 });
|
|
2239
|
+
}
|
|
2240
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScHiddenRenderingComponent, [{
|
|
2241
|
+
type: Component,
|
|
2242
|
+
args: [{
|
|
2243
|
+
selector: 'sc-hidden-rendering',
|
|
2244
|
+
template: `<div [style]="styles">The component is hidden</div>`,
|
|
2245
|
+
}]
|
|
2246
|
+
}], null, { rendering: [{ type: i0.Input, args: [{ isSignal: true, alias: "rendering", required: false }] }], fields: [{ type: i0.Input, args: [{ isSignal: true, alias: "fields", required: false }] }], params: [{ type: i0.Input, args: [{ isSignal: true, alias: "params", required: false }] }] }); })();
|
|
2247
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ScHiddenRenderingComponent, { className: "ScHiddenRenderingComponent", filePath: "components/sc-hidden-rendering.component.ts", lineNumber: 12 }); })();
|
|
2248
|
+
|
|
2249
|
+
const _c0$1 = ["container"];
|
|
2250
|
+
/**
|
|
2251
|
+
* Angular placeholder component. Renders components from layout data for a given placeholder name.
|
|
2252
|
+
*
|
|
2253
|
+
* Usage:
|
|
2254
|
+
* ```html
|
|
2255
|
+
* <sc-placeholder name="headless-main" [rendering]="route"></sc-placeholder>
|
|
2256
|
+
* ```
|
|
2257
|
+
*
|
|
2258
|
+
* Optional `[passThroughProps]` sets extra `input()` values on each child (merged after `fields`, `params`, and `rendering`).
|
|
2259
|
+
* @public
|
|
2260
|
+
*/
|
|
2261
|
+
class ScPlaceholderComponent {
|
|
2262
|
+
/** Name of the placeholder to render. */
|
|
2263
|
+
name = input.required(...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
|
|
2264
|
+
/** Rendering or route data containing placeholders. */
|
|
2265
|
+
rendering = input.required(...(ngDevMode ? [{ debugName: "rendering" }] : /* istanbul ignore next */ []));
|
|
2266
|
+
/** Optional placeholder-level fields merged into each child. */
|
|
2267
|
+
fields = input(...(ngDevMode ? [undefined, { debugName: "fields" }] : /* istanbul ignore next */ []));
|
|
2268
|
+
/** Optional placeholder-level params merged into each child's `params` input. */
|
|
2269
|
+
params = input(...(ngDevMode ? [undefined, { debugName: "params" }] : /* istanbul ignore next */ []));
|
|
2270
|
+
/**
|
|
2271
|
+
* Extra inputs to set on each dynamically created component, after the standard `fields`, `params`, and `rendering` inputs.
|
|
2272
|
+
* Keys must match `input()` names on the target components.
|
|
2273
|
+
*/
|
|
2274
|
+
passThroughProps = input({}, ...(ngDevMode ? [{ debugName: "passThroughProps" }] : /* istanbul ignore next */ []));
|
|
2275
|
+
/** Override component map (defaults to injected SITECORE_COMPONENT_MAP). */
|
|
2276
|
+
componentMap = input(...(ngDevMode ? [undefined, { debugName: "componentMap" }] : /* istanbul ignore next */ []));
|
|
2277
|
+
/** Override for missing component rendering. */
|
|
2278
|
+
missingComponent = input(...(ngDevMode ? [undefined, { debugName: "missingComponent" }] : /* istanbul ignore next */ []));
|
|
2279
|
+
/** Override for hidden rendering component. */
|
|
2280
|
+
hiddenRenderingComponent = input(...(ngDevMode ? [undefined, { debugName: "hiddenRenderingComponent" }] : /* istanbul ignore next */ []));
|
|
2281
|
+
context = inject(SitecoreContextService);
|
|
2282
|
+
contextComponentMap = inject(SITECORE_COMPONENT_MAP, { optional: true });
|
|
2283
|
+
injector = inject(Injector);
|
|
2284
|
+
containerRef = viewChild('container', { ...(ngDevMode ? { debugName: "containerRef" } : /* istanbul ignore next */ {}), read: ViewContainerRef });
|
|
2285
|
+
isEditing = computed(() => this.context.isEditing(), ...(ngDevMode ? [{ debugName: "isEditing" }] : /* istanbul ignore next */ []));
|
|
2286
|
+
constructor() {
|
|
2287
|
+
effect(() => {
|
|
2288
|
+
const container = this.containerRef();
|
|
2289
|
+
if (!container) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
const rendering = this.rendering();
|
|
2293
|
+
const name = this.name();
|
|
2294
|
+
const componentMap = this.componentMap() ??
|
|
2295
|
+
this.contextComponentMap ??
|
|
2296
|
+
new Map();
|
|
2297
|
+
const isEditing = this.isEditing();
|
|
2298
|
+
const renderings = getPlaceholderRenderings(rendering, name, isEditing);
|
|
2299
|
+
container.clear();
|
|
2300
|
+
if (renderings.length === 0) {
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
for (const componentRendering of renderings) {
|
|
2304
|
+
const { component } = resolveComponentForRendering(componentRendering, name, componentMap, this.hiddenRenderingComponent() ?? ScHiddenRenderingComponent, this.missingComponent() ?? ScMissingComponentComponent);
|
|
2305
|
+
if (!component) {
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
const childProps = getChildComponentProps(this.fields(), this.params(), componentRendering);
|
|
2309
|
+
const ref = container.createComponent(component, { injector: this.injector });
|
|
2310
|
+
this.trySetInput(ref, 'fields', childProps.fields);
|
|
2311
|
+
this.trySetInput(ref, 'params', childProps.params);
|
|
2312
|
+
this.trySetInput(ref, 'rendering', childProps.rendering);
|
|
2313
|
+
const passThrough = this.passThroughProps();
|
|
2314
|
+
if (passThrough && typeof passThrough === 'object') {
|
|
2315
|
+
for (const [inputName, value] of Object.entries(passThrough)) {
|
|
2316
|
+
this.trySetInput(ref, inputName, value);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
trySetInput(ref, inputName, value) {
|
|
2323
|
+
try {
|
|
2324
|
+
ref.setInput(inputName, value);
|
|
2325
|
+
}
|
|
2326
|
+
catch (e) {
|
|
2327
|
+
if (isDevMode()) {
|
|
2328
|
+
console.debug(`[sc-placeholder] Skipped input "${inputName}" — not declared on component`, e);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
static ɵfac = function ScPlaceholderComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScPlaceholderComponent)(); };
|
|
2333
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ScPlaceholderComponent, selectors: [["sc-placeholder"]], viewQuery: function ScPlaceholderComponent_Query(rf, ctx) { if (rf & 1) {
|
|
2334
|
+
i0.ɵɵviewQuerySignal(ctx.containerRef, _c0$1, 5, ViewContainerRef);
|
|
2335
|
+
} if (rf & 2) {
|
|
2336
|
+
i0.ɵɵqueryAdvance();
|
|
2337
|
+
} }, inputs: { name: [1, "name"], rendering: [1, "rendering"], fields: [1, "fields"], params: [1, "params"], passThroughProps: [1, "passThroughProps"], componentMap: [1, "componentMap"], missingComponent: [1, "missingComponent"], hiddenRenderingComponent: [1, "hiddenRenderingComponent"] }, decls: 2, vars: 0, consts: [["container", ""]], template: function ScPlaceholderComponent_Template(rf, ctx) { if (rf & 1) {
|
|
2338
|
+
i0.ɵɵdomElementContainer(0, null, 0);
|
|
2339
|
+
} }, dependencies: [CommonModule], encapsulation: 2 });
|
|
2340
|
+
}
|
|
2341
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScPlaceholderComponent, [{
|
|
2342
|
+
type: Component,
|
|
2343
|
+
args: [{
|
|
2344
|
+
selector: 'sc-placeholder',
|
|
2345
|
+
imports: [CommonModule],
|
|
2346
|
+
template: `<ng-container #container></ng-container>`,
|
|
2347
|
+
}]
|
|
2348
|
+
}], () => [], { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], rendering: [{ type: i0.Input, args: [{ isSignal: true, alias: "rendering", required: true }] }], fields: [{ type: i0.Input, args: [{ isSignal: true, alias: "fields", required: false }] }], params: [{ type: i0.Input, args: [{ isSignal: true, alias: "params", required: false }] }], passThroughProps: [{ type: i0.Input, args: [{ isSignal: true, alias: "passThroughProps", required: false }] }], componentMap: [{ type: i0.Input, args: [{ isSignal: true, alias: "componentMap", required: false }] }], missingComponent: [{ type: i0.Input, args: [{ isSignal: true, alias: "missingComponent", required: false }] }], hiddenRenderingComponent: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenRenderingComponent", required: false }] }], containerRef: [{ type: i0.ViewChild, args: ['container', { ...{ read: ViewContainerRef }, isSignal: true }] }] }); })();
|
|
2349
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ScPlaceholderComponent, { className: "ScPlaceholderComponent", filePath: "components/placeholder/sc-placeholder.component.ts", lineNumber: 44 }); })();
|
|
2350
|
+
|
|
2351
|
+
const _c0 = ["formContainer"];
|
|
2352
|
+
const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form;
|
|
2353
|
+
/* eslint-disable @typescript-eslint/member-ordering -- ViewChild + signal inputs + constructor ordering conflicts with default groups */
|
|
2354
|
+
/**
|
|
2355
|
+
* Angular wrapper for Sitecore Forms.
|
|
2356
|
+
* Loads form HTML from Edge, executes embedded scripts, and subscribes to form events.
|
|
2357
|
+
*
|
|
2358
|
+
* Usage: register in the component map with the rendering name "Form".
|
|
2359
|
+
* @public
|
|
2360
|
+
*/
|
|
2361
|
+
class ScFormComponent {
|
|
2362
|
+
formContainerRef;
|
|
2363
|
+
rendering = input(...(ngDevMode ? [undefined, { debugName: "rendering" }] : /* istanbul ignore next */ []));
|
|
2364
|
+
params = input({}, ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
|
|
2365
|
+
config = inject(SITECORE_CONFIG_TOKEN, { optional: true });
|
|
2366
|
+
context = inject(SitecoreContextService);
|
|
2367
|
+
platformId = inject(PLATFORM_ID);
|
|
2368
|
+
destroyRef = inject(DestroyRef);
|
|
2369
|
+
/**
|
|
2370
|
+
* Merges `rendering.params` with the `params` input: the component `params()` values override layout for the same key.
|
|
2371
|
+
*/
|
|
2372
|
+
mergedFormParams() {
|
|
2373
|
+
return { ...(this.rendering()?.params ?? {}), ...this.params() };
|
|
2374
|
+
}
|
|
2375
|
+
constructor() {
|
|
2376
|
+
afterNextRender(() => {
|
|
2377
|
+
if (!isPlatformBrowser(this.platformId))
|
|
2378
|
+
return;
|
|
2379
|
+
const p = this.mergedFormParams();
|
|
2380
|
+
const formId = p.FormId;
|
|
2381
|
+
if (!formId)
|
|
2382
|
+
return;
|
|
2383
|
+
const cfg = this.config;
|
|
2384
|
+
const edgeId = cfg?.api?.edge?.clientContextId;
|
|
2385
|
+
const edgeUrl = cfg?.api?.edge?.edgeUrl;
|
|
2386
|
+
if (!edgeId) {
|
|
2387
|
+
console.warn('Warning: clientContextId is missing – form cannot be loaded properly on the client');
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
let cancelled = false;
|
|
2391
|
+
this.destroyRef.onDestroy(() => {
|
|
2392
|
+
cancelled = true;
|
|
2393
|
+
});
|
|
2394
|
+
loadForm(edgeId, formId, edgeUrl)
|
|
2395
|
+
.then((html) => {
|
|
2396
|
+
if (cancelled)
|
|
2397
|
+
return;
|
|
2398
|
+
const el = this.formContainerRef?.nativeElement;
|
|
2399
|
+
if (!el)
|
|
2400
|
+
return;
|
|
2401
|
+
el.innerHTML = html;
|
|
2402
|
+
const isEditing = this.context.isEditing();
|
|
2403
|
+
if (!isEditing) {
|
|
2404
|
+
subscribeToFormSubmitEvent(el, this.rendering()?.uid);
|
|
2405
|
+
}
|
|
2406
|
+
executeScriptElements(el);
|
|
2407
|
+
})
|
|
2408
|
+
.catch(() => {
|
|
2409
|
+
console.error(`Failed to load form with id ${formId}. Check debug logs for content-sdk:form for more details.`);
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
styles = () => {
|
|
2414
|
+
const p = this.mergedFormParams();
|
|
2415
|
+
const s = p.styles;
|
|
2416
|
+
return s ? s.replace(/\s+$/, '') : '';
|
|
2417
|
+
};
|
|
2418
|
+
renderingId = () => {
|
|
2419
|
+
const p = this.mergedFormParams();
|
|
2420
|
+
return p.RenderingIdentifier || undefined;
|
|
2421
|
+
};
|
|
2422
|
+
static ɵfac = function ScFormComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScFormComponent)(); };
|
|
2423
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ScFormComponent, selectors: [["sc-form"]], viewQuery: function ScFormComponent_Query(rf, ctx) { if (rf & 1) {
|
|
2424
|
+
i0.ɵɵviewQuery(_c0, 7);
|
|
2425
|
+
} if (rf & 2) {
|
|
2426
|
+
let _t;
|
|
2427
|
+
i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.formContainerRef = _t.first);
|
|
2428
|
+
} }, inputs: { rendering: [1, "rendering"], params: [1, "params"] }, decls: 2, vars: 3, consts: [["formContainer", ""], [3, "id"]], template: function ScFormComponent_Template(rf, ctx) { if (rf & 1) {
|
|
2429
|
+
i0.ɵɵdomElement(0, "div", 1, 0);
|
|
2430
|
+
} if (rf & 2) {
|
|
2431
|
+
i0.ɵɵclassMap(ctx.styles());
|
|
2432
|
+
i0.ɵɵdomProperty("id", ctx.renderingId());
|
|
2433
|
+
} }, encapsulation: 2 });
|
|
2434
|
+
}
|
|
2435
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScFormComponent, [{
|
|
2436
|
+
type: Component,
|
|
2437
|
+
args: [{
|
|
2438
|
+
selector: 'sc-form',
|
|
2439
|
+
template: ` <div #formContainer [class]="styles()" [id]="renderingId()"></div> `,
|
|
2440
|
+
}]
|
|
2441
|
+
}], () => [], { formContainerRef: [{
|
|
2442
|
+
type: ViewChild,
|
|
2443
|
+
args: ['formContainer', { static: true }]
|
|
2444
|
+
}], rendering: [{ type: i0.Input, args: [{ isSignal: true, alias: "rendering", required: false }] }], params: [{ type: i0.Input, args: [{ isSignal: true, alias: "params", required: false }] }] }); })();
|
|
2445
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ScFormComponent, { className: "ScFormComponent", filePath: "components/sc-form.component.ts", lineNumber: 31 }); })();
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* Renders a Sitecore text field value into the host element's text content.
|
|
2449
|
+
* For simple string/number fields in published mode.
|
|
2450
|
+
*
|
|
2451
|
+
* Usage:
|
|
2452
|
+
* ```html
|
|
2453
|
+
* <h1 [scText]="fields.Title"></h1>
|
|
2454
|
+
* <span [scText]="fields.Subtitle" scTextEncode="false"></span>
|
|
2455
|
+
* ```
|
|
2456
|
+
* @public
|
|
2457
|
+
*/
|
|
2458
|
+
class ScTextDirective {
|
|
2459
|
+
/** The Sitecore text field. */
|
|
2460
|
+
scText = input.required(...(ngDevMode ? [{ debugName: "scText" }] : /* istanbul ignore next */ []));
|
|
2461
|
+
/** Whether to HTML-encode the value (default: true). When false, uses innerHTML. */
|
|
2462
|
+
scTextEncode = input(true, ...(ngDevMode ? [{ debugName: "scTextEncode" }] : /* istanbul ignore next */ []));
|
|
2463
|
+
el = inject((ElementRef));
|
|
2464
|
+
renderer = inject(Renderer2);
|
|
2465
|
+
constructor() {
|
|
2466
|
+
effect(() => {
|
|
2467
|
+
const field = this.scText();
|
|
2468
|
+
const encode = this.scTextEncode();
|
|
2469
|
+
const element = this.el.nativeElement;
|
|
2470
|
+
if (!field || isFieldValueEmpty(field)) {
|
|
2471
|
+
this.renderer.setProperty(element, 'textContent', '');
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const value = String(field.value);
|
|
2475
|
+
if (encode) {
|
|
2476
|
+
this.renderer.setProperty(element, 'textContent', value);
|
|
2477
|
+
}
|
|
2478
|
+
else {
|
|
2479
|
+
this.renderer.setProperty(element, 'innerHTML', value);
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
static ɵfac = function ScTextDirective_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScTextDirective)(); };
|
|
2484
|
+
static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: ScTextDirective, selectors: [["", "scText", ""]], inputs: { scText: [1, "scText"], scTextEncode: [1, "scTextEncode"] } });
|
|
2485
|
+
}
|
|
2486
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScTextDirective, [{
|
|
2487
|
+
type: Directive,
|
|
2488
|
+
args: [{
|
|
2489
|
+
selector: '[scText]',
|
|
2490
|
+
}]
|
|
2491
|
+
}], () => [], { scText: [{ type: i0.Input, args: [{ isSignal: true, alias: "scText", required: true }] }], scTextEncode: [{ type: i0.Input, args: [{ isSignal: true, alias: "scTextEncode", required: false }] }] }); })();
|
|
2492
|
+
|
|
2493
|
+
/**
|
|
2494
|
+
* "className" property will be transformed into or appended to "class" instead.
|
|
2495
|
+
* @param {string} fieldAttrs all other props included on the image component
|
|
2496
|
+
* @returns {void}
|
|
2497
|
+
*/
|
|
2498
|
+
const getClassFromField = (fieldAttrs) => {
|
|
2499
|
+
if (fieldAttrs.className) {
|
|
2500
|
+
if (fieldAttrs.class) {
|
|
2501
|
+
let mergedClass = fieldAttrs.className;
|
|
2502
|
+
mergedClass += ` ${fieldAttrs.class}`;
|
|
2503
|
+
return mergedClass;
|
|
2504
|
+
}
|
|
2505
|
+
else {
|
|
2506
|
+
return fieldAttrs.className;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
return fieldAttrs.class;
|
|
2510
|
+
};
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* Renders a Sitecore image field onto a host `<img>` element.
|
|
2514
|
+
* Sets `src`, `alt`, and other attributes from the field data.
|
|
2515
|
+
*
|
|
2516
|
+
* Usage:
|
|
2517
|
+
* ```html
|
|
2518
|
+
* <img [scImage]="fields.Image" />
|
|
2519
|
+
* ```
|
|
2520
|
+
* @public
|
|
2521
|
+
*/
|
|
2522
|
+
class ScImageDirective {
|
|
2523
|
+
/** The Sitecore image field. */
|
|
2524
|
+
scImage = input.required(...(ngDevMode ? [{ debugName: "scImage" }] : /* istanbul ignore next */ []));
|
|
2525
|
+
/** Optional image params for media URL transformation. */
|
|
2526
|
+
imageParams = input(...(ngDevMode ? [undefined, { debugName: "imageParams" }] : /* istanbul ignore next */ []));
|
|
2527
|
+
/** Optional media URL prefix regexp. */
|
|
2528
|
+
mediaUrlPrefix = input(...(ngDevMode ? [undefined, { debugName: "mediaUrlPrefix" }] : /* istanbul ignore next */ []));
|
|
2529
|
+
el = inject((ElementRef));
|
|
2530
|
+
renderer = inject(Renderer2);
|
|
2531
|
+
constructor() {
|
|
2532
|
+
effect(() => {
|
|
2533
|
+
const field = this.scImage();
|
|
2534
|
+
const element = this.el.nativeElement;
|
|
2535
|
+
if (!field || isFieldValueEmpty(field)) {
|
|
2536
|
+
this.renderer.removeAttribute(element, 'src');
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const img = field.src
|
|
2540
|
+
? field
|
|
2541
|
+
: field.value;
|
|
2542
|
+
if (!img?.src) {
|
|
2543
|
+
this.renderer.removeAttribute(element, 'src');
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const params = this.imageParams();
|
|
2547
|
+
const prefix = this.mediaUrlPrefix();
|
|
2548
|
+
const resolvedSrc = mediaApi.updateImageUrl(img.src, params, prefix);
|
|
2549
|
+
this.renderer.setAttribute(element, 'src', resolvedSrc);
|
|
2550
|
+
const classValue = getClassFromField(img);
|
|
2551
|
+
if (classValue) {
|
|
2552
|
+
this.renderer.addClass(element, classValue);
|
|
2553
|
+
}
|
|
2554
|
+
if (img.alt !== undefined) {
|
|
2555
|
+
this.renderer.setAttribute(element, 'alt', img.alt);
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
this.renderer.removeAttribute(element, 'alt');
|
|
2559
|
+
}
|
|
2560
|
+
if (img.width !== undefined) {
|
|
2561
|
+
this.renderer.setAttribute(element, 'width', String(img.width));
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
this.renderer.removeAttribute(element, 'width');
|
|
2565
|
+
}
|
|
2566
|
+
if (img.height !== undefined) {
|
|
2567
|
+
this.renderer.setAttribute(element, 'height', String(img.height));
|
|
2568
|
+
}
|
|
2569
|
+
else {
|
|
2570
|
+
this.renderer.removeAttribute(element, 'height');
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
static ɵfac = function ScImageDirective_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScImageDirective)(); };
|
|
2575
|
+
static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: ScImageDirective, selectors: [["img", "scImage", ""]], inputs: { scImage: [1, "scImage"], imageParams: [1, "imageParams"], mediaUrlPrefix: [1, "mediaUrlPrefix"] } });
|
|
2576
|
+
}
|
|
2577
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScImageDirective, [{
|
|
2578
|
+
type: Directive,
|
|
2579
|
+
args: [{
|
|
2580
|
+
selector: 'img[scImage]',
|
|
2581
|
+
}]
|
|
2582
|
+
}], () => [], { scImage: [{ type: i0.Input, args: [{ isSignal: true, alias: "scImage", required: true }] }], imageParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "imageParams", required: false }] }], mediaUrlPrefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "mediaUrlPrefix", required: false }] }] }); })();
|
|
2583
|
+
|
|
2584
|
+
/**
|
|
2585
|
+
* Splits a CSS class string and applies each token via {@link Renderer2.addClass}.
|
|
2586
|
+
* @param {Renderer2} renderer - Angular renderer.
|
|
2587
|
+
* @param {HTMLElement} element - Target element.
|
|
2588
|
+
* @param {string} classString - Space-separated class names.
|
|
2589
|
+
* @returns {void}
|
|
2590
|
+
*/
|
|
2591
|
+
function addClassTokens(renderer, element, classString) {
|
|
2592
|
+
for (const token of classString.trim().split(/\s+/).filter(Boolean)) {
|
|
2593
|
+
renderer.addClass(element, token);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Normalizes a Sitecore link field input to a {@link LinkFieldValue}, or `undefined` when empty.
|
|
2598
|
+
* @param {LinkField | LinkFieldValue | undefined | null} field - Raw field or value from layout.
|
|
2599
|
+
* @returns {LinkFieldValue | undefined} Resolved link value, or `undefined` when empty.
|
|
2600
|
+
*/
|
|
2601
|
+
function resolveLinkFromField(field) {
|
|
2602
|
+
if (!field || isFieldValueEmpty(field)) {
|
|
2603
|
+
return undefined;
|
|
2604
|
+
}
|
|
2605
|
+
return field.href ? field : field.value;
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Builds the `href` string (path + query + hash fragment) from a link value.
|
|
2609
|
+
* @param {LinkFieldValue} link - Sitecore link field value.
|
|
2610
|
+
* @returns {string} Full `href` string for an anchor.
|
|
2611
|
+
*/
|
|
2612
|
+
function buildHrefFromLinkField(link) {
|
|
2613
|
+
const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : '';
|
|
2614
|
+
const querystring = link.querystring ? `?${link.querystring}` : '';
|
|
2615
|
+
return `${link.href || ''}${querystring}${anchor}`;
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Applies Sitecore link attributes and optional text to a host anchor (shared by ScLink / ScRouterLink),
|
|
2619
|
+
* or preserves original attributes and text when the field link is empty.
|
|
2620
|
+
* @param {Renderer2} renderer - Angular renderer.
|
|
2621
|
+
* @param {HTMLAnchorElement} element - Host anchor element.
|
|
2622
|
+
* @param {LinkFieldValue | undefined} link - Resolved link value, or `undefined` when empty.
|
|
2623
|
+
* @param {ApplyLinkFieldToAnchorOptions} options - Text/class/title/target behavior flags.
|
|
2624
|
+
* @returns {void}
|
|
2625
|
+
*/
|
|
2626
|
+
function applyLinkFieldToAnchor(renderer, element, link, options) {
|
|
2627
|
+
if (!link) {
|
|
2628
|
+
renderer.removeAttribute(element, 'href');
|
|
2629
|
+
}
|
|
2630
|
+
else {
|
|
2631
|
+
renderer.setAttribute(element, 'href', buildHrefFromLinkField(link));
|
|
2632
|
+
}
|
|
2633
|
+
const classValue = link ? getClassFromField(link) : undefined;
|
|
2634
|
+
if (classValue) {
|
|
2635
|
+
addClassTokens(renderer, element, classValue);
|
|
2636
|
+
}
|
|
2637
|
+
else {
|
|
2638
|
+
renderer.removeAttribute(element, 'class');
|
|
2639
|
+
if (options.originalClass) {
|
|
2640
|
+
addClassTokens(renderer, element, options.originalClass);
|
|
2641
|
+
}
|
|
2642
|
+
if (link?.title) {
|
|
2643
|
+
renderer.setAttribute(element, 'title', link.title);
|
|
2644
|
+
}
|
|
2645
|
+
else {
|
|
2646
|
+
renderer.removeAttribute(element, 'title');
|
|
2647
|
+
if (options.originalTitle) {
|
|
2648
|
+
renderer.setAttribute(element, 'title', options.originalTitle);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
if (link?.target) {
|
|
2652
|
+
renderer.setAttribute(element, 'target', link.target);
|
|
2653
|
+
if (link?.target === '_blank' && !options.originalRel) {
|
|
2654
|
+
renderer.setAttribute(element, 'rel', 'noopener noreferrer');
|
|
2655
|
+
}
|
|
2656
|
+
else {
|
|
2657
|
+
options.originalRel
|
|
2658
|
+
? renderer.setAttribute(element, 'rel', options.originalRel)
|
|
2659
|
+
: renderer.removeAttribute(element, 'rel');
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
else {
|
|
2663
|
+
renderer.removeAttribute(element, 'target');
|
|
2664
|
+
if (options.originalTarget) {
|
|
2665
|
+
renderer.setAttribute(element, 'target', options.originalTarget);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
const hasChildren = element.childNodes.length > 0 && element.textContent?.trim();
|
|
2669
|
+
if (!hasChildren) {
|
|
2670
|
+
const text = link?.text || link?.href || '';
|
|
2671
|
+
renderer.setProperty(element, 'textContent', text);
|
|
2672
|
+
}
|
|
2673
|
+
else if (options.preferTextFromField && link?.text) {
|
|
2674
|
+
renderer.setProperty(element, 'textContent', link?.text || '');
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const EXTERNAL_HREF_PREFIXES = [
|
|
2680
|
+
'http://',
|
|
2681
|
+
'https://',
|
|
2682
|
+
'mailto:',
|
|
2683
|
+
'tel:',
|
|
2684
|
+
'sms:',
|
|
2685
|
+
'javascript:',
|
|
2686
|
+
'data:',
|
|
2687
|
+
'ftp:',
|
|
2688
|
+
'//',
|
|
2689
|
+
];
|
|
2690
|
+
/**
|
|
2691
|
+
* Returns true when the href should be written to the DOM unchanged
|
|
2692
|
+
* (external scheme, protocol-relative, fragment-only, or empty/whitespace).
|
|
2693
|
+
* @param {string} href - Raw href string from the Sitecore link field.
|
|
2694
|
+
* @returns {boolean} Whether the href is non-internal and must be left alone.
|
|
2695
|
+
*/
|
|
2696
|
+
function isNonInternalHref(href) {
|
|
2697
|
+
if (!href)
|
|
2698
|
+
return true;
|
|
2699
|
+
const trimmed = href.trim();
|
|
2700
|
+
if (!trimmed)
|
|
2701
|
+
return true;
|
|
2702
|
+
if (trimmed.startsWith('#'))
|
|
2703
|
+
return true;
|
|
2704
|
+
const lower = trimmed.toLowerCase();
|
|
2705
|
+
return EXTERNAL_HREF_PREFIXES.some((p) => lower.startsWith(p));
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Renders a Sitecore link field onto a host `<a>` element.
|
|
2709
|
+
* Sets `href`, `title`, `target`, `class`, and text content from the field data.
|
|
2710
|
+
*
|
|
2711
|
+
* Locale-awareness: when a configured locale list is provided via `sitecore.config`,
|
|
2712
|
+
* internal hrefs are prefixed with the current URL locale (read from
|
|
2713
|
+
* {@link SitecoreContextService}). Hrefs that already contain a configured-locale segment
|
|
2714
|
+
* are written as-is, which respects author-intent cross-locale links and keeps the
|
|
2715
|
+
* directive idempotent under repeated change detection.
|
|
2716
|
+
*
|
|
2717
|
+
* Usage:
|
|
2718
|
+
* ```html
|
|
2719
|
+
* <a [scLink]="fields.Link">Optional child content</a>
|
|
2720
|
+
* ```
|
|
2721
|
+
* @public
|
|
2722
|
+
*/
|
|
2723
|
+
class ScLinkDirective {
|
|
2724
|
+
/** The Sitecore link field. */
|
|
2725
|
+
scLink = input.required(...(ngDevMode ? [{ debugName: "scLink" }] : /* istanbul ignore next */ []));
|
|
2726
|
+
/** Whether to show link text alongside existing child content. */
|
|
2727
|
+
preferTextFromField = input(false, ...(ngDevMode ? [{ debugName: "preferTextFromField" }] : /* istanbul ignore next */ []));
|
|
2728
|
+
el = inject((ElementRef));
|
|
2729
|
+
renderer = inject(Renderer2);
|
|
2730
|
+
context = inject(SitecoreContextService);
|
|
2731
|
+
locales = inject(SITECORE_CONFIG_TOKEN, { optional: true })?.angular?.locales ?? [];
|
|
2732
|
+
originalClass;
|
|
2733
|
+
originalTitle;
|
|
2734
|
+
originalTarget;
|
|
2735
|
+
originalRel;
|
|
2736
|
+
constructor() {
|
|
2737
|
+
this.originalClass = this.el.nativeElement.className;
|
|
2738
|
+
this.originalTitle = this.el.nativeElement.title;
|
|
2739
|
+
this.originalTarget = this.el.nativeElement.target;
|
|
2740
|
+
this.originalRel = this.el.nativeElement.rel;
|
|
2741
|
+
effect(() => {
|
|
2742
|
+
const field = this.scLink();
|
|
2743
|
+
const element = this.el.nativeElement;
|
|
2744
|
+
const link = resolveLinkFromField(field);
|
|
2745
|
+
const localizedLink = link ? this.localizeLink(link) : link;
|
|
2746
|
+
applyLinkFieldToAnchor(this.renderer, element, localizedLink, {
|
|
2747
|
+
preferTextFromField: this.preferTextFromField(),
|
|
2748
|
+
originalClass: this.originalClass,
|
|
2749
|
+
originalTitle: this.originalTitle,
|
|
2750
|
+
originalTarget: this.originalTarget,
|
|
2751
|
+
originalRel: this.originalRel,
|
|
2752
|
+
});
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Returns a copy of the link with `href` prefixed by the current URL locale when applicable.
|
|
2757
|
+
* Internal hrefs are prefixed only when:
|
|
2758
|
+
* 1. There is a current URL locale (page itself has a locale prefix), and
|
|
2759
|
+
* 2. The href does not already start with a configured locale segment.
|
|
2760
|
+
* External, fragment-only, and locale-prefixed hrefs are returned unchanged.
|
|
2761
|
+
* @param {LinkFieldValue} link - Resolved link value from layout data.
|
|
2762
|
+
* @returns {LinkFieldValue} Link value with locale-aware href.
|
|
2763
|
+
*/
|
|
2764
|
+
localizeLink(link) {
|
|
2765
|
+
const href = link.href ?? '';
|
|
2766
|
+
if (isNonInternalHref(href)) {
|
|
2767
|
+
return link;
|
|
2768
|
+
}
|
|
2769
|
+
if (this.locales.length > 0) {
|
|
2770
|
+
const { locale } = splitLocaleFromPath(href, this.locales);
|
|
2771
|
+
if (locale) {
|
|
2772
|
+
return link;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
const currentLocale = this.context.urlLocale();
|
|
2776
|
+
if (!currentLocale) {
|
|
2777
|
+
return link;
|
|
2778
|
+
}
|
|
2779
|
+
return { ...link, href: getLocaleRewrite(href, currentLocale) };
|
|
2780
|
+
}
|
|
2781
|
+
static ɵfac = function ScLinkDirective_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScLinkDirective)(); };
|
|
2782
|
+
static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: ScLinkDirective, selectors: [["a", "scLink", ""]], inputs: { scLink: [1, "scLink"], preferTextFromField: [1, "preferTextFromField"] } });
|
|
2783
|
+
}
|
|
2784
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScLinkDirective, [{
|
|
2785
|
+
type: Directive,
|
|
2786
|
+
args: [{
|
|
2787
|
+
selector: 'a[scLink]',
|
|
2788
|
+
}]
|
|
2789
|
+
}], () => [], { scLink: [{ type: i0.Input, args: [{ isSignal: true, alias: "scLink", required: true }] }], preferTextFromField: [{ type: i0.Input, args: [{ isSignal: true, alias: "preferTextFromField", required: false }] }] }); })();
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* Renders a Sitecore link field onto a host `<a>` and calls `Router.navigateByUrl` on click
|
|
2793
|
+
* for in-app paths only. Clicks are left to the browser when `href` is missing/empty, when
|
|
2794
|
+
* `target="_blank"`, or when `href` uses http(s), mailto, tel, sms, javascript, data, ftp,
|
|
2795
|
+
* or protocol-relative (`//`) URLs.
|
|
2796
|
+
*
|
|
2797
|
+
* Usage:
|
|
2798
|
+
* ```html
|
|
2799
|
+
* <a [scRouterLink]="fields.Link">Optional child content</a>
|
|
2800
|
+
* ```
|
|
2801
|
+
* @public
|
|
2802
|
+
*/
|
|
2803
|
+
class ScRouterLinkDirective extends ScLinkDirective {
|
|
2804
|
+
/**
|
|
2805
|
+
* Sitecore link field; host attribute `[scRouterLink]` maps to the base {@link ScLinkDirective.scLink} input.
|
|
2806
|
+
*/
|
|
2807
|
+
scLink = input.required({ ...(ngDevMode ? { debugName: "scLink" } : /* istanbul ignore next */ {}), alias: 'scRouterLink' });
|
|
2808
|
+
router = inject(Router);
|
|
2809
|
+
onClick(event) {
|
|
2810
|
+
const el = this.el.nativeElement;
|
|
2811
|
+
const hrefAttr = el.getAttribute('href')?.trim() ?? '';
|
|
2812
|
+
const targetAttr = el.getAttribute('target');
|
|
2813
|
+
if (this.shouldDeferNavigation(hrefAttr, targetAttr)) {
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
// Early return in editing mode
|
|
2817
|
+
// if (this.sitecoreContext.isEditing()) {
|
|
2818
|
+
// return;
|
|
2819
|
+
// }
|
|
2820
|
+
void this.router.navigateByUrl(hrefAttr);
|
|
2821
|
+
if (!hrefAttr.includes('#')) {
|
|
2822
|
+
event.preventDefault();
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Returns true when the browser should handle navigation (no in-app Router navigation).
|
|
2827
|
+
* @param {string | null} hrefAttr - Raw `href` attribute from the anchor.
|
|
2828
|
+
* @param {string | null} targetAttr - Raw `target` attribute from the anchor.
|
|
2829
|
+
* @returns {boolean} Whether to skip `Router.navigateByUrl`.
|
|
2830
|
+
*/
|
|
2831
|
+
shouldDeferNavigation(hrefAttr, targetAttr) {
|
|
2832
|
+
if (!hrefAttr || hrefAttr === '') {
|
|
2833
|
+
return true;
|
|
2834
|
+
}
|
|
2835
|
+
if (targetAttr === '_blank') {
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
const lower = hrefAttr.toLowerCase();
|
|
2839
|
+
return (lower.startsWith('http://') ||
|
|
2840
|
+
lower.startsWith('https://') ||
|
|
2841
|
+
lower.startsWith('mailto:') ||
|
|
2842
|
+
lower.startsWith('tel:') ||
|
|
2843
|
+
lower.startsWith('sms:') ||
|
|
2844
|
+
lower.startsWith('javascript:') ||
|
|
2845
|
+
lower.startsWith('data:') ||
|
|
2846
|
+
lower.startsWith('ftp:') ||
|
|
2847
|
+
lower.startsWith('//'));
|
|
2848
|
+
}
|
|
2849
|
+
static ɵfac = /*@__PURE__*/ (() => { let ɵScRouterLinkDirective_BaseFactory; return function ScRouterLinkDirective_Factory(__ngFactoryType__) { return (ɵScRouterLinkDirective_BaseFactory || (ɵScRouterLinkDirective_BaseFactory = i0.ɵɵgetInheritedFactory(ScRouterLinkDirective)))(__ngFactoryType__ || ScRouterLinkDirective); }; })();
|
|
2850
|
+
static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: ScRouterLinkDirective, selectors: [["a", "scRouterLink", ""]], hostBindings: function ScRouterLinkDirective_HostBindings(rf, ctx) { if (rf & 1) {
|
|
2851
|
+
i0.ɵɵlistener("click", function ScRouterLinkDirective_click_HostBindingHandler($event) { return ctx.onClick($event); });
|
|
2852
|
+
} }, inputs: { scLink: [1, "scRouterLink", "scLink"] }, features: [i0.ɵɵInheritDefinitionFeature] });
|
|
2853
|
+
}
|
|
2854
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScRouterLinkDirective, [{
|
|
2855
|
+
type: Directive,
|
|
2856
|
+
args: [{
|
|
2857
|
+
selector: 'a[scRouterLink]',
|
|
2858
|
+
}]
|
|
2859
|
+
}], null, { scLink: [{ type: i0.Input, args: [{ isSignal: true, alias: "scRouterLink", required: true }] }], onClick: [{
|
|
2860
|
+
type: HostListener,
|
|
2861
|
+
args: ['click', ['$event']]
|
|
2862
|
+
}] }); })();
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* Renders a Sitecore rich text field value as innerHTML of the host element.
|
|
2866
|
+
* Content is marked trusted for Angular sanitization (typical for CMS-authored HTML).
|
|
2867
|
+
*
|
|
2868
|
+
* Usage:
|
|
2869
|
+
* ```html
|
|
2870
|
+
* <div [scRichText]="fields.Content"></div>
|
|
2871
|
+
* ```
|
|
2872
|
+
* @public
|
|
2873
|
+
*/
|
|
2874
|
+
class ScRichTextDirective {
|
|
2875
|
+
/** The Sitecore rich text field. */
|
|
2876
|
+
scRichText = input.required(...(ngDevMode ? [{ debugName: "scRichText" }] : /* istanbul ignore next */ []));
|
|
2877
|
+
el = inject((ElementRef));
|
|
2878
|
+
renderer = inject(Renderer2);
|
|
2879
|
+
sanitizer = inject(DomSanitizer);
|
|
2880
|
+
constructor() {
|
|
2881
|
+
effect(() => {
|
|
2882
|
+
const field = this.scRichText();
|
|
2883
|
+
const element = this.el.nativeElement;
|
|
2884
|
+
if (!field || isFieldValueEmpty(field)) {
|
|
2885
|
+
this.renderer.setProperty(element, 'innerHTML', '');
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
const raw = field.value ?? '';
|
|
2889
|
+
const trusted = this.sanitizer.bypassSecurityTrustHtml(raw);
|
|
2890
|
+
const html = this.sanitizer.sanitize(SecurityContext.HTML, trusted) ?? '';
|
|
2891
|
+
this.renderer.setProperty(element, 'innerHTML', html);
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
static ɵfac = function ScRichTextDirective_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ScRichTextDirective)(); };
|
|
2895
|
+
static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: ScRichTextDirective, selectors: [["", "scRichText", ""]], inputs: { scRichText: [1, "scRichText"] } });
|
|
2896
|
+
}
|
|
2897
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScRichTextDirective, [{
|
|
2898
|
+
type: Directive,
|
|
2899
|
+
args: [{
|
|
2900
|
+
selector: '[scRichText]',
|
|
2901
|
+
}]
|
|
2902
|
+
}], () => [], { scRichText: [{ type: i0.Input, args: [{ isSignal: true, alias: "scRichText", required: true }] }] }); })();
|
|
2903
|
+
|
|
2904
|
+
const _coreVersionMarker = VERSION;
|
|
2905
|
+
const _routerTokenMarker = Router;
|
|
2906
|
+
|
|
2907
|
+
/**
|
|
2908
|
+
* Generated bundle index. Do not edit.
|
|
2909
|
+
*/
|
|
2910
|
+
|
|
2911
|
+
export { ClientLoaderDataService, ClientPreLoaderDataService, DEFAULT_EXPORT_NAME, FETCH_DATA_ENDPOINT, LOADER_DATA_ENDPOINT, LOADER_ID, LOADER_REGISTRY, LoaderHttpError, LocaleUrlSerializer, NotFoundNavigationError, SERVER_LOADER_RUNNER, SITECORE_CLIENT_TOKEN, SITECORE_COMPONENT_MAP, SITECORE_CONFIG_TOKEN, ScFormComponent, ScHiddenRenderingComponent, ScImageDirective, ScLinkDirective, ScMissingComponentComponent, ScPlaceholderComponent, ScRichTextDirective, ScRouterLinkDirective, ScTextDirective, ServerLoaderRunner, SitecoreContextService, SitecoreTranslateLoader, _coreVersionMarker, _routerTokenMarker, applyRedirect, collectSitecoreTagsFromEdgeRevalidateRequestBody, createCacheAdminMiddleware, createExpressDataMiddleware, createLoaderCache, createLoaderDataServiceMiddleware, createSitecoreRevalidateMiddleware, defineConfig, extractSitecoreEdgeContentId, getChildComponentProps, getPlaceholderRenderings, getSXAParams, handleNavigationError, loaderResolver, provideLoaderRegistry, provideServerLoaderRunner, provideSitecoreAngular, resolveComponentForRendering, resolveConfiguredRevalidateSecret, resolveSitecorePage, scLocaleMatcher, splitLocaleFromPath };
|
|
2912
|
+
//# sourceMappingURL=sitecore-content-sdk-angular.mjs.map
|