@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.
@@ -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