From cb6fa6b7ff8627c7c3207468aa964c360e37fb23 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 14 May 2026 17:30:20 -0400 Subject: [PATCH 01/14] angular/nextjs/common wiki (cherry picked from commit 45640df4faa70b53b73d0a8ac9c25a8ba7344d51) --- AGENTS.md | 70 ++++++++ README.md | 3 +- llm-wiki/README.md | 13 ++ llm-wiki/raw/2026-05-14-adapters.md | 14 ++ .../raw/2026-05-14-architecture-overview.md | 25 +++ ...026-05-14-content-sdk-services-and-apis.md | 14 ++ ...05-14-editor-integration-using-metadata.md | 61 +++++++ ...5-14-example-environment-variable-files.md | 41 +++++ ...14-internationalization-using-next-intl.md | 42 +++++ ...14-jss-angular-live-design-architecture.md | 161 ++++++++++++++++++ ...-05-14-page-composition-sitecoreai-data.md | 16 ++ llm-wiki/raw/2026-05-14-plugins.md | 42 +++++ ...2026-05-14-route-handling-data-fetching.md | 38 +++++ ...-14-sitecore-content-sdk-for-sitecoreai.md | 22 +++ ...14-supporting-multilingual-applications.md | 16 ++ ...6-05-14-the-sitecore-configuration-file.md | 131 ++++++++++++++ llm-wiki/raw/README.md | 22 +++ ...-Angular-Live-Design-Doc-140526-211917.pdf | Bin 0 -> 319930 bytes .../doc-config-environment-variables.md | 43 +++++ .../common/doc-sitecore-client-and-graphql.md | 78 +++++++++ .../wiki/common/doc-sitecore-config-input.md | 64 +++++++ llm-wiki/wiki/common/index.md | 20 +++ ...tecture-goals-challenges-and-foundation.md | 21 +++ .../doc-architecture-loaders-and-ssr.md | 29 ++++ .../doc-components-and-placeholder-map.md | 20 +++ .../doc-editing-and-page-context-angular.md | 50 ++++++ ...c-environment-and-define-config-angular.md | 23 +++ .../doc-field-directives.md | 23 +++ ...er-resolver-transfer-state-and-endpoint.md | 77 +++++++++ .../doc-loaders-outside-angular-di.md | 18 ++ ...-loaders-route-registry-and-page-loader.md | 41 +++++ .../doc-multisite-angular-roadmap.md | 17 ++ .../doc-personalization-angular-roadmap.md | 7 + .../doc-preloader-data-service.md | 34 ++++ .../doc-sitecore-config-typescript-angular.md | 17 ++ .../doc-ssr-express-and-loader-middleware.md | 17 ++ llm-wiki/wiki/content-sdk-angular/index.md | 49 ++++++ .../doc-architecture-edge-graphql.md | 31 ++++ .../doc-editor-integration-metadata.md | 37 ++++ .../doc-example-environment-variable-files.md | 33 ++++ .../doc-graphql-client-and-edge-urls.md | 15 ++ .../doc-i18n-multilingual.md | 124 ++++++++++++++ .../doc-page-composition-placeholders.md | 30 ++++ .../doc-plugins-and-adapters.md | 26 +++ .../doc-route-handling-data-fetching.md | 26 +++ .../doc-sitecore-client-apis.md | 13 ++ .../content-sdk-nextjs/doc-sitecore-config.md | 50 ++++++ .../doc-terminology-platform-names.md | 13 ++ llm-wiki/wiki/content-sdk-nextjs/index.md | 49 ++++++ .../overview-content-sdk.md | 52 ++++++ .../source-ingest-2026-05-14-official-docs.md | 22 +++ llm-wiki/wiki/index.md | 18 ++ llm-wiki/wiki/log.md | 67 ++++++++ 53 files changed, 1984 insertions(+), 1 deletion(-) create mode 100644 llm-wiki/README.md create mode 100644 llm-wiki/raw/2026-05-14-adapters.md create mode 100644 llm-wiki/raw/2026-05-14-architecture-overview.md create mode 100644 llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md create mode 100644 llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md create mode 100644 llm-wiki/raw/2026-05-14-example-environment-variable-files.md create mode 100644 llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md create mode 100644 llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md create mode 100644 llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md create mode 100644 llm-wiki/raw/2026-05-14-plugins.md create mode 100644 llm-wiki/raw/2026-05-14-route-handling-data-fetching.md create mode 100644 llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md create mode 100644 llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md create mode 100644 llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md create mode 100644 llm-wiki/raw/README.md create mode 100644 llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf create mode 100644 llm-wiki/wiki/common/doc-config-environment-variables.md create mode 100644 llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md create mode 100644 llm-wiki/wiki/common/doc-sitecore-config-input.md create mode 100644 llm-wiki/wiki/common/index.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-field-directives.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md create mode 100644 llm-wiki/wiki/content-sdk-angular/index.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/index.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md create mode 100644 llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md create mode 100644 llm-wiki/wiki/index.md create mode 100644 llm-wiki/wiki/log.md diff --git a/AGENTS.md b/AGENTS.md index bfd21714b4..ebb9951149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,76 @@ Branch: `dev`. Feature: `git switch -c feature/my-content-sdk-feature`. PRs agai --- +## LLM Wiki (persistent Content SDK knowledge base) + +This monorepo includes an **[LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** — a structured, interlinked markdown corpus maintained by an agent for **Content SDK developers** using LLM-assisted coding. + +### Purpose and scope + +| | | +|--|--| +| **Domain** | Sitecore **Content SDK** monorepo: packages, templates, CLI, samples | +| **Audience** | Developers and AI agents working **in this repo** (not head apps under `samples/` unless explicitly extending samples) | +| **“Done”** | Wiki explains **high- and medium-level** architecture, flows, and concepts well enough to speed up implementation work; pages cite **code** where behavior matters | +| **Location** | `llm-wiki/raw/` (immutable sources), `llm-wiki/wiki/` (agent-owned pages), `llm-wiki/README.md` (human orientation) | + +### Relationship to other agent guidance + +- **This file (`AGENTS.md`)** — monorepo tasks, commands, boundaries, package map; remains the primary **session** guide. +- **`.cursor/rules/*.mdc`**, **`CLAUDE.md`**, **`copilot-instructions.md`**, **`Skills.md` / `.agents/skills/`** — coding rules and capabilities; the wiki **compiles** and **cross-links** knowledge for longer-horizon memory, not replace those files. +- **Head application `AGENTS.md`** — still applies inside generated apps; do not merge this wiki’s repo scope into a head app’s file. + +### Source hierarchy (conflict resolution) + +1. **`packages/*/src/**` and tests** — **authoritative** for behavior and APIs. +2. **Official documentation** (including material from the **Sitecore Documentation MCP** or saved web articles in `llm-wiki/raw/`) — **secondary**; use for intent, terminology, and product framing. +3. **Wiki pages** — synthesized; must be **reconciled** with (1) on every ingest or when contradictions are found. + +If documentation and code disagree: **document the code’s behavior** in the wiki, link to paths/symbols, and add a short **“Documentation note”** or **contradiction** callout describing what the external doc claims. Optionally log in `llm-wiki/wiki/log.md`. + +### Directory contract + +| Path | Who edits | Contents | +|------|-----------|----------| +| `llm-wiki/raw/` | **Human** adds files; agent **does not** modify | Curated markdown/text copies of docs, MCP exports, articles | +| `llm-wiki/wiki/` | **Agent** creates/updates (per user direction) | Overviews, package notes, flows; **Next.js** pages under `wiki/content-sdk-nextjs/`; shared stubs under `wiki/common/`; Angular under `wiki/content-sdk-angular/` | +| `llm-wiki/wiki/index.md` | **Agent** maintains | Root hub linking stack-specific indexes | +| `llm-wiki/wiki/content-sdk-nextjs/index.md` | **Agent** maintains | Next.js wiki catalog | +| `llm-wiki/wiki/log.md` | **Agent** appends | Chronological ingest / query / lint entries | + +### Workflows (agent) + +**Ingest** — When the user adds a source under `llm-wiki/raw/` or points to new doc/MCP material: + +1. Read the source; identify claims relevant to this repo. +2. **Verify** important claims against code (read `src`, follow imports, check tests). +3. Update or create wiki pages (package overviews, concept pages, flow diagrams in prose/mermaid as appropriate). +4. Update `index.md` and append `log.md` (consistent heading format, e.g. `## [YYYY-MM-DD] ingest | `). + +Prefer **one source per ingest** when the user wants tight review; batch only when asked. + +**Query** — When answering questions about SDK behavior: + +1. Skim `llm-wiki/wiki/index.md`, then open the most relevant wiki pages. +2. If the wiki is incomplete or stale, read **code** and then **update the wiki** so the next session benefits. +3. Good answers (comparisons, non-trivial analyses) may be **saved** as new wiki pages and linked from `index.md`. + +**Lint** — Periodically or on request: + +- Orphan pages (no inbound links from index or other pages). +- Contradictions between wiki pages or between wiki and code. +- Stale summaries superseded by refactors; missing cross-links for major concepts. +- Gaps that need a doc fetch via MCP or a code dive — log suggested follow-ups in `log.md`. + +### Conventions + +- Prefer **relative links** between wiki pages; cite code as `` `packages/<pkg>/src/...` ``. +- **Platform naming:** In wiki and comments, **Sitecore AI / SitecoreAI / SAI / XM Cloud / XMC** refer to the **same** platform context unless code explicitly distinguishes behavior. See `llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md`. +- Do **not** store secrets in raw or wiki; follow `.cursor/rules/safety.mdc`. +- Do **not** edit `dist/**`, `node_modules/`, or generated-only paths as part of wiki maintenance. + +--- + ## MCP Sitecore Documentation MCP: https://sitecore.mcp.kapa.ai diff --git a/README.md b/README.md index 5e7affcfb2..8cc63eb492 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ For more information check out our [Getting Started Guide](https://doc.sitecore. ### AI Development Support -- [AGENTS.md](AGENTS.md) - AI agent guidance: structure, commands, DOs/DON'Ts, boundaries, and quick reference +- [AGENTS.md](AGENTS.md) - AI agent guidance: structure, commands, DOs/DON'Ts, boundaries, quick reference, and **LLM Wiki** maintainer rules for `llm-wiki/` +- [LLM Wiki](llm-wiki/README.md) - persistent markdown knowledge base (raw sources + agent-maintained wiki); schema and workflows in the **LLM Wiki** section of [AGENTS.md](AGENTS.md) - [Skills.md](Skills.md) - Capability groupings for the Content SDK (for AI tools and developers); [.agents/skills/](.agents/skills/) provides each capability as an Agent Skill (SKILL.md) for tools that support the [Agent Skills](https://agentskills.io) standard - [Claude Code Agent Guide](CLAUDE.md) - Comprehensive guide for Claude Code Agent to generate consistent and idiomatic Sitecore Content SDK code - [GitHub Copilot Instructions](copilot-instructions.md) - Instructions for GitHub Copilot to provide accurate Sitecore Content SDK suggestions diff --git a/llm-wiki/README.md b/llm-wiki/README.md new file mode 100644 index 0000000000..f50e6250ac --- /dev/null +++ b/llm-wiki/README.md @@ -0,0 +1,13 @@ +# Content SDK LLM Wiki + +Persistent markdown knowledge base for **Content SDK monorepo** development, maintained by an LLM per the [LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). + +| Layer | Path | Role | +|--------|------|------| +| **Schema** | [AGENTS.md](../AGENTS.md) (section *LLM Wiki*) | Conventions, workflows, truth hierarchy | +| **Wiki** | [`wiki/`](wiki/) | LLM-written synthesis, entities, flows (git-tracked) | +| **Raw sources** | [`raw/`](raw/) | Immutable inputs (clipped docs, exports you add) | + +Start with [`wiki/index.md`](wiki/index.md) and [`wiki/log.md`](wiki/log.md). Next.js head docs live under [`wiki/content-sdk-nextjs/`](wiki/content-sdk-nextjs/). Do not edit files under `raw/` except by adding new source material. + +**Platform naming:** See [`wiki/content-sdk-nextjs/doc-terminology-platform-names.md`](wiki/content-sdk-nextjs/doc-terminology-platform-names.md). diff --git a/llm-wiki/raw/2026-05-14-adapters.md b/llm-wiki/raw/2026-05-14-adapters.md new file mode 100644 index 0000000000..746b75cf48 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-adapters.md @@ -0,0 +1,14 @@ +--- +title: Adapters +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/adapters.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Adapters (snapshot) + +Adapters provide **environment-specific** implementations for **plugins** (browser vs server: cookies, request/response, fetching). In analytics, adapters implement **`AnalyticsAdapter`**, extending **`PluginAdapter`** from `@sitecore-content-sdk/core`. + +Example shape (per doc): `getClientId`, `setClientId`, `location.getSearchParams`. Passed into plugins at init so plugins stay environment-agnostic. Example: `initContentSdk` with `analyticsPlugin({ adapter: analyticsBrowserAdapter(), options: { enableCookie: true } })` and `eventsPlugin()`. + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-architecture-overview.md b/llm-wiki/raw/2026-05-14-architecture-overview.md new file mode 100644 index 0000000000..6f68d73886 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-architecture-overview.md @@ -0,0 +1,25 @@ +--- +title: Architecture overview +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/architecture-overview.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Architecture overview (snapshot) + +Content SDK is part of the headless suite for SitecoreAI. + +## Experience Edge + +Experience Edge delivers layout and dictionary data via **GraphQL**. Official doc states the Edge delivery endpoint path is configured in the app’s **`package.json`** under `config.graphQLEndpointPath`, default `/sitecore/api/graph/edge`. + +**Wiki alignment:** Runtime GraphQL endpoint for `SitecoreClient` is resolved from **`sitecore.config` + env** (`api.edge` / `api.local`); see `llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md` and `content-sdk-nextjs/doc-sitecore-config.md`. + +## Content SDK components (per doc) + +- Core SDK: retrieve data from Sitecore services/APIs; work with Sitecore data and layout in JavaScript. +- Next.js SDKs: placeholders, field components, layout/field values editable by authors. +- Sample / starter app for Next.js. +- Developer tooling and utilities. + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md b/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md new file mode 100644 index 0000000000..327670e573 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md @@ -0,0 +1,14 @@ +--- +title: Content SDK Services and APIs +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/content-sdk-services-and-apis.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Content SDK Services and APIs (snapshot) + +Primary entry for apps: **`SitecoreClient`** — framework-agnostic client for headless SitecoreAI APIs: content, layout, dictionary, error pages, preview, sitemaps, robots.txt, other site-related data. + +Related doc topics linked from official page: Content SDK GraphQL API, Content SDK Media API. + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md b/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md new file mode 100644 index 0000000000..aa05ec612d --- /dev/null +++ b/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md @@ -0,0 +1,61 @@ +--- +title: Editor integration using metadata +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/editor-integration-using-metadata.html +doc_version: "2.x" +ingested: "2026-05-14" +fetch_note: "HTML retrieved via curl; body distilled to markdown (diagram omitted)." +--- + +# Editor integration using metadata (snapshot) + +**Scope (official):** Next.js **Pages Router** apps + SitecoreAI **Page builder**, using **layout service metadata** on placeholders, renderings, and fields so the editor can identify nodes for **in-browser visual editing**. + +**Stack (official):** [Sitecore Headless Services HTTP rendering engine](https://doc.sitecore.com/xp/en/developers/hd/22/sitecore-headless-development/http-rendering-engine.html), [Next.js API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes), [Next.js Preview Mode](https://nextjs.org/docs/pages/guides/preview-mode). + +**Note:** Official diagram: teal = Content SDK for Next.js APIs; other colors = sample app pieces. + +**Local Pages testing:** Connect [local host to Pages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/connect-your-local-host-to-pages.html) (doc link uses XMC path; same platform family as SAI — see wiki `content-sdk-nextjs/doc-terminology-platform-names.md`). + +**Important — iframes:** `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors 'self'` can **block** the Pages iframe. Allow the Pages domain to frame the editing host (adjust headers / exceptions). + +## API routes (sample app) + +1. **`src/pages/api/editing/render.ts`** — `GET`; **`EditingRenderMiddleware`**. Use this URL as **`serverSideRenderingEngineEndpointUrl`** in Sitecore Content SDK app configuration. +2. **`src/pages/api/editing/config.ts`** — `GET`; **`EditingConfigMiddleware`**. +3. **Catch-all page** **`src/pages/[[...path]].tsx`** — main optional catch-all; renders Sitecore routes. + +## Editing secret + +Token securing editor endpoints exposed via the **Render** API route. **`EditingRenderMiddleware`** validates it; failure → **401**. + +## Next.js preview mode + +Draft / Page builder content at **request time**, bypassing static generation when appropriate. **`EditingRenderMiddleware`** enables preview mode (cookies on render response, passed to subsequent page request). In the catch-all, use **`SitecoreClient.getPreview`** or **`getDesignLibraryData`** when in preview (vs normal **`getPage`**). + +## Example render `GET` (metadata integration) + +``` +/api/editing/render?secret={EDITING_SECRET}&sc_site=nextjs-app&sc_itemid=54C8E9B5-0B2C-5363-8FA6-D32A3A302F51&sc_lang=en&route=/&mode=edit&sc_version=latest&sc_variant={VARIANT_ID}&sc_layoutKind=shared +``` + +- `sc_layoutKind`: enum, default **`final`**, optional **`shared`**. +- `sc_version`, `sc_variant`, `sc_layoutKind` — optional. + +## SDK APIs + +Import from **`@sitecore-content-sdk/nextjs/editing`**, e.g. `EditingRenderMiddleware`. + +### EditingRenderMiddleware (responsibilities, per doc) + +1. Validate editing secret. +2. Extract required query string parameters. +3. Enable Next.js preview mode; pass parameters as preview data. +4. Send internal request to editing host catch-all to fetch the page. +5. Return rendered page markup. + +### EditingConfigMiddleware (responsibilities, per doc) + +1. Validate editing secret. +2. Provide required configuration (**application metadata**) for feature compatibility. + +Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-example-environment-variable-files.md b/llm-wiki/raw/2026-05-14-example-environment-variable-files.md new file mode 100644 index 0000000000..2ba5476d0b --- /dev/null +++ b/llm-wiki/raw/2026-05-14-example-environment-variable-files.md @@ -0,0 +1,41 @@ +--- +title: Example environment variable files +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/example-environment-variable-files.html +doc_version: "2.x" +ingested: "2026-05-14" +fetch_status: ok +--- + +# Example environment variable files (snapshot) + +Content SDK apps from **0.2.0+** ship **`.example`** env files to help configure local dev for **local container** vs **remote SitecoreAI**. + +## Purpose (per official doc) + +- Show which variables Content SDK expects. +- Show how to set them per environment. +- Avoid committing secrets (**.example** only — no real secrets). + +## Files (official) + +| File | Purpose | +|------|---------| +| **`.env.container.example`** | Local dev against a **local SitecoreAI container**. | +| **`.env.remote.example`** | Local dev against a **remote** SitecoreAI instance. | + +Templates in repo are editable; keep **`.example`** files updated when SDK adds vars. **Do not** put client secrets in `.example` files. + +## Implement + +Copy the relevant **`.example`** into **`.env.local`** and fill values. + +## Template code (Pages Router) + +Shipped under `packages/create-content-sdk-app/src/templates/nextjs/`: + +- `.env.container.example` — `SITECORE_EDITING_SECRET`, `NEXT_PUBLIC_DEFAULT_SITE_NAME`, `NEXT_PUBLIC_DEFAULT_LANGUAGE`, `NEXT_PUBLIC_SITECORE_API_KEY`, `NEXT_PUBLIC_SITECORE_API_HOST`, optional `DEBUG`. +- `.env.remote.example` — same defaults plus Edge (`SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional Edge hostname / Personalize / Design Library auth vars). + +Wiki: `llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md`. + +Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md b/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md new file mode 100644 index 0000000000..7730afe675 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md @@ -0,0 +1,42 @@ +--- +title: Internationalization using next-intl +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/internationalization-using-next-intl.html +doc_version: "2.x" +ingested: "2026-05-14" +fetch_status: ok +--- + +# Internationalization using next-intl (snapshot) + +**Scope (official):** Next.js **App Router** template — **`next-intl`** for locale routing, server/client translation patterns, and **dictionary phrases from Sitecore** namespaced by **`siteName`**. + +## Prerequisites + +Configure **languages in SitecoreAI** before app-level i18n ([working with languages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/working-with-languages.html) — XMC path; same platform family as SAI). + +## Configuration files (App Router) + +| File | Role | +|------|------| +| **`src/i18n/routing.ts`** | `defineRouting`: supported **locales**, **defaultLocale** (often `sitecoreConfig.defaultLanguage`), **localePrefix** (e.g. `"as-needed"`). | +| **`src/i18n/request.ts`** | `defineRequestConfig`: per-request **locale** + **dictionary** from Sitecore for server components. | + +## Routing (App Router + multisite + SSG) + +- Catch-all: **`[site]/[locale]/[[...path]]`**. +- **`localeMiddleware`** first in **`src/middleware.ts`**, then other middlewares. +- **`generateStaticParams`** enumerates **site × locale** for SSG. + +## Components (official) + +- **Async server:** `getTranslations` / `getLocale` from **`next-intl/server`**; namespace `page.siteName`. +- **Sync server:** `useTranslations(page.siteName)`. +- **Client:** `NextIntlClientProvider` on catch-all page; `useTranslations()` / `useLocale()`. + +See **next-intl** docs for server vs client environments. + +## Pages Router (this repo) + +The **Pages Router** template does **not** use **`next-intl`**. It uses **Next.js built-in `i18n`**, **`next-localization`** (`I18nProvider` + rosetta), and **`context.locale`** in data fetching — see wiki **`doc-i18n-multilingual.md`** (code-first section). + +Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md b/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md new file mode 100644 index 0000000000..9275ce5ed4 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md @@ -0,0 +1,161 @@ +--- +title: "JSS-Angular Live Design — Architecture" +source_type: pdf +source_file: "JSS-Angular Live Design Doc-140526-211917.pdf" +pdf_in_repo: "llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf" +ingested: "2026-05-14" +note: "Text extracted from user-supplied PDF; structure preserved for LLM Wiki. Internal POC doc references in original PDF appear as placeholders where not included." +--- + +# JSS-Angular Live Design Doc — Architecture (extract) + +## Goal + +Provide **Angular** support while reusing common Content SDK concepts: **`scClient`**, **component-map**, **import-map** (later), **`scConfig`** (CLI config too). + +## Challenges + +- All imported logic must not bloat the Angular bundle incorrectly (**server and client** split). +- **`process.env`** is **not** available on the **client** in the same way as Node-based heads. + +## Foundation + +Angular implementation rests on the **loader system** described in an internal POC doc (referenced in the original PDF; not attached to this snapshot). + +--- + +## Loaders + +Loaders are implemented as **Angular route data resolvers** (see Angular docs: *Route data resolvers*). They populate **`page`**, **`dictionary`**, and potentially other route props when a request is processed by **Angular SSR**. + +### Route configuration (example) + +```typescript +{ + path: '**', + component: PageComponent, + resolve: { + page: loaderResolver('page'), + dictionary: loaderResolver('dictionary'), + }, +} +``` + +### Registry (`app.config.ts`) + +Loaders are retrieved from a **loader registry** and provided in app config: + +```typescript +import { LOADERS } from '../content-sdk/loaders'; +// ... +providers: [ + // ... + provideLoaderRegistry(LOADERS), + // ... +]; +``` + +### Default page loader (example) + +Loader implementation lives in the app. Example pattern: + +```typescript +import type { LoaderFn, Page } from '@sitecore-content-sdk/angular'; +import { NotFoundNavigationError, resolveSitecorePage } from '@sitecore-content-sdk/angular'; +import scConfig from '../../../sitecore.config'; +import { getClient } from '../client/sitecore-client'; + +/** + * Page loader: fetches layout data from Sitecore for the current URL. + * Uses imported config and getClient so this runs outside Angular injection context. + */ +export const pageLoader: LoaderFn<Page> = async (context) => { + const page = await resolveSitecorePage(context.url, scConfig, getClient()); + if (!page) { + throw new NotFoundNavigationError(); + } + return page; +}; +``` + +### `loaderResolver` behavior + +**`loaderResolver`** is the main entry point for loader execution. + +**On server** + +- Retrieves loaders from **`LOADER_REGISTRY`** by name. +- Executes loaders. +- Writes loader results to **`TransferState`** so values are available quickly on subsequent client navigation. + +**On browser** + +- Tries to read loader result from **`TransferState`** first. +- If absent, calls **`loader-data-service`** Express middleware via **`loader-data.service`**. +- Request promise is tracked in a **pending** collection; duplicate concurrent requests for the same loader/route reuse the pending promise (performance). +- Express middleware loads the loader from the registry, runs it, returns the result. + +Depending on the result (**data**, **error**, **not found**), the resolver either sets the route prop or triggers navigation to **error** / **not found** routes. + +### `PreLoaderDataService` + +On **browser** routing, the service subscribes to Angular’s **`ActivationStart`** and runs **all** loaders for the target route **in parallel** as a **pre-warm**. Angular data resolvers run **sequentially** by default; this narrows the gap. + +### Loader constraints + +Loaders may run from **`loaderResolver`** **or** from the **Express** middleware — **not** inside a normal Angular **`inject()`** context. **Known limitation:** use **imports** for **`scConfig`**, **`getClient`**, etc., instead of constructor injection in loader bodies. + +--- + +## Config and environment + +Angular reuses common **`defineConfig`** logic. **`process.env`** is unavailable on the client; importing a config built only from **`process.env`** can throw. + +**Mitigation (build-time script):** + +1. Read **`CSDK_PUBLIC_*`** (and related) variables from **server** `process.env`. +2. Write **`environment.dev.ts` / `environment.prod.ts`** depending on the command (`npm run dev` / `npm run start`). +3. The Angular bundler folds these into a runtime **`environment.ts`** whose values are passed into **`defineConfig`** — instead of reading **`process.env`** in the browser. + +--- + +## Components + +- **Standalone** components only (Angular + Content SDK convention). +- **Component map** structure mirrors **Next.js**: PascalCase names for SAI compatibility, default + variant files (`file.default.ts`, `file.var.ts`), shared generation utilities between Next and Angular. +- **Placeholder** uses the same component map format and high-level resolution as Next.js. +- **Component map generation** is configured via **`sitecore.cli.config.ts`**. + +--- + +## SSR + +The Angular app registers **`loader-data-service`** middleware in **`server.ts`** **before** registering the browser bundle and the main Angular SSR bundle. + +--- + +## Config (sitecore) + +Angular reuses the common **`sitecore.config.ts`** approach. + +--- + +## Fields and directives + +Original PDF: **TBA**. + +## Editing + +Original PDF: **TBA**. + +## Multisite + +Original PDF: **TBA**. + +## Personalization + +Original PDF: **TBA**. + +--- + +_End of extracted pages (PDF indicated 5 pages)._ diff --git a/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md b/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md new file mode 100644 index 0000000000..99fb108003 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md @@ -0,0 +1,16 @@ +--- +title: Page composition in Content SDK apps using SitecoreAI data +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/page-composition-in-content-sdk-apps-using-sitecoreai-data.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Page composition (snapshot) + +Pages use SitecoreAI **layouts**: named placeholders hosting components. Authors use WYSIWYG in SitecoreAI. Content SDK uses a top-level **Layout** component with at least one **root** placeholder mirroring SitecoreAI. Component hierarchy is **dynamic** from layout **JSON** returned by **GraphQL** (Experience Edge or local) over HTTP. Placeholder names and component types in the app must match what authors configure. Layout data is JSON from SitecoreAI GraphQL via route handling / data fetching; front ends consume that JSON shape. + +**Wiki alignment:** Do not describe head layout as “CMS XML vs app JSON”; GraphQL responses are JSON. See `llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md`. + +Related official topics: dynamic placeholders, placeholders in Content SDK apps, components, route handling. + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-plugins.md b/llm-wiki/raw/2026-05-14-plugins.md new file mode 100644 index 0000000000..05eaf02345 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-plugins.md @@ -0,0 +1,42 @@ +--- +title: Plugins +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/plugins.html +doc_version: "2.x" +ingested: "2026-05-14" +reingested: "2026-05-14" +fetch_status: ok +--- + +# Plugins (snapshot) + +Plugins are **modular** extensions to the Content SDK: type-safe, declarative, with explicit **dependencies** between plugins. See also: [Initializing tracking, events, and personalization in the Content SDK](https://doc.sitecore.com/sai/en/developers/content-sdk/20/initializing-tracking,-events,-and-personalization-in-the-content-sdk.html). + +## `Plugin` interface (per official doc) + +```ts +export interface Plugin<Options = unknown, Adapter = unknown> { + name: string; + options?: Options; + dependencies?: PluginDependency[]; + init?: () => void | Promise<void>; + adapter?: Adapter; +} +``` + +| Field | Description | +|--------|--------------| +| `name` | Plugin name | +| `options` | Plugin-specific options | +| `dependencies` | Declares plugins that must be present and initialized first | +| `init` | Runs once on first `init` (may be async) | +| `adapter` | Optional environment-specific adapter (type-safe) | + +## Built-in plugins (per official doc) + +| Plugin | Purpose | Package | +|--------|---------|---------| +| `analyticsPlugin()` | Core client ID + shared analytics init; **required** for events and personalization. Alone: visitor ID, no events/personalization. | `@sitecore-content-sdk/analytics-core` | +| `eventsPlugin()` | Page view and/or custom events on top of client ID logic. | `@sitecore-content-sdk/events` | +| `personalizeBrowserPlugin()` / `personalizeServerPlugin()` | Personalization (cookie, optional web personalization); depends on analytics. Browser = client contexts; server = Server Components or Next middleware/proxy. | `@sitecore-content-sdk/personalize` | + +Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md b/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md new file mode 100644 index 0000000000..ce2c538c66 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md @@ -0,0 +1,38 @@ +--- +title: Route handling and data fetching in Content SDK apps +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/route-handling-and-data-fetching-in-content-sdk-apps.html +doc_version: "2.x" +ingested: "2026-05-14" +reingested: "2026-05-14" +fetch_status: ok +--- + +# Route handling and data fetching (snapshot) + +## Content tree → URLs (SitecoreAI) + +Each page is a **content item**; tree structure defines **URL structure** (authors control URLs by hierarchy). Example: `site-1/Home/About`, `site-1/Home/Products/Item-1` → `/about`, `/products/item-1` on `site-1` hostname. + +Content SDK apps are expected to **fully support** SitecoreAI URL mapping. + +**Note:** Hostname and URL mapping rules are configured in Sitecore (site definitions, URL rewrite); custom routing needs front-end + Sitecore coordination. Prefer hierarchical routes (e.g. `/products/shoes/running`). + +## Route resolution (Next.js) + +Next.js uses **file-system routing** and **catch-all** routes. A **custom route resolver** maps incoming paths to Sitecore content items. + +## Data fetching flow (example `/products/item-1`) + +1. User navigates to `/products/item-1`. +2. Route resolver maps path → Sitecore route. +3. **GraphQL** to Edge (or Preview) API — official doc example host pattern: `https://edge-platform.sitecorecloud.io/v1/content/api/graphql/v1?sitecoreContextId=...` (actual URL comes from app **sitecore.config** / Edge settings). +4. Query returns: **route layout**, **component fields**, **context** (language, site, …). +5. Data passed to rendering (**Placeholders**, **Components**). + +**Documentation note:** The official page links `LayoutService` to `packages/core/src/layout/layout-service.ts` on GitHub; in **this** repo the implementation lives under **`packages/content/src/layout/layout-service.ts`** (see wiki `doc-route-handling-data-fetching.md`). + +## Display name–based routing (Content SDK 1.1+) + +URLs can derive from item **display name** instead of item **name**. Sitemaps for display-name routes may require a **Sitecore patch** (`linkManager` / `useDisplayName="true"`), redeploy, then Sitemap settings → link provider name. After patch, sitemaps may **only** include display-name routes (per official warning). + +Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md b/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md new file mode 100644 index 0000000000..9a39c49cd4 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md @@ -0,0 +1,22 @@ +--- +title: Sitecore Content SDK for SitecoreAI +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/sitecore-content-sdk-for-sitecoreai.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Sitecore Content SDK for SitecoreAI (snapshot) + +The Content SDK enables developers to integrate SitecoreAI content with front-end JavaScript applications. It includes APIs and services that fetch SitecoreAI data to build and deliver pages. Developers can work locally, connect to SitecoreAI Pages visual editor, and use a starter kit template. Open source Apache 2.0; GitHub `Sitecore/content-sdk`. + +## Key features (per official doc) + +- SitecoreAI Pages integration for visual editing and testing. +- Next.js starter template. +- Personalization and component A/B/n testing without custom coding. +- Multi-site support (note: different sites do not support different default languages by default; see Next.js i18n docs). +- GraphQL utilities for layout, content, site info, dictionaries. +- Analytics, events, personalization. +- Framework-specific capabilities (Next.js locales, SSR, SSG). + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md b/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md new file mode 100644 index 0000000000..640c24572d --- /dev/null +++ b/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md @@ -0,0 +1,16 @@ +--- +title: Supporting multilingual applications in Content SDK +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/supporting-multilingual-applications-in-content-sdk.html +doc_version: "2.x" +ingested: "2026-05-14" +--- + +# Multilingual (snapshot) + +Uses SitecoreAI content language versioning. + +- **Page content:** layout service respects language context; GraphQL uses explicit `language` parameter. +- **Dictionary:** GraphQL-powered API; sample pattern `client.getDictionary({ site: page.siteName, locale: page.locale })`. +- **Routing:** Content SDK does not dictate URL structure; sample apps follow route item hierarchy and may use language prefixes (`/about`, `/en/about`, `/es-US/about`). + +Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md b/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md new file mode 100644 index 0000000000..8538a06569 --- /dev/null +++ b/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md @@ -0,0 +1,131 @@ +--- +title: The Sitecore configuration file +source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/the-sitecore-configuration-file.html +doc_version: "2.x" +ingested: "2026-05-14" +reingested: "2026-05-14" +fetch_status: ok +--- + +# The Sitecore configuration file (full snapshot) + +**Version:** 2.x (per official topic) + +Content SDK includes **`sitecore.config.ts`** at the **app root** — central configuration. Starter templates ship a **minimal** file; expand as needed. + +**Import:** + +```ts +import scConfig from 'sitecore.config'; +``` + +**Env resolution:** Many properties have a **corresponding environment variable**. If a property has no explicit value, it falls back to the env var when present; otherwise **defaults** in the config layer apply. + +--- + +## The base configuration + +| Property | Type | Description | Env var | +|----------|------|-------------|---------| +| `api` | object | Connection credentials for SitecoreAI (provide **`edge`** or **`local`**, not both) | n/a | +| `defaultSite` | string | If **multisite** enabled: fallback site. If multisite **off**: site for visitors. Default `''`. | `NEXT_PUBLIC_DEFAULT_SITE_NAME` | +| `defaultLanguage` | string (optional) | Default locale fallback (API, site resolution, middleware, etc.). Must align with framework i18n (e.g. Next.js). Default `'en'`. | `NEXT_PUBLIC_DEFAULT_LANGUAGE` | +| `editingSecret` | string (optional) | Secret for SitecoreAI **editing and preview** when the app is an editing host. Default `'editing-secret-missing'`. | `SITECORE_EDITING_SECRET` | + +### `api` — choose one + +| Branch | Use | +|--------|-----| +| `edge` | SaaS SitecoreAI | +| `local` | Local SitecoreAI in Docker | + +#### `api.edge` + +| Property | Type | Description | Env var | +|----------|------|-------------|---------| +| `contextId` | string | Connect / retrieve data. Default `''`. | Server: `SITECORE_EDGE_CONTEXT_ID`. Client / server fallback: `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | +| `clientContextId` | string (optional) | Client-side operations. Default `''`. | `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | +| `edgeUrl` | string (optional) | SitecoreAI endpoint URL. Default `https://edge-platform.sitecorecloud.io` | `NEXT_PUBLIC_SITECORE_EDGE_URL` | + +#### `api.local` + +| Property | Type | Description | Env var | +|----------|------|-------------|---------| +| `apiKey` | string | API key for GraphQL. Default `''`. | `NEXT_PUBLIC_SITECORE_API_KEY` | +| `apiHost` | string | API hostname. Default `''`. | `NEXT_PUBLIC_SITECORE_API_HOST` | +| `path` | string (optional) | Path appended to `apiHost` for full GraphQL URL. Default `/sitecore/api/graph/edge` | n/a | + +--- + +## Services configuration + +| Property | Purpose | +|----------|---------| +| `layout` | Extra **layout service** settings | +| `dictionary` | Extra **dictionary service** settings | +| `retries` | Retry behavior for **layout**, **dictionary**, and **ErrorPages** by default (on by default for Edge stability) | + +### `layout` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `formatLayoutQuery` | function (optional) | Args: `siteName`, `itemPath`, `locale` — returns first segment of layout GraphQL query. Default format: `layout(site:"${siteName}", routePath:"${itemPath}", language:"${language}")` | n/a | + +### `dictionary` → `caching` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `enabled` | boolean (optional) | Memory cache for dictionary. Default `true` | n/a | +| `timeout` | number (optional) | Cache TTL seconds. Default `60` | n/a | + +### `retries` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `count` | number (optional) | Max GraphQL retries; `0` disables. Default `3` | n/a | +| `retryStrategy` | `RetryStrategy` (optional) | From `@sitecore-content-sdk/nextjs/client`. Default **`DefaultRetryStrategy`**: exponential backoff factor **2** for **429, 502, 503, 504, 520–524** | n/a | + +--- + +## Extra middleware and other configurations + +*(Doc: oriented to Next.js middleware; other frameworks may differ.)* + +### `redirects` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `enabled` | boolean (optional) | Global redirects. Default **`true`** production, **`false`** development | n/a | +| `locales` | string[] (optional) | Locales for redirect strategy; must match app i18n (e.g. `next.config`). Default `['en']` | n/a | + +**Important (official):** SitecoreAI does **not** support redirect **items** — only **redirect maps**. + +### `multisite` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `enabled` | boolean (optional) | Multisite for normal rendering. **Preview mode: multisite always on.** Default `true` | n/a | +| `useCookieResolution` | function (optional) | `req: RequestInit` → optionally resolve site from **`sc_site`** cookie. Default **`true`** on Vercel preview, else **`false`** | n/a | + +### `personalize` + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `enabled` | boolean (optional) | Personalize feature. Default **`true`** prod, **`false`** dev | n/a | +| `edgeTimeout` | number (optional) | Edge personalization timeout (seconds). Default `400` | `PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT` | +| `cdpTimeout` | number (optional) | CDP timeout (seconds). Default `400` | `PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT` | +| `scope` | string (optional) | Personalize scope between environments. Default `''` | `NEXT_PUBLIC_PERSONALIZE_SCOPE` | +| `channel` | string (optional) | CDP channel. Default `WEB` | n/a | +| `currency` | string (optional) | CDP currency. Default `USD` | n/a | + +### Other + +| Property | Type | Description | Env | +|----------|------|-------------|-----| +| `generateStaticPaths` | boolean (optional) | Next.js: whether **`getStaticPaths`** pre-renders paths. **`false`** → ISR for all pages; use **`false`** when app is SitecoreAI **editing host**. Default `true` | `GENERATE_STATIC_PATHS` | +| `disableCodeGeneration` | boolean (optional) | Skip AI component generation / code extraction when `true` | n/a | +| `sitecoreInternalEditingHostUrl` | string (optional) | Internal URL for editing render scenarios (non-standard local setups). SDK **1.1+**. Default: SitecoreAI deploys → `http://localhost:3000`; else **request Host** | `SITECORE_INTERNAL_EDITING_HOST_URL` | + +--- + +Canonical page: `source_url`. diff --git a/llm-wiki/raw/README.md b/llm-wiki/raw/README.md new file mode 100644 index 0000000000..366f503c5e --- /dev/null +++ b/llm-wiki/raw/README.md @@ -0,0 +1,22 @@ +# Raw sources (immutable) + +Place **documentation** and other reference material here. The LLM **reads** these files but must **not** modify them. + +## Intended sources + +- Clipped or exported **official documentation** web pages (markdown or text). +- Supplementary articles you curate. +- Material retrieved via **MCP** (e.g. Sitecore Documentation MCP) should be saved here as markdown when used as the basis for a wiki update, so provenance stays clear. + +## Naming + +Use descriptive filenames, for example: + +- `sitecore-content-sdk-overview.md` +- `creating-a-content-sdk-app.md` + +Optional: prefix with `YYYY-MM-DD-` if you care about ingest order. + +## Truth hierarchy + +When integrating into the wiki: **this repo’s source code and tests are authoritative** if they disagree with documentation. The wiki should state what the code does and cite file paths; documentation conflicts should be noted explicitly. diff --git a/llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf b/llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6811353701ba3f6c50a8414b1e21af70dddcbb42 GIT binary patch literal 319930 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�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 P*Z_lT;X+j%HLm{y##2m< literal 0 HcmV?d00001 diff --git a/llm-wiki/wiki/common/doc-config-environment-variables.md b/llm-wiki/wiki/common/doc-config-environment-variables.md new file mode 100644 index 0000000000..c9ada469d6 --- /dev/null +++ b/llm-wiki/wiki/common/doc-config-environment-variables.md @@ -0,0 +1,43 @@ +# Environment variables and `buildFallbackConfig` + +How **`@sitecore-content-sdk/content`** fills **`SitecoreConfig`** from **`process.env`** (and from head-supplied env objects). Same variable *names* support **Next** (often **`NEXT_PUBLIC_*`** or server-only), **Angular** (browser-safe **`CSDK_PUBLIC_*`** literals + server **`process.env`**), and any other consumer of **`defineConfig`**. + +**Code:** `packages/content/src/config/define-config.ts` — **`buildFallbackConfig`**. + +## `buildFallbackConfig` env keys + +| Area | Variables (chained with `\|\|`) | +|------|----------------------------------| +| Edge hostname | `CSDK_PUBLIC_SITECORE_EDGE_HOSTNAME`, `SITECORE_EDGE_PLATFORM_HOSTNAME_ENV` | +| Edge context | `SITECORE_EDGE_CONTEXT_ID` | +| Client context | `SITECORE_EDGE_CLIENT_CONTEXT_ID`, `CSDK_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | +| Local key | `SITECORE_API_KEY`, `CSDK_PUBLIC_SITECORE_API_KEY`, `NEXT_PUBLIC_SITECORE_API_KEY` | +| Local host | `SITECORE_API_HOST`, `CSDK_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_HOST` | +| Editing secret | `SITECORE_EDITING_SECRET` (fallback placeholder if unset) | +| Default site | `SITECORE_DEFAULT_SITE`, `CSDK_PUBLIC_SITECORE_DEFAULT_SITE`, `CSDK_PUBLIC_DEFAULT_SITE` | +| Default language | `SITECORE_DEFAULT_LANGUAGE`, `CSDK_PUBLIC_DEFAULT_LANGUAGE` → default **`en`** | +| Personalize | `PERSONALIZE_MIDDLEWARE_*_TIMEOUT`, scope envs, … | +| Local GraphQL path | Hardcoded **`/sitecore/api/graph/edge`** unless overridden in config | + +## By head (how env reaches `defineConfig`) + +### Next.js + +- **`getNextFallbackConfig`** (in **`@sitecore-content-sdk/nextjs/config`**) layers **`NEXT_PUBLIC_*`** and other Next-specific keys before calling content **`defineConfig`**. +- **`sitecore.config.ts`** may still set values explicitly; merge rules in [doc-sitecore-config-input.md](doc-sitecore-config-input.md) apply. + +### Angular + +- **Browser:** `process.env` is not reliable in the client bundle. The template runs **`scripts/generate-environment.ts`**, which reads **`.env`**, **`.env.local`**, **`.env.dev`** / **`.env.prod`** and writes only keys prefixed with **`CSDK_PUBLIC_*`** into **`src/environments/environment.dev.ts`** / **`environment.prod.ts`** as string literals. +- **`sitecore.config.ts`** passes that object as **`clientEnv`** into **`defineConfig`** from **`@sitecore-content-sdk/angular`**, which merges **`clientEnv`** with **`getProcessEnv()`** (Node/SSR only) and forwards to content **`defineConfig`**. +- Server entry loads dotenv (e.g. **`load-env.ts`**) so **`SITECORE_*`** secrets exist at runtime for SSR and Express without embedding them in the browser bundle. + +## Product / security notes + +- Use **`.env.*.example`** (placeholders only) in templates; copy to **`.env.local`** for real values. Never commit secrets. +- Next template examples: [../content-sdk-nextjs/doc-example-environment-variable-files.md](../content-sdk-nextjs/doc-example-environment-variable-files.md). + +## Related + +- [doc-sitecore-config-input.md](doc-sitecore-config-input.md) — full **`SitecoreConfigInput`** reference +- [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) — GraphQL endpoint resolution from merged config diff --git a/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md b/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md new file mode 100644 index 0000000000..20367cf492 --- /dev/null +++ b/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md @@ -0,0 +1,78 @@ +# `SitecoreClient` and GraphQL (content package) + +**Mental model:** Experience Edge (or local GraphQL) returns **JSON** layout/dictionary data. The head’s **`sitecore.config.ts`**, merged via **`defineConfig`** ([doc-sitecore-config-input.md](doc-sitecore-config-input.md)) and env ([doc-config-environment-variables.md](doc-config-environment-variables.md)), drives **`createGraphQLClientFactory`** and **`SitecoreClient`**. This path is the same for **Next**, **Angular**, and any app using **`@sitecore-content-sdk/content`**. + +**Packages:** `@sitecore-content-sdk/core` (HTTP GraphQL client, retry), `@sitecore-content-sdk/content` (`SitecoreClient`, layout/dictionary/editing services). + +## GraphQL client factory + +**Source:** `packages/content/src/client/utils.ts` — **`createGraphQLClientFactory`**. + +`GraphQLClientOptions` = `Pick<SitecoreConfigInput, 'api'>` + optional **`FetchOptions`**. + +### Branching (resolved endpoint) + +| Condition | Result | +|-----------|--------| +| `api.edge.contextId` (server) | Edge endpoint from **`getEdgeProxyContentUrl(edgeUrl)`** + server `contextId` | +| Browser + `api.edge.clientContextId` | Same Edge base + **client** `contextId` | +| `api.local.apiKey` && `api.local.apiHost` | `` `${apiHost}${path}` `` + API key header | +| Browser, none of the above | Warn; dummy endpoint `/api/graphql` | +| Server, none | **Throw** (misconfiguration) | + +### Edge content URL + +`packages/content/src/client/edge-proxy.ts` — **`getEdgeProxyContentUrl`** appends **`/v1/content/api/graphql/v1`** to the normalized Edge base (not the bare `edgeUrl` root alone). + +### Local URL + +`` `${local.apiHost}${local.path}` ``; default **`path`** from **`buildFallbackConfig`**: **`/sitecore/api/graph/edge`**. + +### Head-specific consumers + +- **Any head:** **`SitecoreClient`** constructor uses the factory from merged **`api`** + **`retries`**. +- **Next.js only:** dev **proxy** (`packages/nextjs/src/proxy/proxy.ts`) — see [../content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md). + +## `SitecoreClient` role + +Framework-agnostic client: layout pages, dictionary, preview/editing, error pages, sitemap (**XML** string), robots, **`getData`** (raw GraphQL). + +**Implementation:** `packages/content/src/client/sitecore-client.ts`. + +### Construction + +- **`createGraphQLClientFactory`** from init **`api`** + retries — table above. +- Services: **`layoutService`** (**`LayoutService`** in **`packages/content/src/layout/`**), **`dictionaryService`**, **`editingService`**, **`errorPagesService`**, **`sitePathService`**, **`componentService`**. Overridable via **`SitecoreClientInit.custom`**. +- **`getPage`** → **`layoutService.fetchLayoutData`** → personalization hooks / **`applyContentRewrite`** → **`Page`**. + +### `BaseSitecoreClient` methods + +| Method | Role | +|--------|------| +| `getData` | Raw GraphQL | +| `getPage` | Route layout + metadata | +| `getDictionary` | Dictionary phrases | +| `getPreview` | Preview / editing layout via **`EditingService`** | +| `getDesignLibraryData` | Design library | +| `getErrorPages` / `getErrorPage` | Error content | +| `getPagePaths` | SSG paths | +| `getHeadLinks` | Styles / theme links | +| `getSiteMap` | Sitemap XML string | +| `getRobots` | robots.txt | + +## Angular usage (pointer) + +`@sitecore-content-sdk/angular` re-exports **`SitecoreClient`** from **`@sitecore-content-sdk/content/client`**. The template uses a lazy singleton **`getClient()`** that does **`new SitecoreClient(scConfig)`** so credentials are not required at Angular build-time route extraction. Loaders call **`resolveSitecorePage(path, scConfig, getClient())`**, which delegates to **`client.getPage`**. + +## Next.js usage (pointer) + +**`SitecoreNextjsClient`** extends the base with **`parsePath`**, App Router preview headers, **`getComponentData`**, etc. See [../content-sdk-nextjs/doc-sitecore-client-apis.md](../content-sdk-nextjs/doc-sitecore-client-apis.md). + +## Related + +- [doc-sitecore-config-input.md](doc-sitecore-config-input.md) +- [../content-sdk-nextjs/doc-architecture-edge-graphql.md](../content-sdk-nextjs/doc-architecture-edge-graphql.md) — official doc alignment + `package.json` note + +## Raw + +- `llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md` (Services and APIs ingest) diff --git a/llm-wiki/wiki/common/doc-sitecore-config-input.md b/llm-wiki/wiki/common/doc-sitecore-config-input.md new file mode 100644 index 0000000000..8f58dff589 --- /dev/null +++ b/llm-wiki/wiki/common/doc-sitecore-config-input.md @@ -0,0 +1,64 @@ +# `SitecoreConfigInput` and `sitecore.config.ts` (content package) + +Framework-agnostic configuration consumed by **`@sitecore-content-sdk/content`** (`defineConfig`, `SitecoreClient`, GraphQL factory). Next.js and Angular add thin **`defineConfig`** wrappers that supply environment maps before calling this layer. + +**Code:** `packages/content/src/config/models.ts` (types), `packages/content/src/config/define-config.ts` (merge + validation). + +## Where `sitecore.config.ts` lives + +- Generated apps: root **`sitecore.config.ts`** (any head). +- Monorepo templates: Next (Pages / App Router), Angular under `packages/create-content-sdk-app/src/templates/*/`. + +## Merge pipeline (content `defineConfig`) + +1. **`buildFallbackConfig(env)`** fills gaps from process-backed env keys (see [doc-config-environment-variables.md](doc-config-environment-variables.md)). +2. **`resolveConfig`** **`deepMerge`** — skips **`undefined`** and empty string **`''`** overrides so env can intentionally clear some paths. +3. **`resolveEdgeUrl`** normalizes merged **`api.edge.edgeUrl`**. +4. **CLI mode** (`SITECORE_CLI_MODE=true`): lazy validation **Proxy** on sensitive paths; otherwise **`validateApiConfiguration`** runs immediately (server must have Edge **`contextId`** or local **`apiHost`** + **`apiKey`**). + +## `SitecoreConfig` shape + +Runtime type is **`SitecoreConfig`** = **`DeepRequired<SitecoreConfigInput>`**. + +### Top-level keys + +| Key | Type | Purpose | +|-----|------|---------| +| `api` | optional object | **`edge`** and/or **`local`**; runtime choice in **`createGraphQLClientFactory`**. | +| `defaultLanguage` | `string?` | Fallback locale. | +| `defaultSite` | `string?` | Fallback site name. | +| `editingSecret` | `string?` | Editing / preview route auth. | +| `retries` | object? | `count`, `retryStrategy` (**`RetryStrategy`**). | +| `layout` | object? | `formatLayoutQuery` hook. | +| `dictionary` | object? | `caching.enabled`, `caching.timeout` (seconds). | +| `multisite` | object? | `enabled`, `useCookieResolution(req?,res?) => boolean`. | +| `personalize` | object? | `enabled`, `edgeTimeout`, `cdpTimeout`, `scope`, `channel`, `currency`. | +| `redirects` | object? | `enabled`, `locales` — **redirect maps only** (not redirect items). | +| `rewriteMediaUrls` | `boolean \| ((v: string) => string)?` | Media/content URL rewrite in layout JSON (`SitecoreClient.applyContentRewrite`). | +| `disableCodeGeneration` | `boolean?` | Opt out of code-generation tooling. | + +### `api.edge` / `api.local` + +See **`SitecoreConfigInput`** JSDoc in **`models.ts`**: Edge **`contextId`**, **`clientContextId`**, **`edgeUrl`** vs local **`apiKey`**, **`apiHost`**, **`path`**. + +### `SitecoreCliConfigInput` (separate `sitecore.cli.config`) + +Holds **`config: SitecoreConfig`**, optional **`build.commands`**, **`scaffold.templates`** (`ScaffoldTemplate[]`), **`componentMap`** (`GenerateMapArgs` + optional **`generator`**). See **`models.ts`** and **`packages/content/src/tools/generate-map.ts`**. + +## Head-specific wrappers + +| Head | Wrapper | Notes | +|------|---------|-------| +| **Next.js** | `@sitecore-content-sdk/nextjs/config` **`defineConfig`** | **`getNextFallbackConfig`** adds **`NEXT_PUBLIC_*`**, preview multisite cookie behavior, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`**, etc. | +| **Angular** | `@sitecore-content-sdk/angular` **`defineConfig`** | Merges **`clientEnv`** (generated **`environment*.ts`**) with **`getProcessEnv()`** on the server, then calls content **`defineConfig`**. | + +## Related + +- [doc-config-environment-variables.md](doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys +- [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) — **`SitecoreClient`** + GraphQL URL selection +- [../content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) — Next-only **`SitecoreConfigInput`** fields and App Router multisite notes +- [../content-sdk-angular/doc-environment-and-define-config-angular.md](../content-sdk-angular/doc-environment-and-define-config-angular.md) — Angular env generation + +## Raw (official topic) + +- `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md` diff --git a/llm-wiki/wiki/common/index.md b/llm-wiki/wiki/common/index.md new file mode 100644 index 0000000000..b9e07f1725 --- /dev/null +++ b/llm-wiki/wiki/common/index.md @@ -0,0 +1,20 @@ +# Common wiki (shared packages) + +Framework-agnostic Content SDK knowledge: **`packages/core`**, **`packages/content`** (`SitecoreClient`, `defineConfig`, layout GraphQL, editing helpers when described without a specific head), **`packages/cli`**, and **cross-head env / config contracts**. + +## Pages (canonical for all heads) + +| Page | Summary | +|------|---------| +| [doc-sitecore-config-input.md](doc-sitecore-config-input.md) | **`SitecoreConfigInput`** / **`SitecoreConfig`**, merge pipeline, CLI config, head wrappers | +| [doc-config-environment-variables.md](doc-config-environment-variables.md) | **`buildFallbackConfig`** env keys; Next vs Angular env wiring | +| [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) | **`createGraphQLClientFactory`**, Edge/local URLs, **`SitecoreClient`** methods | + +## Head wikis + +| Stack | Index | +|-------|--------| +| Next.js | [../content-sdk-nextjs/index.md](../content-sdk-nextjs/index.md) — middleware, `SitecoreNextjsClient`, templates | +| Angular | [../content-sdk-angular/index.md](../content-sdk-angular/index.md) — loaders, SSR, `environment*.ts` | + +When a topic is duplicated between a head wiki and **common**, treat **common** as canonical for **`@sitecore-content-sdk/content`** behavior; head pages keep integration-only deltas. diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md new file mode 100644 index 0000000000..ad53f6e7d3 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md @@ -0,0 +1,21 @@ +# Goals, challenges, and foundation (Angular head) + +Aligned with the **Goal**, **Challenges**, and **Foundation** sections of the ingested design PDF and with the shipped Angular integration. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Goals + +The Angular head is meant to reuse the same Content SDK concepts as other stacks: a shared **Sitecore client**, **component map** (and future **import map** parity), **`sitecore.config`** / CLI tooling, and familiar layout/page data types from `@sitecore-content-sdk/content`. + +## Challenges + +1. **Bundle shape:** logic that must run only on the server must not be pulled into the browser bundle incorrectly. Loaders and middleware stay on clearly separated paths; loader bodies should use static imports rather than Angular DI when they also run inside Express (see [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md)). + +2. **`process.env` on the client:** the browser bundle does not have Node’s `process.env`. The scaffold mitigates this with a **build-time** script that emits `environment.*.ts` from **`CSDK_PUBLIC_*`** variables (see [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md)). + +## Foundation + +The PDF’s “foundation” points at the **loader system**: route **`resolve`** functions backed by a **loader registry**, server execution with **`TransferState`**, and a small **Express** RPC surface for client navigations. That design is implemented under `packages/angular/src/loaders/` and `packages/angular/src/server/loader-data-service-middleware.ts`. + +**Next:** [Loaders — routes and registry](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md new file mode 100644 index 0000000000..a5cf4a7186 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md @@ -0,0 +1,29 @@ +# Angular architecture — index + +This hub splits the ingested **JSS-Angular Live Design** architecture PDF into focused wiki pages, each checked against **`@sitecore-content-sdk/angular`** and the **Angular template** under `packages/create-content-sdk-app/src/templates/angular/`. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · PDF in repo: [`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`](../../raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf) + +## Pages (by PDF section) + +| Topic | Page | +|--------|------| +| Goal, challenges, foundation | [doc-architecture-goals-challenges-and-foundation.md](doc-architecture-goals-challenges-and-foundation.md) | +| Route `resolve`, registry, `pageLoader` pattern | [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) | +| `loaderResolver`, `TransferState`, `/_data`, errors / redirects | [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) | +| `PreLoaderDataService` (parallel prefetch) | [doc-preloader-data-service.md](doc-preloader-data-service.md) | +| Loaders outside `inject()` in loader bodies | [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) | +| Env script + `defineConfig` | [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) | +| Standalone components, map, placeholders | [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) | +| SSR + Express middleware order | [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) | +| `sitecore.config.ts` | [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) | +| Field directives | [doc-field-directives.md](doc-field-directives.md) | +| Editing / page context | [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | +| Multisite | [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | +| Personalization | [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | + +## See also + +- [index.md](index.md) — Angular wiki hub +- [Common wiki — config, env, SitecoreClient + GraphQL](../common/index.md) — **`@sitecore-content-sdk/content`** canonical pages +- [Next.js `sitecore.config` (Next-only fields)](../content-sdk-nextjs/doc-sitecore-config.md) — **`getNextFallbackConfig`**, App Router multisite notes diff --git a/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md b/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md new file mode 100644 index 0000000000..d5907d6fa1 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md @@ -0,0 +1,20 @@ +# Components, component map, and placeholders (Angular) + +Standalone components, generated **component map**, and placeholder resolution aligned with other Content SDK heads. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Standalone components + +The Angular template and package assume **standalone** components (no NgModule feature pattern for app components). + +## Component map + +- Generation is driven from **`sitecore.cli.config.ts`** (same family as Next). +- **`packages/angular/src/tools/generate-map.ts`** implements Angular map generation; output is consumed via **`SITECORE_COMPONENT_MAP`** injection token in **`app.config.ts`** (`useValue: componentMap` from **`.sitecore/component-map`**). + +## Placeholders + +**`sc-placeholder`** and **`placeholder-utils.ts`** resolve rendering names to standalone components using the same map shape as Next (PascalCase, default + variant files at generation time). Editing mode affects which renderings are exposed (`getPlaceholderRenderings` takes **`isEditing`** from **`SitecoreContextService`** — see [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md)). + +**Related:** [doc-field-directives.md](doc-field-directives.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md new file mode 100644 index 0000000000..bcc6048726 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md @@ -0,0 +1,50 @@ +# Editing and page context (Angular) + +The PDF marked **Editing** as TBA. In code, editing is surfaced primarily through **`SitecoreContextService`** and layout **`Page.mode`**, not through a separate Next-style middleware document for Angular. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## `SitecoreContextService` + +Injectable **`providedIn: 'root'`** with: + +- **`page`** — read-only signal of the current **`Page | null`**. +- **`isEditing`** — derived from **`page()?.mode?.isEditing`**. + +Call **`setPage(page)`** from the route shell when **`page`** (and other) route data resolves so placeholders, forms, and directives see the same context. + +```4:27:packages/angular/src/lib/sitecore-context.service.ts +/** + * Provides request-scoped Sitecore context (current page, mode flags) to the Angular component tree. + * Analogous to React's `SitecoreProvider` / `useSitecore()`. + * + * Set once per navigation via `setPage(page)` — typically from the route component + * after the page loader resolves. All consumers (placeholders, field directives, forms) + * inject this service to read the current page and editing state. + * @public + */ +@Injectable({ providedIn: 'root' }) +export class SitecoreContextService { + /** Current Sitecore page data (layout + mode). */ + readonly page: Signal<Page | null>; + + /** Whether the current page is in editing mode. */ + readonly isEditing: Signal<boolean>; + + private readonly _page: WritableSignal<Page | null>; + + constructor() { + const pageSignal = signal<Page | null>(null); + this._page = pageSignal; + this.page = pageSignal.asReadonly(); + this.isEditing = computed(() => pageSignal()?.mode?.isEditing ?? false); + } +``` + +## Consumers + +- **`sc-placeholder`** uses **`isEditing`** when choosing placeholder renderings. +- **`sc-form`** skips certain client behavior when **`isEditing`** is true. +- **`@sitecore-content-sdk/angular`** re-exports **`isEditorActive`** and **`resetEditorChromes`** from **`@sitecore-content-sdk/content/editing`** for chrome detection utilities used with Experience Editor–style flows. + +**Related:** [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md new file mode 100644 index 0000000000..e91d014fad --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md @@ -0,0 +1,23 @@ +# Environment and `defineConfig` (Angular) + +How the Angular head avoids raw **`process.env`** in the browser and still feeds **`defineConfig`** from `@sitecore-content-sdk/content`. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +**Shared with all heads:** [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) ( **`buildFallbackConfig`** env keys, Next vs Angular wiring) · [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) (merge pipeline, **`SitecoreConfigInput`** tables). + +## Angular `defineConfig` wrapper + +`packages/angular/src/config/define-config.ts` merges a **`clientEnv`** record with **`getProcessEnv()`** (Node on the server, empty on the client) and forwards to the shared **`defineConfig`** from **`@sitecore-content-sdk/content/config`**. Call sites pass **`clientEnv`** from generated **`environment.ts`** so public values exist in browser bundles. + +## Scaffold: `generate-environment.ts` + +The template script **`packages/create-content-sdk-app/src/templates/angular/scripts/generate-environment.ts`** loads **`.env`**, **`.env.local`**, and mode-specific **`.env.dev`** / **`.env.prod`**, filters keys prefixed with **`CSDK_PUBLIC_`**, and writes **`src/environments/environment.dev.ts`** and **`environment.prod.ts`**. The build selects the right variant so **`defineConfig`** receives stable literals instead of runtime **`process.env`** reads in client code. + +Only **`CSDK_PUBLIC_*`** keys are embedded in those files; server secrets use **`process.env`** at runtime (see script header comment and **`load-env`** / server bootstrap). + +## GraphQL and `SitecoreClient` + +Merged **`SitecoreConfig`** drives the same **`createGraphQLClientFactory`** and **`SitecoreClient`** as Next. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — especially **Angular usage** for **`getClient()`** + **`new SitecoreClient(scConfig)`**. + +**Related:** [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) · [Next.js sitecore config (Next-only fields)](../content-sdk-nextjs/doc-sitecore-config.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md b/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md new file mode 100644 index 0000000000..52ea2e7095 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md @@ -0,0 +1,23 @@ +# Field directives (`@sitecore-content-sdk/angular`) + +The PDF marked **Fields** as TBA; the package already ships a small set of **attribute directives** under `packages/angular/src/field-directives/`. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Overview + +| Directive | Role | +|-----------|------| +| **`scText`** | Binds **`TextField`** to host text; default **HTML-encode** via `textContent`, optional `innerHTML` when encoding is off. | +| **`scRichText`** | Binds **`TextField`** rich text to **`innerHTML`** using **`DomSanitizer.bypassSecurityTrustHtml`** (CMS HTML). | +| **`scImage`** | Renders **`ImageField`** on **`img`** (src, alt, dimensions when present). | +| **`scLink`** | Anchor from **`LinkField`** / helpers in **`link-field-utils.ts`**. | +| **`scRouterLink`** | Wraps **`RouterLink`** with Sitecore link resolution (internal vs external). | + +Specs live alongside each directive (`*.spec.ts`). + +## Security note + +**`scText`** with **`scTextEncode="false"`** assigns **`innerHTML`** from string values — use only for trusted content. **`scRichText`** intentionally bypasses strict sanitization for typical CMS HTML; follow Sitecore authoring and CSP practices. + +**Related:** [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md b/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md new file mode 100644 index 0000000000..d1d9262d4a --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md @@ -0,0 +1,77 @@ +# `loaderResolver` — `TransferState`, `/_data`, and outcomes + +Execution path for **`loaderResolver(loaderId)`**: server vs browser, **`TransferState`**, HTTP fallback, and error / redirect handling. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## State key + +Keys are built as `makeStateKey(\`loader:${loaderId}:${url}\`)` so each loader and URL pair is isolated (`loader-resolver.ts`). + +## Server (SSR) + +On the server, the resolver **`inject`s** the **`LOADER_REGISTRY`**, loads the **`LoaderFn`**, builds **`LoaderContext`** (including **`requestContext`** from the optional Angular **`REQUEST`** token when present), runs the loader, and on success **`transferState.set(key, result)`** before returning the value. Redirect results from the loader are turned into router redirects via **`applyRedirect`**. + +```109:139:packages/angular/src/loaders/loader-resolver.ts + const url = state.url; + const key = stateKey(loaderId, url); + + if (isPlatformBrowser(platformId)) { + try { + return await resolveOnBrowser(route, state, loaderId, router); + } catch (e) { + // special handling for browser, as navigation error for handleNavigationError is only generated on server + return redirectOnNavigationError(e as Error, url, notFoundRoute, errorRoute, router); + } + } + + const loader = registry[loaderId]; + + if (!loader) { + throw new Error(`No loader registered for id "${loaderId}"`); + } + + const requestContext = request ? extractRequestContext(request) : undefined; + + const result = await loader({ + url, + params: route.params, + query: route.queryParams, + requestContext, + }); + if (isLoaderRedirectResult(result)) { + return applyRedirect(router, result.loaderRedirectTarget); + } + transferState.set(key, result); + return result; +``` + +**Note:** On the server, **`route.params`** are the params for the **activated** route snapshot only; the browser path merges **`pathFromRoot`** when calling **`LoaderDataService.getData`** (see below). + +## Browser + +1. If **`TransferState.hasKey(key)`**, the value is **`get`** then **`remove`** (one-shot hydration / navigation). +2. Otherwise **`LoaderDataService.getData`** **`POST`s** to the loader endpoint (default **`/_data`**, from **`LOADER_DATA_ENDPOINT`** in `packages/angular/src/server/constants.ts`). The service deduplicates concurrent requests per cache key (`loader:${loaderId}:${url}`). + +Browser resolver merges **all** parent params for the HTTP request: + +```77:84:packages/angular/src/loaders/loader-resolver.ts + const allParams = route.pathFromRoot.reduce((acc, r) => ({ ...acc, ...r.params }), {}) as Params; + + const resp = await loaderData.getData({ + url, + loaderId, + params: allParams, + query: route.queryParams as Record<string, string | string[]>, + }); +``` + +3. **`LoaderApiResponse`** kinds map to throws or redirects: **`error`** → **`LoaderHttpError`**, **`notFound`** → **`NotFoundNavigationError`**, **`redirect`** → **`applyRedirect`**. + +Express mirrors the same execution and response kinds in **`createLoaderDataServiceMiddleware`** (`packages/angular/src/server/loader-data-service-middleware.ts`), including mapping **`NotFoundNavigationError`** to a **`notFound`** payload. + +## Custom endpoint + +Apps may provide **`FETCH_DATA_ENDPOINT`** to override the URL **`LoaderDataService`** calls; it defaults to **`LOADER_DATA_ENDPOINT`** when omitted (`loader-data.service.ts`). + +**Related:** [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) · [doc-preloader-data-service.md](doc-preloader-data-service.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md b/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md new file mode 100644 index 0000000000..b0ac313bd6 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md @@ -0,0 +1,18 @@ +# Loaders outside Angular `inject()` in loader bodies + +Why loader functions should not rely on **`inject()`** or constructor DI for Sitecore wiring. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Two execution contexts + +A **`LoaderFn`** runs in: + +1. **Angular SSR / server resolver** — inside Angular’s injection context **for the resolver wrapper**, but the **loader callback** is still invoked as a plain async function with **`LoaderContext`** (`loader-resolver.ts` server branch). +2. **Express loader middleware** — **`executeLoader`** calls the same **`LoaderFn`** with only **`LoaderContext`**; there is **no** Angular injector (`loader-data-service-middleware.ts`). + +So anything used **inside** the loader body must be available **without** calling **`inject()`** from within that body. The supported pattern is **static imports**: default **`sitecore.config`**, **`getClient()`** factory module, and helpers such as **`resolveSitecorePage`** from **`@sitecore-content-sdk/angular`**. + +The **resolver factory** itself uses **`inject()`** for **`TransferState`**, **`Router`**, **`LOADER_REGISTRY`**, etc.; that is fine because it only runs inside Angular. + +**Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md b/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md new file mode 100644 index 0000000000..13229eef1e --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md @@ -0,0 +1,41 @@ +# Loaders — route configuration, registry, and `pageLoader` + +How Angular wires **route `resolve`** to named loaders via **`provideLoaderRegistry`** and the typical **`pageLoader`** pattern. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Route configuration + +Routes attach **`loaderResolver('<id>')`** to `resolve` keys (for example `page`, `dictionary`). The resolver is created by `loaderResolver` in `packages/angular/src/loaders/loader-resolver.ts` and tagged with a **`LOADER_ID`** symbol so `PreLoaderDataService` can discover it (see [doc-preloader-data-service.md](doc-preloader-data-service.md)). + +## Registry in `app.config.ts` + +The generated app provides the registry object with **`provideLoaderRegistry(LOADERS)`** and registers **`PreLoaderDataService`** (template: `packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts`). + +## Default page loader (example) + +Loaders are plain **`LoaderFn`** functions in app code. They receive **`LoaderContext`** (`url`, `params`, `query`, optional `requestContext` on the server). The usual **page** loader calls **`resolveSitecorePage`** with **`context.url`**, the default **`sitecore.config`**, and a shared **`getClient()`** singleton — all **imported**, not injected in the loader body, so the same function runs in SSR resolvers and in the Express loader middleware. + +`resolveSitecorePage` is documented as taking a **path**; `LoaderContext.url` is the current URL path for the navigation. + +```19:33:packages/angular/src/lib/sitecore-page-resolver.ts +export async function resolveSitecorePage( + path: string, + sitecoreConfig: SitecoreConfig, + client: SitecoreClient, + options?: { locale?: string; site?: string } +): Promise<Page | null> { + const pageOptions: PageOptions = {}; + if (options?.locale) { + pageOptions.locale = options.locale || sitecoreConfig.defaultLanguage; + } + if (options?.site) { + pageOptions.site = options.site || sitecoreConfig.defaultSite; + } + return client.getPage(path, pageOptions); +} +``` + +On **not found**, loaders throw **`NotFoundNavigationError`** so the resolver / middleware can map that to a **404** response or not-found route (see [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md)). + +**Related:** [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) · [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md new file mode 100644 index 0000000000..29318fadcb --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md @@ -0,0 +1,17 @@ +# Multisite (Angular) — status + +The PDF marked **Multisite** as TBA. **`resolveSitecorePage`** already accepts optional **`site`** (and **`locale`**) overrides on top of **`sitecore.config`** defaults, but high-level multisite resolution (cookie vs host, etc.) is not documented as a first-class Angular feature in the same way as Next middleware. + +```4:9:packages/angular/src/lib/sitecore-page-resolver.ts +/** + * Resolves layout/page data for a route path using a {@link SitecoreClient} and Sitecore config. + * Import your `sitecore.config` default and shared client (e.g. `getClient()`) from the app; + * this stays usable from route loaders without Angular injection context. + * + * Future: add helpers for personalization and multisite alongside this call. + * @param {string} path - Route path (e.g. `'/'` or `'/about'`). +``` + +**Practical note:** apps can pass **`options.site`** / **`options.locale`** from loader logic once they determine the active site (custom resolver, headers, etc.). Shared **`SitecoreConfig`** multisite keys are described in [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md). + +**Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md new file mode 100644 index 0000000000..b7172f4a8e --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md @@ -0,0 +1,7 @@ +# Personalization (Angular) — status + +The PDF marked **Personalization** as TBA. The Angular **`resolveSitecorePage`** helper is a thin **`client.getPage`** wrapper; its JSDoc explicitly calls out **future** helpers for **personalization** (and multisite) next to this call. + +Until those helpers exist, personalization behavior depends on what **`SitecoreClient.getPage`** and your **`sitecore.config`** (for example **`personalize`** service settings) already provide — same underlying **`@sitecore-content-sdk/content`** stack as other heads. See [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) for config surface. + +**Related:** [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) · [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md b/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md new file mode 100644 index 0000000000..dc400868d0 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md @@ -0,0 +1,34 @@ +# `PreLoaderDataService` + +Browser-only **prefetch** of loader data for all **`loaderResolver`** entries on the target route so sequential Angular resolvers often hit **`LoaderDataService`** cache or join in-flight requests. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Behavior + +- Subscribes to the router’s **`ActivationStart`** events. +- When the activated snapshot is a **leaf** (no `children.length`), it collects every **`resolve`** function on **`pathFromRoot`** that carries the **`LOADER_ID`** symbol (set by **`loaderResolver`**). +- For each collected **`LoaderDataRequest`**, it calls **`LoaderDataService.prefetch()`**, which is a **no-op on the server** (`isPlatformBrowser` guard in both services). + +```24:34:packages/angular/src/loaders/pre-loader-data.service.ts +/** + * PreLoaderDataService kicks off loader data fetches for all loaders in the current route + * and its parent routes in parallel, so that when Angular runs resolvers sequentially, + * resolvers get cache hits or join already-pending requests instead of waiting. + * + * Subscribes to the router's ActivationStart event and prefetches for the + * ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader + * resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then + * calls LoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches + * run in parallel; results are stored in LoaderDataService cache for getData() to consume. + * @public + */ +``` + +Prefetch issues **`HttpClient.post`** asynchronously; the constructor’s **`for`** loop starts each prefetch without awaiting, so multiple loaders start **without waiting for each other**. + +## Template wiring + +The Angular scaffold registers **`PreLoaderDataService`** next to **`provideLoaderRegistry(LOADERS)`** in **`app.config.ts`**. + +**Related:** [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md new file mode 100644 index 0000000000..c4b8bab861 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md @@ -0,0 +1,17 @@ +# `sitecore.config.ts` (Angular) + +Angular apps use the same root **`sitecore.config.ts`** model as other Content SDK heads. + +**Shared reference:** [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, **`api.edge` / `api.local`**, merge pipeline. [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — env keys consumed by **`buildFallbackConfig`**. [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — how config becomes GraphQL URLs and **`SitecoreClient.getPage`**. + +## In the template + +The scaffold imports the default config into **`app.config.ts`** (`provideSitecoreAngular({ sitecoreConfig: scConfig, sitecoreClient: getClient(), ... })`) and into loaders via a **static import** of **`sitecore.config`**. + +## `SitecoreClient` in generated apps + +`packages/create-content-sdk-app/src/templates/angular/src/content-sdk/client/sitecore-client.ts` exposes **`getClient()`**: a lazy singleton **`new SitecoreClient(scConfig)`** so the Angular build does not require live credentials during route extraction. **`resolveSitecorePage`** in loaders calls **`client.getPage(path, pageOptions)`** with optional **`locale`** / **`site`** overrides. + +The **`SitecoreClient`** class and GraphQL behavior are unchanged from **`@sitecore-content-sdk/content`**; **`@sitecore-content-sdk/angular`** re-exports the client surface from **`@sitecore-content-sdk/content/client`**. + +**Related:** [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) · [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md b/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md new file mode 100644 index 0000000000..5d208aee7f --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md @@ -0,0 +1,17 @@ +# SSR, Express, and loader middleware (Angular) + +Express bootstrap order for **`loader-data-service`**, JSON parsing, and the Angular SSR handler. + +**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) + +## Middleware order + +The design PDF states that **`loader-data-service`** is registered **before** the browser static folder and the main Angular SSR handler. The template’s **`server.ts`** uses **`express.json()`** first (so **`POST /_data`** bodies parse), then **`createLoaderDataServiceMiddleware({ loaders: LOADERS })`**, then static assets, then the SSR entry. + +That order matters because the browser **`LoaderDataService`** issues **`POST`** requests with a JSON body to the default **`LOADER_DATA_ENDPOINT`** (**`/_data`**, `packages/angular/src/server/constants.ts`). + +## Handler shape + +**`createLoaderDataServiceMiddleware`** exposes **GET** and **POST** on the same path, validates **`loaderId` / `url` / params / query**, runs **`executeLoader`**, and returns a **`LoaderApiResponse`** JSON payload (`loader-data-service-middleware.ts`). + +**Related:** [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/index.md b/llm-wiki/wiki/content-sdk-angular/index.md new file mode 100644 index 0000000000..770542c0c4 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/index.md @@ -0,0 +1,49 @@ +# Content SDK Angular wiki + +**Scope:** **`@sitecore-content-sdk/angular`**, the **Angular** scaffold under **`packages/create-content-sdk-app/src/templates/angular`**, and Angular-specific **sitecore.config** / **environment** generation. + +## Pages + +| Page | Summary | +|------|---------| +| [doc-architecture-loaders-and-ssr.md](doc-architecture-loaders-and-ssr.md) | **Architecture index** — links to subsection pages + PDF path | +| [doc-architecture-goals-challenges-and-foundation.md](doc-architecture-goals-challenges-and-foundation.md) | Goals, bundle/env challenges, loader foundation | +| [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) | Route `resolve`, `provideLoaderRegistry`, `pageLoader` / `resolveSitecorePage` | +| [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) | `loaderResolver`, `TransferState`, `/_data`, merged params, outcomes | +| [doc-preloader-data-service.md](doc-preloader-data-service.md) | `PreLoaderDataService`, `ActivationStart`, parallel prefetch | +| [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) | Loaders in Express vs Angular — no `inject()` inside loader bodies | +| [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) | **`generate-environment`**, **`CSDK_PUBLIC_*`**, Angular **`defineConfig`**; links **common** for `buildFallbackConfig` keys | +| [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) | Standalone components, map generation, placeholders | +| [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) | Express order: `json()` → loader middleware → static → SSR | +| [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) | Root **`sitecore.config.ts`**, **`getClient()`** / **`SitecoreClient`**; links **common** for config types + GraphQL | +| [doc-field-directives.md](doc-field-directives.md) | `scText`, `scRichText`, `scImage`, `scLink`, `scRouterLink` | +| [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | `SitecoreContextService`, `isEditing`, content `editing` re-exports | +| [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | Multisite: PDF TBA vs `resolveSitecorePage` options + JSDoc “future” | +| [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | Personalization: PDF TBA vs client/config reality | + +## Sources + +| Source | Location | +|--------|----------| +| JSS-Angular Live Design PDF (ingest 2026-05-14) | **`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`** | +| Text extract (same document) | `llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md` | + +## Code anchors + +- `packages/angular/src/` — package implementation +- `packages/create-content-sdk-app/src/templates/angular/` — generated app template +- `packages/angular/src/config/define-config.ts` — Angular `defineConfig` wrapper +- `packages/angular/src/server/loader-data-service-middleware.ts` — loader RPC middleware + +## Shared with Next.js (common wiki) + +These topics are identical for Angular and Next at the **`@sitecore-content-sdk/content`** layer: + +- [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, merge pipeline +- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys; Angular **`CSDK_PUBLIC_*`** vs Next **`NEXT_PUBLIC_*`** +- [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **`createGraphQLClientFactory`**, **`SitecoreClient`**, **`getPage`** + +## See also + +- [Next.js wiki index](../content-sdk-nextjs/index.md) — parallel head patterns +- [Common wiki index](../common/index.md) — shared packages diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md b/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md new file mode 100644 index 0000000000..8c94422048 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md @@ -0,0 +1,31 @@ +# Architecture: Experience Edge and GraphQL + +From official [Architecture overview](https://doc.sitecore.com/sai/en/developers/content-sdk/20/architecture-overview.html), aligned with this repo. + +**Runtime detail (all heads):** [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — how **`sitecore.config.ts`** + **`defineConfig`** drive Edge/local GraphQL and **`SitecoreClient`**. + +## Official doc + +- **Experience Edge** delivers layout and dictionary (and related) data via **GraphQL**. +- Doc may cite **`package.json`** → `config.graphQLEndpointPath`, default **`/sitecore/api/graph/edge`**. + +## Runtime (this repo) + +GraphQL URLs for **`SitecoreClient`** come from **`sitecore.config.ts`** via **`defineConfig`** and **env** — not primarily from `package.json`. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) and [doc-sitecore-config.md](doc-sitecore-config.md). + +## Templates + +**Pages Router** `package.json` may still list `graphQLEndpointPath` for tooling alignment. **App Router** template may omit that block — treat **`sitecore.config` + env** as authoritative. + +## Implementation + +- **`@sitecore-content-sdk/core`** — `GraphQLClient`, factories, retry. +- **`@sitecore-content-sdk/content`** — `SitecoreClient`, layout/dictionary/editing services under `packages/content/src/client`, `packages/content/src/layout`, … + +## Mental model + +Authors compose pages in SitecoreAI. The head consumes **JSON** (GraphQL responses) from Edge or local GraphQL via **`SitecoreClient`**. Fetch wiring is **framework-specific** (Next `getPage` / App Router / middleware; Angular loaders + **`resolveSitecorePage`**); **`@sitecore-content-sdk/react`** renders typed layout data but does not replace **`SitecoreClient`**. + +## Raw + +- `llm-wiki/raw/2026-05-14-architecture-overview.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md b/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md new file mode 100644 index 0000000000..0bc02c85aa --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md @@ -0,0 +1,37 @@ +# Editor integration (metadata, Pages Router) + +Official: [Editor integration using metadata](https://doc.sitecore.com/sai/en/developers/content-sdk/20/editor-integration-using-metadata.html). Raw: `llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md`. + +**Scope:** Next.js **Pages Router** + SitecoreAI **Page builder** — metadata on placeholders, renderings, fields for visual editing. + +## Head routes (template) + +| Role | Path | +|------|------| +| Render | `src/pages/api/editing/render.ts` — **`EditingRenderMiddleware`** | +| Config / metadata | `src/pages/api/editing/config.ts` — **`EditingConfigMiddleware`** | +| FEaaS | `src/pages/api/editing/feaas/render.ts` — **`FEAASRenderMiddleware`** (template extra; `next.config.js` rewrite `/feaas-render` → API) | +| Page | `src/pages/[[...path]].tsx` — preview vs `getPage` — [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | + +## Editing secret + +**`editingSecret`** / **`SITECORE_EDITING_SECRET`** — [doc-sitecore-config.md](doc-sitecore-config.md). Invalid `secret` on render route → **401**. + +## Preview / editing flow (short) + +1. Editor calls **`GET /api/editing/render?...`** (CORS, secret, required params). +2. **`EditingRenderMiddleware`** sets **Next preview data**, CSP, optional preview cookies for **`mode=preview`**, then **server fetch** of the catch-all route with preview cookies + **`x-sitecore-editing-params`** header + **`__content_sdk_preview`**. +3. Catch-all uses **`getPreview`** / **`getDesignLibraryData`** when `context.preview` is set — **`EditingService`** GraphQL with **`sc_editMode` / `sc_previewMode`** headers. + +## CSP / iframes + +Strict **`X-Frame-Options`** or **`frame-ancestors 'self'`** breaks Pages iframe — allow the Pages host. + +## Code + +- `packages/nextjs/src/editing/editing-render-middleware.ts`, `editing-config-middleware.ts`, `utils.ts` + +## Related + +- [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) +- [doc-terminology-platform-names.md](doc-terminology-platform-names.md) diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md b/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md new file mode 100644 index 0000000000..450600b8ec --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md @@ -0,0 +1,33 @@ +# Example environment variable files + +From [Example environment variable files](https://doc.sitecore.com/sai/en/developers/content-sdk/20/example-environment-variable-files.html). Raw: `llm-wiki/raw/2026-05-14-example-environment-variable-files.md`. + +## Product intent + +- **`.env.*.example`** files document required variables for **container** vs **remote** SitecoreAI dev. +- Copy into **`.env.local`** for real values; never commit secrets into **`.example`** files. + +## Where they live in this monorepo + +**Pages Router** template: `packages/create-content-sdk-app/src/templates/nextjs/` + +| Template file | When to use | +|---------------|-------------| +| `.env.container.example` | Local GraphQL against **Docker / local** Sitecore — `NEXT_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_KEY`, editing + default site/language. | +| `.env.remote.example` | **Experience Edge** / remote — `SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional `NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME`, Personalize timeouts/scope, optional Design Library auth vars. | + +(App Router template ships its own `.env.*.example` under `templates/nextjs-app-router/` — same pattern, different variable set; align with that template when scaffolding.) + +## Relationship to `sitecore.config.ts` + +`defineConfig` / **`buildFallbackConfig`** read the same logical settings from **`process.env`** (and Next’s **`getNextFallbackConfig`** layers **`NEXT_PUBLIC_*`**). Keeping **`.env.local`** and **`sitecore.config.ts`** in sync (especially **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** ↔ **`defaultLanguage`**, site name, Edge IDs) avoids subtle mismatches. See [doc-sitecore-config.md](doc-sitecore-config.md). + +## Angular template (cross-head) + +The Angular scaffold does **not** use **`NEXT_PUBLIC_*`** for the browser bundle the same way. It documents **`CSDK_PUBLIC_*`** in **`.env.example`**, runs **`scripts/generate-environment.ts`**, and emits **`src/environments/environment.*.ts`** so **`defineConfig`** receives literals in the client. Server-only variables stay in **`process.env`** (loaded before `sitecore.config` on the server). Canonical table of **`buildFallbackConfig`** keys (shared with Next): [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md). + +## Related + +- [doc-sitecore-config.md](doc-sitecore-config.md) +- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) +- [doc-i18n-multilingual.md](doc-i18n-multilingual.md) — `NEXT_PUBLIC_DEFAULT_LANGUAGE` and Next `i18n.defaultLocale` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md b/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md new file mode 100644 index 0000000000..180c91abf5 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md @@ -0,0 +1,15 @@ +# GraphQL client factory and Edge URLs (Next hub) + +Canonical **code-truth** for **`createGraphQLClientFactory`**, Edge vs local URLs, and **`SitecoreClient`** construction lives in the **common** wiki (same behavior for Angular and any **`@sitecore-content-sdk/content`** consumer): + +- **[../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md)** + +## Next-only addition + +**Dev proxy:** `packages/nextjs/src/proxy/proxy.ts` — used in Next workflows alongside the shared factory. + +## Related + +- [doc-sitecore-config.md](doc-sitecore-config.md) — Next **`defineConfig`** pipeline +- [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) — official doc alignment +- [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) — **`SitecoreNextjsClient`** extensions diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md b/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md new file mode 100644 index 0000000000..9552d6a883 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md @@ -0,0 +1,124 @@ +# Multilingual / i18n (Next.js) + +Official references: + +- [Supporting multilingual applications in Content SDK](https://doc.sitecore.com/sai/en/developers/content-sdk/20/supporting-multilingual-applications-in-content-sdk.html) — `llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md` +- [Internationalization using next-intl](https://doc.sitecore.com/sai/en/developers/content-sdk/20/internationalization-using-next-intl.html) — **`App Router` + `next-intl`** — `llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md` + +This wiki page contrasts **App Router (doc)** with **Pages Router (template code)** in this repo. + +--- + +## App Router template (official — `next-intl`) + +The SAI doc describes the **Next.js App Router** starter: + +- **`src/i18n/routing.ts`** — `defineRouting({ locales, defaultLocale, localePrefix })`; **`defaultLocale`** typically tied to **`sitecore.config`** `defaultLanguage`. +- **`src/i18n/request.ts`** — per-request locale + **Sitecore dictionary** for server components. +- Route shape **`[site]/[locale]/[[...path]]`**, **`localeMiddleware`** first in **`middleware.ts`**, **`generateStaticParams`** for SSG site×locale. +- Components: **`getTranslations` / `getLocale`** (async server), **`useTranslations` / `useLocale`** (server/client), **`NextIntlClientProvider`** for client subtree. + +Details and examples: see the **raw snapshot** above; product examples may contain typos (“Dafault”) — treat code in **`packages/create-content-sdk-app/src/templates/nextjs-app-router/`** as source of truth for App Router. + +--- + +## Pages Router template — code path (no `next-intl`) + +**Template:** `packages/create-content-sdk-app/src/templates/nextjs/`. + +### 1. Next.js `i18n` config (`next.config.js`) + +Next’s **built-in i18n routing** (not `next-intl`): + +```16:23:packages/create-content-sdk-app/src/templates/nextjs/next.config.js + i18n: { + // These are all the locales you want to support in your application. + // These should generally match (or at least be a subset of) those in Sitecore. + locales: ['en'], + // This is the locale that will be used when visiting a non-locale + // prefixed path e.g. `/about`. + defaultLocale: process.env.DEFAULT_LANGUAGE || process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || 'en', + }, +``` + +- Extend **`locales`** to match Sitecore languages you publish. +- **`defaultLocale`** reads **`DEFAULT_LANGUAGE`** or **`NEXT_PUBLIC_DEFAULT_LANGUAGE`**, else **`en`** — keep aligned with **`sitecore.config.ts`** **`defaultLanguage`** / **`defineConfig`**. + +### 2. Data fetching: locale on `SitecoreClient` (`[[...path]].tsx`) + +Catch-all **`getStaticProps` / `getServerSideProps`**: + +- **`extractPath(context)`** (`@sitecore-content-sdk/nextjs/utils`) returns the **Sitecore item path** from **`context.params.path`** only — it does **not** parse locale out of the path; locale comes from **`context.locale`** provided by Next when i18n is enabled. + +```57:63:packages/nextjs/src/utils/utils.ts +export const extractPath = (context: GetStaticPropsContext | GetServerSidePropsContext) => { + return context.params === undefined + ? '/' + : Array.isArray(context.params.path) + ? context.params.path.join('/') + : context.params.path ?? '/'; +}; +``` + +- **`getPage(path, { locale: context.locale })`** — layout GraphQL uses the active locale. +- **`getDictionary({ site: page.siteName, locale: page.locale })`** — dictionary phrases for that site/language pair after the page resolves. + +```86:104:packages/create-content-sdk-app/src/templates/nextjs/src/pages/[[...path]].tsx + const path = extractPath(context); + let page; + // ... + : await client.getPage(path, { locale: context.locale }); + // ... + dictionary: await client.getDictionary({ + site: page.siteName, + locale: page.locale, + }), +``` + +- **SSG `getStaticPaths`**: passes **`context?.locales || []`** into **`client.getPagePaths`** so static paths can be generated per Next locale when configured. + +### 3. Client dictionary provider (`_app.tsx`) + +**`next-localization`** wraps the tree with **`I18nProvider`** (rosetta-backed), not `next-intl`: + +```13:24:packages/create-content-sdk-app/src/templates/nextjs/src/pages/_app.tsx + <I18nProvider + lngDict={dictionary} + locale={pageProps.page?.locale || scConfig.defaultLanguage} + > + <Component {...rest} /> + </I18nProvider> +``` + +- **`dictionary`** comes from **`getStaticProps` / `getServerSideProps`** props (Sitecore dictionary service). +- **`locale`** falls back to **`scConfig.defaultLanguage`** if the page object has no locale. + +### 4. Error pages + +**`404.tsx` / `500.tsx`** use **`context.locale`**, **`context.defaultLocale`**, then **`scConfig.defaultLanguage`** for `getPage` / dictionary — same alignment pattern as the catch-all. + +### 5. Sitecore config / redirects + +- **`defaultLanguage`** / **`defaultSite`** in **`sitecore.config.ts`** should match how Next resolves locale and how **`getDictionary`** is called. +- **`redirects.locales`** in Sitecore config should stay consistent with **`next.config.js`** **`locales`** for redirect middleware (see [doc-sitecore-config.md](doc-sitecore-config.md)). + +--- + +## Sitecore side (both templates) + +- Layout GraphQL respects **language**. +- Dictionary is fetched via **`SitecoreClient.getDictionary`** (GraphQL-backed in **`@sitecore-content-sdk/content`**). + +--- + +## Env files + +See [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** is documented in **`.env.*.example`**. + +--- + +## Related + +- [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) +- [doc-sitecore-config.md](doc-sitecore-config.md) +- Skill (template): `packages/create-content-sdk-app/src/templates/nextjs/.agents/skills/content-sdk-dictionary-and-i18n/SKILL.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md b/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md new file mode 100644 index 0000000000..990bdc1990 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md @@ -0,0 +1,30 @@ +# Page composition and placeholders + +From [Page composition in Content SDK apps using SitecoreAI data](https://doc.sitecore.com/sai/en/developers/content-sdk/20/page-composition-in-content-sdk-apps-using-sitecoreai-data.html) plus templates in `packages/create-content-sdk-app/src/templates/nextjs*`. + +## Authoring vs runtime + +1. **SitecoreAI** — authors compose pages in WYSIWYG; **placeholders** nest **renderings** (components). +2. **App** — root **`Layout`** with a **root placeholder** whose name matches SitecoreAI. +3. **Runtime** — layout arrives as **JSON** from **GraphQL** (Edge or local) via **`SitecoreClient`** / layout service. + +## Developer constraints + +- Placeholder keys must match authoring. +- Rendering names map to **registered** front-end components (`.sitecore/component-map.ts`). +- **Dynamic placeholders** — supported per product doc; keep names in sync. + +## Code anchors + +- `packages/react` — `Placeholder`, field components. +- `packages/nextjs` — editing, `getComponentData`, App Router helpers. +- Templates — `Layout.tsx`, `[[...path]].tsx` / App Router `[[...path]]/page.tsx`. + +## Related + +- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) +- [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) + +## Raw + +- `llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md b/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md new file mode 100644 index 0000000000..9e32f4c527 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md @@ -0,0 +1,26 @@ +# Plugins and adapters + +Official: [Plugins](https://doc.sitecore.com/sai/en/developers/content-sdk/20/plugins.html) · [Adapters](https://doc.sitecore.com/sai/en/developers/content-sdk/20/adapters.html). Raw: `llm-wiki/raw/2026-05-14-plugins.md`, `2026-05-14-adapters.md`. + +## Plugins + +- Declarative, typed extensions with **`name`**, **`options`**, **`dependencies`**, **`init`**, optional **`adapter`**. +- Typical entry: **`initContentSdk`** (see templates / `packages/nextjs` init patterns). + +## Built-in stack (per doc) + +| Plugin | Role | Package | +|--------|------|---------| +| `analyticsPlugin` | Client ID + shared analytics init; base for events/personalize | `@sitecore-content-sdk/analytics-core` | +| `eventsPlugin` | Page view / custom events | `@sitecore-content-sdk/events` | +| `personalizeBrowserPlugin` / `personalizeServerPlugin` | Personalization | `@sitecore-content-sdk/personalize` | + +Further reading: [Initializing tracking, events, and personalization](https://doc.sitecore.com/sai/en/developers/content-sdk/20/initializing-tracking,-events,-and-personalization-in-the-content-sdk.html). + +## Adapters + +Environment-specific implementations for plugins (browser vs server: cookies, headers, location). Analytics adapters extend **`PluginAdapter`** / **`AnalyticsAdapter`** from **`@sitecore-content-sdk/core`**. + +## Related + +- [doc-sitecore-config.md](doc-sitecore-config.md) — personalize block diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md b/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md new file mode 100644 index 0000000000..9a222d14b7 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md @@ -0,0 +1,26 @@ +# Route handling and data fetching + +Official: [Route handling and data fetching](https://doc.sitecore.com/sai/en/developers/content-sdk/20/route-handling-and-data-fetching-in-content-sdk-apps.html). Raw: `llm-wiki/raw/2026-05-14-route-handling-data-fetching.md`. + +## Model + +1. **Content tree → URLs** — hierarchy drives URLs; multisite hostnames; Sitecore URL rules need front-end coordination. +2. **Route resolution** — Next catch-all / App Router dynamic segments; path → Sitecore route. +3. **Data fetch** — **`SitecoreClient`** + GraphQL ([../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md)); config from **`sitecore.config`** / **`defineConfig`**. +4. **Rendering** — JSON → **Placeholders** / components; **`getComponentData`** for props. + +### Documentation note (LayoutService path) + +Official doc may link **`packages/core/.../layout-service.ts`**. In **this repo**, layout GraphQL is **`packages/content/src/layout/layout-service.ts`**, consumed by **`SitecoreClient`** (`packages/content/src/client/sitecore-client.ts`). + +## Templates (Next) + +- **Pages Router:** `src/pages/[[...path]].tsx` — `extractPath`, `context.preview` → `getPreview` / `getDesignLibraryData` / `getPage`, `getDictionary`, `getComponentData`. +- **App Router:** `src/app/[site]/[locale]/[[...path]]/page.tsx` — **`draftMode()`**, **`getPreviewData(headers)`**, same client branches. + +## Related + +- [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) +- [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) +- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) +- [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) — Next dev proxy pointer diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md new file mode 100644 index 0000000000..3dfd4b2c9b --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md @@ -0,0 +1,13 @@ +# SitecoreClient — services and APIs + +Official hub: [Content SDK Services and APIs](https://doc.sitecore.com/sai/en/developers/content-sdk/20/content-sdk-services-and-apis.html). + +**Shared (all heads):** [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **`SitecoreClient`** role, construction, **`createGraphQLClientFactory`**, **`BaseSitecoreClient`** method table, GraphQL branching. + +## Next: `SitecoreNextjsClient` + +`packages/nextjs/src/client/sitecore-nextjs-client.ts` — **`parsePath`** (site + personalization rewrites), **`getPage`** (site from path, personalization), **`getComponentData`**, **`getAppRouterStaticParams`**, **`getPreviewData(headers)`** (App Router; reads **`x-sitecore-editing-params`**). + +## Raw + +- `llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md new file mode 100644 index 0000000000..7fa459051e --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md @@ -0,0 +1,50 @@ +# Sitecore configuration (`sitecore.config.ts`) + +Synthesized from official [The Sitecore configuration file](https://doc.sitecore.com/sai/en/developers/content-sdk/20/the-sitecore-configuration-file.html) (tables in `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md`) and **`SitecoreConfigInput`** in `packages/content/src/config/models.ts`. + +**Shared reference (all heads):** [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — full **`SitecoreConfigInput`** tables, merge pipeline, **`api.edge` / `api.local`**, CLI config. [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys. [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — GraphQL URL selection and **`SitecoreClient`**. + +## Where it lives + +- Generated apps: root **`sitecore.config.ts`**. +- Templates: `packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts`, `nextjs-app-router/sitecore.config.ts`, Angular template equivalents. + +## Next.js resolution pipeline + +1. **`defineConfig`** from **`@sitecore-content-sdk/nextjs/config`** (`packages/nextjs/src/config/define-config.ts`) runs **`getNextFallbackConfig`**: merges **`NEXT_PUBLIC_*`**, **`VERCEL_ENV === 'preview'`** for multisite cookie resolution, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`**, etc. +2. Passes result to **`defineConfig`** from **`@sitecore-content-sdk/content/config`** (`packages/content/src/config/define-config.ts`). +3. Content **`defineConfig(config, env?)`**: **`buildFallbackConfig(env)`** → **`resolveConfig`** (**`deepMerge`**, skips `undefined` and **`''`** overrides) → **`resolveEdgeUrl`** on merged `api.edge.edgeUrl`. (Details: [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md).) +4. **CLI mode** (`SITECORE_CLI_MODE=true`): lazy validation **Proxy** on sensitive paths; else immediate **`validateApiConfiguration`** (server needs Edge **`contextId`** or local **`apiHost`+`apiKey`**). + +## Next-only `SitecoreConfigInput` fields + +(`packages/nextjs/src/config/define-config.ts`) + +| Key | Type | Purpose | +|-----|------|---------| +| `generateStaticPaths` | `boolean?` | SSG path prebuild; env **`GENERATE_STATIC_PATHS`** overrides; default **true** if unset. | +| `sitecoreInternalEditingHostUrl` | `string?` | Base URL for editing middleware internal fetch; env **`SITECORE_INTERNAL_EDITING_HOST_URL`**. | + +## Multisite (Next App Router) + +When using the **`[site]`** segment pattern, keep **`multisite.enabled`** consistent with routing expectations — disabling it can break site resolution for that layout. Cookie resolution defaults may change under preview (`VERCEL_ENV`); see **`getNextFallbackConfig`** in `packages/nextjs/src/config/define-config.ts`. + +## Code as source of truth + +| Need | Path | +|------|------| +| Types | `packages/content/src/config/models.ts` | +| Fallback + merge | `packages/content/src/config/define-config.ts` | +| Next wrapper | `packages/nextjs/src/config/define-config.ts` | +| GraphQL URL / `SitecoreClient` | [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | + +## Related + +- [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — `.env.*.example` vs `defineConfig` / env. +- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) — `editingSecret`, render host. +- [doc-terminology-platform-names.md](doc-terminology-platform-names.md) +- [../content-sdk-angular/doc-environment-and-define-config-angular.md](../content-sdk-angular/doc-environment-and-define-config-angular.md) — Angular **`CSDK_PUBLIC_*`** → `environment*.ts` + +## Raw + +- `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md b/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md new file mode 100644 index 0000000000..9980a0ea38 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md @@ -0,0 +1,13 @@ +# Platform naming (SAI / XM Cloud / XMC) + +In official docs, URLs, and template comments you will see **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC**. For Content SDK work in **this monorepo**, treat them as the **same hosted platform context** unless code explicitly branches on a label. + +## Practical guidance + +- Do not treat mixed labels as conflicting products when reading issues, PRs, or wiki notes. +- Template `sitecore.config` comments may use **XMC**-style URLs while SAI doc URLs use `/sai/` — same product family for this wiki. + +## See also + +- [overview-content-sdk.md](overview-content-sdk.md) +- [doc-sitecore-config.md](doc-sitecore-config.md) — example of mixed URL prefixes in comments vs docs diff --git a/llm-wiki/wiki/content-sdk-nextjs/index.md b/llm-wiki/wiki/content-sdk-nextjs/index.md new file mode 100644 index 0000000000..22a964b176 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/index.md @@ -0,0 +1,49 @@ +# Content SDK — Next.js wiki index + +Catalog of **Next.js head** pages (`@sitecore-content-sdk/nextjs`, Pages/App Router templates, editing). **Update this file** when you add, rename, or materially change pages here. + +## Meta + +| Page | Summary | +|------|---------| +| [../index.md](../index.md) | Wiki root hub (all stacks) | +| [../log.md](../log.md) | Append-only timeline (repo-wide) | +| [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | Bibliography for 2026-05-14 official doc batch + follow-up ingests | + +## Overview + +| Page | Summary | +|------|---------| +| [overview-content-sdk.md](overview-content-sdk.md) | Monorepo purpose, package map, doc topic map, head-app vs SDK scope | +| [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | **SAI / Sitecore AI / XMC / XM Cloud** — interchangeable names in docs and comments | + +## Official docs (SAI 2.x) — synthesized + +| Page | Summary | +|------|---------| +| [doc-sitecore-config.md](doc-sitecore-config.md) | `sitecore.config.ts` + Next **`defineConfig`** / **`getNextFallbackConfig`**; links to **common** for full **`SitecoreConfigInput`**, env keys, GraphQL + `SitecoreClient` | +| [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) | Experience Edge, GraphQL; runtime vs `package.json` doc note; points to **common** for implementation | +| [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) | Next hub → **common** canonical factory doc; Next dev proxy pointer | +| [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | Authoring vs GraphQL JSON; Layout + component map | +| [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | Catch-all, `getPage` / preview / `getComponentData`; LayoutService path under `packages/content` | +| [doc-i18n-multilingual.md](doc-i18n-multilingual.md) | i18n: App Router `next-intl` (raw) + **Pages Router** code (`next.config` i18n, `extractPath`, `getDictionary`, `next-localization`) | +| [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | `.env.container` / `.env.remote` examples, template paths, Angular **`CSDK_PUBLIC_*`** cross-link, link to `sitecore.config` | +| [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) | **Next** `SitecoreNextjsClient` extensions; **common** for base `SitecoreClient` + GraphQL | +| [doc-plugins-and-adapters.md](doc-plugins-and-adapters.md) | Plugins, adapters, analytics / personalize stack | +| [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | Page builder, editing API routes, preview, FEaaS, CSP | + +## Concepts & flows + +| Page | Summary | +|------|---------| +| [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | HTTP / GraphQL endpoint selection (all heads) | + +## Source notes + +| Page | Summary | +|------|---------| +| [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | URLs → `raw/` + these wiki pages | + +--- + +**Convention:** Relative links within this folder. Shared package topics: [../common/index.md](../common/index.md). diff --git a/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md b/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md new file mode 100644 index 0000000000..61ff0557e0 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md @@ -0,0 +1,52 @@ +# Overview: Sitecore Content SDK monorepo + +> When this diverges from code, **update this page** to match code and note drift in `../log.md`. + +## Platform naming (read this first) + +**Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC** (in URLs and comments) refer to the **same** platform context for Content SDK work in this repo. See [doc-terminology-platform-names.md](doc-terminology-platform-names.md). + +## Purpose + +This repository ships **TypeScript packages**, a **scaffolding CLI** (`create-content-sdk-app`), and **templates** for building applications on **SitecoreAI / XM Cloud**. Consumer applications are generated from templates and depend on `@sitecore-content-sdk/*`. + +## Doc topic map (ingested 2026-05-14) + +| Topic | Wiki | +|--------|------| +| Naming | [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | +| Config types (all heads) | [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) | +| Env fallbacks / `buildFallbackConfig` (all heads) | [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) | +| `SitecoreClient` + GraphQL factory (all heads) | [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | +| Config / env (Next) | [doc-sitecore-config.md](doc-sitecore-config.md) | +| Edge + GraphQL | [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) | +| GraphQL client factory (Next hub) | [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) | +| Layout / placeholders | [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | +| Next data fetching | [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | +| i18n + dictionary | [doc-i18n-multilingual.md](doc-i18n-multilingual.md) | +| Example `.env` files | [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | +| SitecoreClient APIs | [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) | +| Plugins + adapters | [doc-plugins-and-adapters.md](doc-plugins-and-adapters.md) | +| Page builder / editing | [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | +| Ingest bibliography | [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | + +## Package map (high level) + +| Package | Responsibility | +|---------|----------------| +| `core` | GraphQL client, cache, retry, fetch | +| `analytics-core` | Analytics foundation | +| `content` | Layout, editing, site, media, `SitecoreClient` | +| `search` | Search APIs | +| `events` | Event tracking | +| `personalize` | Personalization | +| `cli` | `sitecore-tools` | +| `create-content-sdk-app` | Scaffolding + templates | +| `nextjs` | Next integration, middleware, editing | +| `react` | Text, Image, Placeholder, … | + +## Key repo locations + +- Sources: `packages/<name>/src/**` +- Templates: `packages/create-content-sdk-app/src/templates/**` +- LLM raw snapshots: `llm-wiki/raw/` diff --git a/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md b/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md new file mode 100644 index 0000000000..e85ebb3cb3 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md @@ -0,0 +1,22 @@ +# Official doc ingest — 2026-05-14 + +**Initial batch:** nine SitecoreAI Content SDK **2.x** URLs. **Follow-up:** editor integration (metadata); example env files; next-intl (App Router) + i18n wiki merge. + +| # | Topic | Raw snapshot | Wiki synthesis | +|---|--------|----------------|-----------------| +| 1 | Content SDK for SitecoreAI | `raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md` | `content-sdk-nextjs/overview-content-sdk.md` | +| 2 | Sitecore configuration file | `raw/2026-05-14-the-sitecore-configuration-file.md` | `content-sdk-nextjs/doc-sitecore-config.md` | +| 3 | Architecture overview | `raw/2026-05-14-architecture-overview.md` | `content-sdk-nextjs/doc-architecture-edge-graphql.md` | +| 4 | Page composition | `raw/2026-05-14-page-composition-sitecoreai-data.md` | `content-sdk-nextjs/doc-page-composition-placeholders.md` | +| 5 | Route handling & data fetching | `raw/2026-05-14-route-handling-data-fetching.md` | `content-sdk-nextjs/doc-route-handling-data-fetching.md` | +| 6 | Multilingual | `raw/2026-05-14-supporting-multilingual-applications.md` | `content-sdk-nextjs/doc-i18n-multilingual.md` | +| 7 | Services and APIs | `raw/2026-05-14-content-sdk-services-and-apis.md` | `content-sdk-nextjs/doc-sitecore-client-apis.md` | +| 8 | Plugins | `raw/2026-05-14-plugins.md` | `content-sdk-nextjs/doc-plugins-and-adapters.md` | +| 9 | Adapters | `raw/2026-05-14-adapters.md` | `content-sdk-nextjs/doc-plugins-and-adapters.md` | +| 10 | Editor integration using metadata | `raw/2026-05-14-editor-integration-using-metadata.md` | `content-sdk-nextjs/doc-editor-integration-metadata.md` | +| 11 | Example environment variable files | `raw/2026-05-14-example-environment-variable-files.md` | `content-sdk-nextjs/doc-example-environment-variable-files.md` | +| 12 | Internationalization using next-intl | `raw/2026-05-14-internationalization-using-next-intl.md` | `content-sdk-nextjs/doc-i18n-multilingual.md` (merged with Pages Router code) | + +**Code-truth supplements:** `doc-sitecore-config.md` (Next `defineConfig` pipeline; full **`SitecoreConfigInput`** / env / GraphQL in **`../common/`**), `doc-graphql-client-and-edge-urls.md` (Next hub → **`../common/doc-sitecore-client-and-graphql.md`**), `doc-sitecore-client-apis.md` (Next `SitecoreNextjsClient`; base client in **common**), architecture + editor pages (template vs doc deltas). + +**Catalog:** [content-sdk-nextjs/index.md](index.md) diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md new file mode 100644 index 0000000000..8d4e8353ab --- /dev/null +++ b/llm-wiki/wiki/index.md @@ -0,0 +1,18 @@ +# LLM Wiki hub + +Agent-maintained markdown under `llm-wiki/wiki/`, split by **head stack** and **shared** concepts. + +| Folder | Scope | Start here | +|--------|--------|------------| +| **[content-sdk-nextjs/](content-sdk-nextjs/index.md)** | **Content SDK Next.js** — `@sitecore-content-sdk/nextjs`, templates, editing, routing, i18n, doc synthesis | [content-sdk-nextjs/index.md](content-sdk-nextjs/index.md) | +| **[common/](common/index.md)** | **Shared** — `SitecoreConfigInput`, env / `buildFallbackConfig`, `SitecoreClient` + GraphQL factory (`packages/content`) | [common/index.md](common/index.md) | +| **[content-sdk-angular/](content-sdk-angular/index.md)** | **Content SDK Angular** — `@sitecore-content-sdk/angular`, Angular template, loaders/SSR architecture (ingested design doc) | [content-sdk-angular/index.md](content-sdk-angular/index.md) | + +## Repo-wide meta + +| | | +|--|--| +| [log.md](log.md) | Append-only ingest / query / lint log for **all** wiki areas | +| [AGENTS.md](../AGENTS.md) | LLM Wiki schema, workflows, truth hierarchy | + +**Agents:** For Next-specific answers, open **`content-sdk-nextjs/index.md`** then the linked page. For **`sitecore.config`**, env fallbacks, or **`SitecoreClient`** / GraphQL behavior shared by all heads, start with **`common/index.md`**. For Angular integration (loaders, SSR), use **`content-sdk-angular/`**. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md new file mode 100644 index 0000000000..b23177aace --- /dev/null +++ b/llm-wiki/wiki/log.md @@ -0,0 +1,67 @@ +# Wiki log (append-only) + +Chronological record of ingests, major queries, and lint passes. New entries at the **top** (after this paragraph) or bottom — pick one convention and keep it; default here is **newest first** after the title block. + +Prefix suggestion for parseability: `## [YYYY-MM-DD] ingest | <short title>` / `query |` / `lint |` + +--- + +## [2026-05-14] wiki | Common wiki — config, env, SitecoreClient + GraphQL + +Extracted framework-agnostic material from **Next.js** wiki into **`common/doc-sitecore-config-input.md`**, **`common/doc-config-environment-variables.md`**, **`common/doc-sitecore-client-and-graphql.md`**. Trimmed **`content-sdk-nextjs/doc-sitecore-config.md`**, **`doc-graphql-client-and-edge-urls.md`**, **`doc-sitecore-client-apis.md`** to Next-specific deltas; pointed **`doc-architecture-edge-graphql`**, **`doc-route-handling-data-fetching`**, **`overview-content-sdk`**, root **`index.md`** at **common**. Expanded **Angular** env + `sitecore.config` pages and **`doc-example-environment-variable-files`** (Angular **`CSDK_PUBLIC_*`**). + +## [2026-05-14] wiki | Angular design PDF — split wiki + binary in repo + +Copied **`JSS-Angular-Live-Design-Doc-140526-211917.pdf`** to **`llm-wiki/raw/design/`**. Split architecture into subsection pages under **`content-sdk-angular/`**; **`doc-architecture-loaders-and-ssr.md`** is now an index hub. Raw extract frontmatter **`pdf_in_repo`** updated. **`content-sdk-angular/index.md`** and **`log.md`** updated. + +## [2026-05-14] ingest | JSS-Angular Live Design PDF (architecture) + +Extracted *JSS-Angular Live Design Doc-140526-211917.pdf* → `raw/2026-05-14-jss-angular-live-design-architecture.md`. Added `content-sdk-angular/doc-architecture-loaders-and-ssr.md` and updated `content-sdk-angular/index.md` (catalog + sources). + +## [2026-05-14] ingest + wiki | Example env files + next-intl / Pages Router i18n + +Ingested `example-environment-variable-files.html` and `internationalization-using-next-intl.html` → `raw/2026-05-14-example-environment-variable-files.md`, `raw/2026-05-14-internationalization-using-next-intl.md`. Added `content-sdk-nextjs/doc-example-environment-variable-files.md`. Expanded `doc-i18n-multilingual.md` with App Router (`next-intl`) summary from raw + **Pages Router** code: `next.config.js` i18n, `extractPath`, `[[...path]].tsx` locale/dictionary, `_app.tsx` `next-localization`, error pages. Updated `index.md`, `overview-content-sdk.md`, `source-ingest`, `doc-sitecore-config` cross-link. + +## [2026-05-14] wiki | Restore `content-sdk-nextjs/` (wiki not in git) + +Previous **`llm-wiki/wiki/**`** markdown was **lost** (untracked + accidental delete). Recreated **13 pages** under **`wiki/content-sdk-nextjs/`** from `llm-wiki/raw/*` + monorepo source (config, GraphQL factory, `SitecoreClient`, route/editor topics). Hub **`wiki/index.md`** now points to **`content-sdk-nextjs/`** (removed duplicate **`nextjs/`** folder). Updated **`AGENTS.md`**, **`llm-wiki/README.md`**, raw wiki-alignment paths, **`common/`** and **`content-sdk-angular/`** cross-links. + +## [2026-05-14] wiki | doc-sitecore-config — full TypeScript reference + +Documented every **`SitecoreConfigInput`** key from `packages/content/src/config/models.ts` (types + purpose): `api.*`, `retries`, `layout`, `dictionary`, `multisite`, `personalize`, `redirects`, **`rewriteMediaUrls`**, **`disableCodeGeneration`**. Added Next-only **`generateStaticPaths`** / **`sitecoreInternalEditingHostUrl`**, and **`SitecoreCliConfigInput`** + **`GenerateMapArgs`** / **`ScaffoldTemplate`**. Clarified multisite **`useCookieResolution`** default via `getNextFallbackConfig`. Index summary updated. + +## [2026-05-14] wiki | Code-truth: config pipeline, GraphQL factory, SitecoreClient wiring + +Added **`doc-graphql-client-and-edge-urls.md`** (`createGraphQLClientFactory`, Edge URL path, server/browser rules). Expanded **`doc-sitecore-config.md`** (Next `getNextFallbackConfig` → content `defineConfig`, `buildFallbackConfig` env table, `deepMerge` / CLI validation). Expanded **`doc-sitecore-client-apis.md`** (constructor services, `LayoutService` path under `packages/content`, `SitecoreNextjsClient` overrides). Linked architecture wiki; FEaaS row in editor wiki; **`index.md`**, **`overview-content-sdk.md`**, **`source-ingest-2026-05-14-official-docs.md`** updated. + +## [2026-05-14] wiki | layout data = GraphQL JSON (no CMS XML framing) + +Removed incorrect “layout stored as XML / head avoids XML” wording from `doc-architecture-edge-graphql.md`, `doc-page-composition-placeholders.md`, and `index.md`; aligned `raw/2026-05-14-page-composition-sitecoreai-data.md`. Route-handling wiki bullet rephrased URL rules without implying layout XML. `doc-sitecore-client-apis` “Sitemap XML” kept (sitemap format). + +## [2026-05-14] wiki | doc-architecture-edge-graphql corrections + +Clarified runtime GraphQL endpoint resolution via `sitecore.config` + env (cross-ref `doc-sitecore-config`, `doc-sitecore-client-apis`); separated Next.js head fetch path from `@sitecore-content-sdk/react`; noted layout service runs through `SitecoreClient`. `package.json` `graphQLEndpointPath` framed as Pages template / doc artifact. Raw `2026-05-14-architecture-overview.md` annotated. Index summary updated. + +## [2026-05-14] ingest | The Sitecore configuration file (full) + +Re-ingested official topic; replaced `raw/2026-05-14-the-sitecore-configuration-file.md` with full markdown tables. Expanded `wiki/doc-sitecore-config.md` with base/api/services/middleware summaries and code-truth pointers. + +## [2026-05-14] ingest | Editor integration using metadata (SAI doc) + +Added `raw/2026-05-14-editor-integration-using-metadata.md` (HTML via curl → distilled markdown) and `wiki/doc-editor-integration-metadata.md`. Updated `index.md`, `overview-content-sdk.md` topic map, `source-ingest-2026-05-14-official-docs.md`; cross-link from `doc-route-handling-data-fetching.md`. + +## [2026-05-14] ingest | Re-fetch plugins + route-handling (raw) + +Automated fetch succeeded for `plugins.html` and `route-handling-and-data-fetching-in-content-sdk-apps.html`. Replaced `raw/2026-05-14-plugins.md` and `raw/2026-05-14-route-handling-data-fetching.md` stubs with snapshots. Updated `doc-plugins-and-adapters.md`, `doc-route-handling-data-fetching.md`, `source-ingest-2026-05-14-official-docs.md`. Noted official doc’s incorrect `LayoutService` GitHub path vs `packages/content/src/layout/`. + +## [2026-05-14] wiki | Platform terminology (SAI / XMC / XM Cloud) + +Added `doc-terminology-platform-names.md`; linked from `overview-content-sdk`, `index`, `doc-sitecore-config`, and **AGENTS.md** LLM Wiki conventions. Clarifies doc URLs and comments that mix **Sitecore AI**, **SAI**, **XMC**, and **XM Cloud** are equivalent naming for this wiki’s scope. + +## [2026-05-14] ingest | Official SAI Content SDK 2.x docs (9 URLs) + +Fetched 7 pages successfully; **route handling** and **plugins** URLs returned Cloudflare challenge (snapshots are stubs in `raw/`). Wiki pages added: `doc-sitecore-config`, `doc-architecture-edge-graphql`, `doc-page-composition-placeholders`, `doc-route-handling-data-fetching`, `doc-i18n-multilingual`, `doc-sitecore-client-apis`, `doc-plugins-and-adapters`, `source-ingest-2026-05-14-official-docs`; `overview-content-sdk` updated. Raw snapshots under `llm-wiki/raw/2026-05-14-*.md`. + +## [2026-05-14] init | LLM Wiki scaffold + +Initial `llm-wiki/` layout and AGENTS.md schema section added. No sources ingested yet. From 78365a7fe7be53295cbaab4fac58f701c28895b8 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Fri, 15 May 2026 11:59:54 -0400 Subject: [PATCH 02/14] llm wiki proofread and refine (cherry picked from commit d3f5b4c33c683018882f0c0289551f7b7787a746) --- AGENTS.md | 4 +- llm-wiki/README.md | 5 +- ...05-14-editor-integration-using-metadata.md | 2 +- llm-wiki/wiki/common/doc-component-map.md | 81 +++++++++++++++ .../doc-config-environment-variables.md | 47 ++++++--- .../common/doc-sitecore-client-and-graphql.md | 38 ++++++- .../wiki/common/doc-sitecore-config-input.md | 2 +- .../common/doc-terminology-platform-names.md | 13 +++ llm-wiki/wiki/common/index.md | 5 +- .../doc-architecture-loaders-and-ssr.md | 4 +- .../doc-components-and-placeholder-map.md | 9 +- .../doc-editing-and-page-context-angular.md | 4 +- ...c-environment-and-define-config-angular.md | 6 +- .../content-sdk-angular/doc-i18n-angular.md | 33 +++++++ .../doc-loaders-outside-angular-di.md | 2 +- .../doc-multisite-angular-roadmap.md | 4 +- .../doc-personalization-angular-roadmap.md | 2 +- .../doc-sitecore-config-typescript-angular.md | 13 ++- llm-wiki/wiki/content-sdk-angular/index.md | 10 +- .../doc-architecture-edge-graphql.md | 6 +- .../doc-editor-integration-metadata.md | 50 +++++++++- .../doc-example-environment-variable-files.md | 76 ++++++++++++-- .../doc-graphql-client-and-edge-urls.md | 2 +- .../doc-page-composition-placeholders.md | 98 ++++++++++++++++++- .../doc-plugins-and-adapters.md | 21 ++-- .../content-sdk-nextjs/doc-sitecore-config.md | 12 +-- .../doc-terminology-platform-names.md | 14 +-- llm-wiki/wiki/content-sdk-nextjs/index.md | 8 +- .../overview-content-sdk.md | 4 +- llm-wiki/wiki/index.md | 4 +- llm-wiki/wiki/log.md | 8 ++ llm-wiki/wiki/plans/README.md | 13 +++ 32 files changed, 507 insertions(+), 93 deletions(-) create mode 100644 llm-wiki/wiki/common/doc-component-map.md create mode 100644 llm-wiki/wiki/common/doc-terminology-platform-names.md create mode 100644 llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md create mode 100644 llm-wiki/wiki/plans/README.md diff --git a/AGENTS.md b/AGENTS.md index ebb9951149..2e072ad276 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,7 +179,7 @@ If documentation and code disagree: **document the code’s behavior** in the wi | Path | Who edits | Contents | |------|-----------|----------| | `llm-wiki/raw/` | **Human** adds files; agent **does not** modify | Curated markdown/text copies of docs, MCP exports, articles | -| `llm-wiki/wiki/` | **Agent** creates/updates (per user direction) | Overviews, package notes, flows; **Next.js** pages under `wiki/content-sdk-nextjs/`; shared stubs under `wiki/common/`; Angular under `wiki/content-sdk-angular/` | +| `llm-wiki/wiki/` | **Agent** creates/updates (per user direction) | Overviews, package notes, flows; **Next.js** pages under `wiki/content-sdk-nextjs/`; shared stubs under `wiki/common/`; Angular under `wiki/content-sdk-angular/`; **in-progress plans** under `wiki/plans/` | | `llm-wiki/wiki/index.md` | **Agent** maintains | Root hub linking stack-specific indexes | | `llm-wiki/wiki/content-sdk-nextjs/index.md` | **Agent** maintains | Next.js wiki catalog | | `llm-wiki/wiki/log.md` | **Agent** appends | Chronological ingest / query / lint entries | @@ -211,7 +211,7 @@ Prefer **one source per ingest** when the user wants tight review; batch only wh ### Conventions - Prefer **relative links** between wiki pages; cite code as `` `packages/<pkg>/src/...` ``. -- **Platform naming:** In wiki and comments, **Sitecore AI / SitecoreAI / SAI / XM Cloud / XMC** refer to the **same** platform context unless code explicitly distinguishes behavior. See `llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md`. +- **Platform naming:** In wiki and comments, **Sitecore AI / SitecoreAI / SAI / XM Cloud / Sitecore XM Cloud / XMC** refer to the **same** platform context unless code explicitly distinguishes behavior. See `llm-wiki/wiki/common/doc-terminology-platform-names.md`. - Do **not** store secrets in raw or wiki; follow `.cursor/rules/safety.mdc`. - Do **not** edit `dist/**`, `node_modules/`, or generated-only paths as part of wiki maintenance. diff --git a/llm-wiki/README.md b/llm-wiki/README.md index f50e6250ac..f4fb131d46 100644 --- a/llm-wiki/README.md +++ b/llm-wiki/README.md @@ -6,8 +6,9 @@ Persistent markdown knowledge base for **Content SDK monorepo** development, mai |--------|------|------| | **Schema** | [AGENTS.md](../AGENTS.md) (section *LLM Wiki*) | Conventions, workflows, truth hierarchy | | **Wiki** | [`wiki/`](wiki/) | LLM-written synthesis, entities, flows (git-tracked) | +| **Plans (in progress)** | [`wiki/plans/`](wiki/plans/) | In-flight feature and wiki-change plans; not canonical until merged into `wiki/**` | | **Raw sources** | [`raw/`](raw/) | Immutable inputs (clipped docs, exports you add) | -Start with [`wiki/index.md`](wiki/index.md) and [`wiki/log.md`](wiki/log.md). Next.js head docs live under [`wiki/content-sdk-nextjs/`](wiki/content-sdk-nextjs/). Do not edit files under `raw/` except by adding new source material. +Start with [`wiki/index.md`](wiki/index.md) and [`wiki/log.md`](wiki/log.md). Next.js head docs live under [`wiki/content-sdk-nextjs/`](wiki/content-sdk-nextjs/). In-progress plans live under [`wiki/plans/`](wiki/plans/). Do not edit files under `raw/` except by adding new source material. -**Platform naming:** See [`wiki/content-sdk-nextjs/doc-terminology-platform-names.md`](wiki/content-sdk-nextjs/doc-terminology-platform-names.md). +**Platform naming:** See [`wiki/common/doc-terminology-platform-names.md`](wiki/common/doc-terminology-platform-names.md). diff --git a/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md b/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md index aa05ec612d..615b92fd35 100644 --- a/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md +++ b/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md @@ -14,7 +14,7 @@ fetch_note: "HTML retrieved via curl; body distilled to markdown (diagram omitte **Note:** Official diagram: teal = Content SDK for Next.js APIs; other colors = sample app pieces. -**Local Pages testing:** Connect [local host to Pages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/connect-your-local-host-to-pages.html) (doc link uses XMC path; same platform family as SAI — see wiki `content-sdk-nextjs/doc-terminology-platform-names.md`). +**Local Pages testing:** Connect [local host to Pages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/connect-your-local-host-to-pages.html) (doc link uses XMC path; same platform family as SAI — see wiki `wiki/common/doc-terminology-platform-names.md`). **Important — iframes:** `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors 'self'` can **block** the Pages iframe. Allow the Pages domain to frame the editing host (adjust headers / exceptions). diff --git a/llm-wiki/wiki/common/doc-component-map.md b/llm-wiki/wiki/common/doc-component-map.md new file mode 100644 index 0000000000..a9d13f423b --- /dev/null +++ b/llm-wiki/wiki/common/doc-component-map.md @@ -0,0 +1,81 @@ +# Component map (shared contract) + +The component map is a TypeScript `Map` from **rendering name** (PascalCase string) to a **component value**, used by all Content SDK heads. Generated by the CLI from `sitecore.cli.config.ts`; output file is `.sitecore/component-map.ts`. + +**Core generator:** `packages/content/src/tools/templating/utils.ts` — `buildComponentMapContent()` (pure string concatenation; no template engine). The framework component type name is derived as `${Framework}ContentSdkComponent` where `Framework` is the capitalized `framework` option (e.g. `angular` → `AngularContentSdkComponent`, `nextjs` → `NextjsContentSdkComponent`). + +## Map format + +```typescript +// Angular — type from @sitecore-content-sdk/angular +import { AngularContentSdkComponent } from '@sitecore-content-sdk/angular'; +import { ScFormComponent } from '@sitecore-content-sdk/angular'; // built-in +import * as MyComponent from 'src/app/components/my.component'; + +export const componentMap = new Map<string, AngularContentSdkComponent>([ + ['Form', ScFormComponent], // built-in; always present in Angular maps + ['MyComponent', { ...MyComponent }], +]); +export default componentMap; + +// Next.js — type from @sitecore-content-sdk/nextjs (extends ReactContentSdkComponent) +import { NextjsContentSdkComponent } from '@sitecore-content-sdk/nextjs'; +import * as MyComponent from './components/MyComponent'; + +export const componentMap = new Map<string, NextjsContentSdkComponent>([ + ['MyComponent', { ...MyComponent, componentType: 'client' }], +]); +export default componentMap; +``` + +- **Key:** PascalCase rendering name — must match the Sitecore rendering name exactly. +- **Value:** spread of the component module (`{ ...Module }`) or a direct class/type reference. +- **`componentType: 'client'`**: marks a React Client Component (App Router); generated when `clientComponentMap` split is enabled. + +## Component map types + +| Framework | Value type | Package | +|-----------|-----------|---------| +| Angular | `AngularContentSdkComponent` = `Type<unknown> \| AngularModule` | `@sitecore-content-sdk/angular` | +| Next.js | `NextjsContentSdkComponent` extends `ReactContentSdkComponent` with `getComponentServerProps`, `dynamicModule`, `componentType` | `@sitecore-content-sdk/nextjs` | + +## CLI config — `GenerateMapArgs` + +`SitecoreCliConfigInput.componentMap` accepts `GenerateMapArgs` (`packages/content/src/tools/generate-map.ts`): + +| Field | Type | Notes | +|-------|------|-------| +| `paths` | `string[]` | Component source directories to scan | +| `destination` | `string?` | Output folder (default: `src/.sitecore`) | +| `componentImports` | `ComponentImport[]?` | Additional package components to inject into the map | +| `exclude` | `string[]?` | Glob patterns to exclude from scanning | +| `mapTemplate` | `ComponentMapTemplate \| EnhancedComponentMapTemplate \| undefined` | Custom generator for the main map file — replaces the default template entirely | +| `clientMapTemplate` | same | Custom generator for the client-only map (used when `clientComponentMap: true`) | +| `clientComponentMap` | `boolean?` | When `true`, generates a second `.sitecore/component-map.client.ts` containing only `client` + `universal` components (App Router split) | +| `includeVariants` | `boolean?` | Include SXA variant component paths in the map | + +Full CLI config shape: [doc-sitecore-config-input.md](doc-sitecore-config-input.md) — `SitecoreCliConfigInput`. + +## Built-in entries (Angular only) + +The Angular generator (`packages/angular/src/tools/generate-map.ts`) hardcodes two built-in values before calling `buildComponentMapContent`: + +```typescript +const DEFAULT_BUILTIN_IMPORTS = `import { ScFormComponent } from '@sitecore-content-sdk/angular';`; +const DEFAULT_BUILTIN_MAP_ENTRIES = [`['Form', ScFormComponent]`]; +``` + +These are passed as `builtInImports` / `builtInMapEntries` to `buildComponentMapContent` so `ScFormComponent` always appears as `'Form'` regardless of what components the app defines. Apps can provide a custom `mapTemplate` to override this behavior. + +## Head-specific wiring + +### Angular + +- Injected into DI via **`SITECORE_COMPONENT_MAP`** token in `app.config.ts` (`useValue: componentMap`). +- `sc-placeholder` reads the map through this token. +- See [doc-components-and-placeholder-map.md](../content-sdk-angular/doc-components-and-placeholder-map.md). + +### Next.js + +- Passed as a prop to `AppPlaceholder` (App Router) or provided via `SitecoreProvider` (Pages Router). +- See [doc-page-composition-placeholders.md](../content-sdk-nextjs/doc-page-composition-placeholders.md). \ No newline at end of file diff --git a/llm-wiki/wiki/common/doc-config-environment-variables.md b/llm-wiki/wiki/common/doc-config-environment-variables.md index c9ada469d6..360863bca5 100644 --- a/llm-wiki/wiki/common/doc-config-environment-variables.md +++ b/llm-wiki/wiki/common/doc-config-environment-variables.md @@ -1,23 +1,40 @@ # Environment variables and `buildFallbackConfig` -How **`@sitecore-content-sdk/content`** fills **`SitecoreConfig`** from **`process.env`** (and from head-supplied env objects). Same variable *names* support **Next** (often **`NEXT_PUBLIC_*`** or server-only), **Angular** (browser-safe **`CSDK_PUBLIC_*`** literals + server **`process.env`**), and any other consumer of **`defineConfig`**. +How **`@sitecore-content-sdk/content`** fills **`SitecoreConfig`** from an env-like record (e.g. **`process.env`**, or **`clientEnv`** merged in by a head’s **`defineConfig`**). **`buildFallbackConfig`** in **`packages/content/src/config/define-config.ts`** uses **string literal** keys only; TypeScript **constant** names such as **`SITECORE_EDGE_PLATFORM_HOSTNAME_ENV`** are **not** environment variable names—they exist so the code can index **`env['SITECORE_EDGE_PLATFORM_HOSTNAME']`** without repeating the string. **Code:** `packages/content/src/config/define-config.ts` — **`buildFallbackConfig`**. -## `buildFallbackConfig` env keys - -| Area | Variables (chained with `\|\|`) | -|------|----------------------------------| -| Edge hostname | `CSDK_PUBLIC_SITECORE_EDGE_HOSTNAME`, `SITECORE_EDGE_PLATFORM_HOSTNAME_ENV` | -| Edge context | `SITECORE_EDGE_CONTEXT_ID` | -| Client context | `SITECORE_EDGE_CLIENT_CONTEXT_ID`, `CSDK_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | -| Local key | `SITECORE_API_KEY`, `CSDK_PUBLIC_SITECORE_API_KEY`, `NEXT_PUBLIC_SITECORE_API_KEY` | -| Local host | `SITECORE_API_HOST`, `CSDK_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_HOST` | -| Editing secret | `SITECORE_EDITING_SECRET` (fallback placeholder if unset) | -| Default site | `SITECORE_DEFAULT_SITE`, `CSDK_PUBLIC_SITECORE_DEFAULT_SITE`, `CSDK_PUBLIC_DEFAULT_SITE` | -| Default language | `SITECORE_DEFAULT_LANGUAGE`, `CSDK_PUBLIC_DEFAULT_LANGUAGE` → default **`en`** | -| Personalize | `PERSONALIZE_MIDDLEWARE_*_TIMEOUT`, scope envs, … | -| Local GraphQL path | Hardcoded **`/sitecore/api/graph/edge`** unless overridden in config | +## Key prefixes (by head) + +| Prefix | Typical use | +|--------|----------------| +| **`SITECORE_*`** | Server-side / shared secrets and IDs (Next **`.env`**, Angular server **`process.env`**, CI). | +| **`NEXT_PUBLIC_*`** | **Next.js** convention for values that must exist in the browser bundle; **`buildFallbackConfig`** reads several of these directly (alongside **`SITECORE_*`** / **`CSDK_PUBLIC_*`**). | +| **`CSDK_PUBLIC_*`** | **Angular** convention only: the scaffold’s **`generate-environment.ts`** copies only these keys into **`environment.*.ts`**, which become **`clientEnv`** for **`@sitecore-content-sdk/angular`** **`defineConfig`**. Next templates do **not** rely on this prefix for public config. | + +Any head may pass a merged map into **`defineConfig`**, so multiple prefixes can appear in the same object at runtime (e.g. Angular SSR merges **`clientEnv`** with **`process.env`**). + +## `buildFallbackConfig` env keys (exact names) + +Values below follow **`env.A \|\| env.B \|\| …`** in **`buildFallbackConfig`** unless noted. + +| Area | Environment variable keys (in evaluation order) | +|------|---------------------------------------------------| +| Edge hostname (input to **`resolveEdgeUrl`**) | `CSDK_PUBLIC_SITECORE_EDGE_HOSTNAME`, then `SITECORE_EDGE_PLATFORM_HOSTNAME` | +| Edge context ID | `SITECORE_EDGE_CONTEXT_ID` | +| Edge **client** context ID | `SITECORE_EDGE_CLIENT_CONTEXT_ID`, then `CSDK_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | +| Local GraphQL **API key** | `SITECORE_API_KEY`, then `CSDK_PUBLIC_SITECORE_API_KEY`, then `NEXT_PUBLIC_SITECORE_API_KEY` | +| Local GraphQL **host** | `SITECORE_API_HOST`, then `CSDK_PUBLIC_SITECORE_API_HOST`, then `NEXT_PUBLIC_SITECORE_API_HOST` | +| Editing secret | `SITECORE_EDITING_SECRET` (if unset, code uses placeholder string **`editing-secret-missing`**) | +| Default site | `SITECORE_DEFAULT_SITE`, then `CSDK_PUBLIC_SITECORE_DEFAULT_SITE`, then `CSDK_PUBLIC_DEFAULT_SITE` | +| Default language | `SITECORE_DEFAULT_LANGUAGE`, then `CSDK_PUBLIC_DEFAULT_LANGUAGE`; if still empty, defaults to **`en`** | +| Personalize — Edge timeout (ms) | `PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT` (parsed integer; default **400** if missing/invalid) | +| Personalize — CDP timeout (ms) | `PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT` (parsed integer; default **400** if missing/invalid) | +| Personalize — scope | `SITECORE_PERSONALIZE_SCOPE`, then `CSDK_PUBLIC_PERSONALIZE_SCOPE`, then `NEXT_PUBLIC_PERSONALIZE_SCOPE` | +| Redirects / personalize **enabled** flags | Derived from **`NODE_ENV`** (`!== 'development'` → enabled), not separate `SITECORE_*` keys | +| Local GraphQL **path** | Not from env: hardcoded **`/sitecore/api/graph/edge`** in this fallback object (overridable via **`sitecore.config.ts`**) | + +**Source for `SITECORE_EDGE_PLATFORM_HOSTNAME`:** the content package imports **`SITECORE_EDGE_PLATFORM_HOSTNAME_ENV`** from **`@sitecore-content-sdk/core/tools`**; that export’s value is the string **`'SITECORE_EDGE_PLATFORM_HOSTNAME'`** (`packages/core/src/tools/resolve-edge-url.ts`). ## By head (how env reaches `defineConfig`) diff --git a/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md b/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md index 20367cf492..3e624190e8 100644 --- a/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md +++ b/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md @@ -60,13 +60,43 @@ Framework-agnostic client: layout pages, dictionary, preview/editing, error page | `getSiteMap` | Sitemap XML string | | `getRobots` | robots.txt | -## Angular usage (pointer) +## `Page` type contract -`@sitecore-content-sdk/angular` re-exports **`SitecoreClient`** from **`@sitecore-content-sdk/content/client`**. The template uses a lazy singleton **`getClient()`** that does **`new SitecoreClient(scConfig)`** so credentials are not required at Angular build-time route extraction. Loaders call **`resolveSitecorePage(path, scConfig, getClient())`**, which delegates to **`client.getPage`**. +`SitecoreClient.getPage` returns **`Page | null`** (`packages/content/src/client/sitecore-client.ts`). -## Next.js usage (pointer) +| Field | Type | Notes | +|-------|------|-------| +| `layout` | `LayoutServiceData` | Contains `layout.sitecore.route: RouteData \| null` — route fields and placeholder tree | +| `siteName` | `string?` | Resolved site name | +| `locale` | `string` | Active locale/language for this page | +| `mode` | `PageMode` | See flags below | -**`SitecoreNextjsClient`** extends the base with **`parsePath`**, App Router preview headers, **`getComponentData`**, etc. See [../content-sdk-nextjs/doc-sitecore-client-apis.md](../content-sdk-nextjs/doc-sitecore-client-apis.md). +**`PageMode` flags:** + +| Flag | Meaning | +|------|---------| +| `mode.isEditing` | Page is open in the Sitecore Pages editor | +| `mode.isPreview` | Preview mode | +| `mode.isNormal` | Normal rendering (not editing, not preview) | +| `mode.isDesignLibrary` | Design Library rendering | + +## Editing utilities (`content/editing`) + +`@sitecore-content-sdk/content/editing` exports two framework-agnostic helpers used by both Angular and Next.js: + +| Export | Purpose | +|--------|---------| +| `isEditorActive()` | Returns `true` when the page is loaded inside the Sitecore Pages editor (checks window/DOM signals) | +| `resetEditorChromes()` | Re-initializes the editor chrome decorators after client-side navigation or dynamic content changes | + +Both heads re-export these from their own packages. In Angular they appear in `SitecoreContextService`'s editing integration; in Next.js the React `Placeholder` calls `PagesEditor.resetChromes()` (same concept, reached through `@sitecore-content-sdk/react` re-export). + +**Source:** `packages/content/src/editing/utils.ts` + +## Head-specific usage + +- **Angular:** `getClient()` singleton, `resolveSitecorePage` — see [doc-sitecore-config-typescript-angular.md](../content-sdk-angular/doc-sitecore-config-typescript-angular.md). +- **Next.js:** `SitecoreNextjsClient` extensions — see [doc-sitecore-client-apis.md](../content-sdk-nextjs/doc-sitecore-client-apis.md). ## Related diff --git a/llm-wiki/wiki/common/doc-sitecore-config-input.md b/llm-wiki/wiki/common/doc-sitecore-config-input.md index 8f58dff589..f485b183d3 100644 --- a/llm-wiki/wiki/common/doc-sitecore-config-input.md +++ b/llm-wiki/wiki/common/doc-sitecore-config-input.md @@ -49,7 +49,7 @@ Holds **`config: SitecoreConfig`**, optional **`build.commands`**, **`scaffold.t | Head | Wrapper | Notes | |------|---------|-------| -| **Next.js** | `@sitecore-content-sdk/nextjs/config` **`defineConfig`** | **`getNextFallbackConfig`** adds **`NEXT_PUBLIC_*`**, preview multisite cookie behavior, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`**, etc. | +| **Next.js** | `@sitecore-content-sdk/nextjs/config` **`defineConfig`** | **`getNextFallbackConfig`** adds **`NEXT_PUBLIC_*`**, preview multisite cookie behavior, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`** (full list: [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md)). | | **Angular** | `@sitecore-content-sdk/angular` **`defineConfig`** | Merges **`clientEnv`** (generated **`environment*.ts`**) with **`getProcessEnv()`** on the server, then calls content **`defineConfig`**. | ## Related diff --git a/llm-wiki/wiki/common/doc-terminology-platform-names.md b/llm-wiki/wiki/common/doc-terminology-platform-names.md new file mode 100644 index 0000000000..5c2b737997 --- /dev/null +++ b/llm-wiki/wiki/common/doc-terminology-platform-names.md @@ -0,0 +1,13 @@ +# Platform naming (SAI / XM Cloud / XMC) + +In official docs, URLs, and template comments you will see **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC**. For Content SDK work in **this monorepo**, treat them as the **same hosted platform context** unless code explicitly branches on a label. + +## Practical guidance + +- Do not treat mixed labels as conflicting products when reading issues, PRs, or wiki notes. +- Template `sitecore.config` comments may use **XMC**-style URLs while SAI doc URLs use `/sai/` — same product family for this wiki. + +## See also + +- [content-sdk-nextjs/overview-content-sdk.md](../content-sdk-nextjs/overview-content-sdk.md) +- [content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) — example of mixed URL prefixes in comments vs docs diff --git a/llm-wiki/wiki/common/index.md b/llm-wiki/wiki/common/index.md index b9e07f1725..10ae34a83a 100644 --- a/llm-wiki/wiki/common/index.md +++ b/llm-wiki/wiki/common/index.md @@ -7,8 +7,11 @@ Framework-agnostic Content SDK knowledge: **`packages/core`**, **`packages/conte | Page | Summary | |------|---------| | [doc-sitecore-config-input.md](doc-sitecore-config-input.md) | **`SitecoreConfigInput`** / **`SitecoreConfig`**, merge pipeline, CLI config, head wrappers | -| [doc-config-environment-variables.md](doc-config-environment-variables.md) | **`buildFallbackConfig`** env keys; Next vs Angular env wiring | +| [doc-config-environment-variables.md](doc-config-environment-variables.md) | **`buildFallbackConfig`** exact env keys; **`SITECORE_*` / `NEXT_PUBLIC_*` / `CSDK_PUBLIC_*`** (Angular-only) | | [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) | **`createGraphQLClientFactory`**, Edge/local URLs, **`SitecoreClient`** methods | +| [doc-component-map.md](doc-component-map.md) | Component map format, `GenerateMapArgs`, type names, head-specific wiring | +| [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, **XMC** — same platform naming in docs and comments | +| [wiki-boundary-and-token-audit.md](../wiki-boundary-and-token-audit.md) | Next vs Angular vs **common** boundaries, LLM routing, vague-language checklist | ## Head wikis diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md index a5cf4a7186..2914fa5e80 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md @@ -4,6 +4,8 @@ This hub splits the ingested **JSS-Angular Live Design** architecture PDF into f **Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · PDF in repo: [`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`](../../raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf) +The Angular head fetches Sitecore layout data through a **loader system**: route `resolve` functions backed by a named loader registry, server execution with Angular's `TransferState`, and an Express RPC endpoint (`/_data`) for client navigations. Each subsection page below covers a section of this design. + ## Pages (by PDF section) | Topic | Page | @@ -26,4 +28,4 @@ This hub splits the ingested **JSS-Angular Live Design** architecture PDF into f - [index.md](index.md) — Angular wiki hub - [Common wiki — config, env, SitecoreClient + GraphQL](../common/index.md) — **`@sitecore-content-sdk/content`** canonical pages -- [Next.js `sitecore.config` (Next-only fields)](../content-sdk-nextjs/doc-sitecore-config.md) — **`getNextFallbackConfig`**, App Router multisite notes +- [Next.js wiki](../content-sdk-nextjs/index.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md b/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md index d5907d6fa1..c3478b60dc 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md @@ -10,11 +10,14 @@ The Angular template and package assume **standalone** components (no NgModule f ## Component map -- Generation is driven from **`sitecore.cli.config.ts`** (same family as Next). -- **`packages/angular/src/tools/generate-map.ts`** implements Angular map generation; output is consumed via **`SITECORE_COMPONENT_MAP`** injection token in **`app.config.ts`** (`useValue: componentMap` from **`.sitecore/component-map`**). +The component map format and CLI generation contract are shared with Next.js — see [../common/doc-component-map.md](../common/doc-component-map.md) for the full specification. + +Angular-specific details: +- **`packages/angular/src/tools/generate-map.ts`** implements the Angular generator; it calls the shared `buildComponentMapContent()` and hardcodes the `ScFormComponent` built-in entry. +- The generated map is consumed via **`SITECORE_COMPONENT_MAP`** injection token in **`app.config.ts`** (`useValue: componentMap` from **`.sitecore/component-map`**). ## Placeholders -**`sc-placeholder`** and **`placeholder-utils.ts`** resolve rendering names to standalone components using the same map shape as Next (PascalCase, default + variant files at generation time). Editing mode affects which renderings are exposed (`getPlaceholderRenderings` takes **`isEditing`** from **`SitecoreContextService`** — see [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md)). +**`sc-placeholder`** and **`placeholder-utils.ts`** resolve rendering names to standalone components using the component map (PascalCase keys, default + variant files at generation time). Editing mode affects which renderings are exposed (`getPlaceholderRenderings` takes **`isEditing`** from **`SitecoreContextService`** — see [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md)). **Related:** [doc-field-directives.md](doc-field-directives.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md index bcc6048726..682412b6eb 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md @@ -1,5 +1,7 @@ # Editing and page context (Angular) +**Status: editing integration is not yet implemented** for the Angular head. This page documents what is currently available. + The PDF marked **Editing** as TBA. In code, editing is surfaced primarily through **`SitecoreContextService`** and layout **`Page.mode`**, not through a separate Next-style middleware document for Angular. **Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) @@ -45,6 +47,6 @@ export class SitecoreContextService { - **`sc-placeholder`** uses **`isEditing`** when choosing placeholder renderings. - **`sc-form`** skips certain client behavior when **`isEditing`** is true. -- **`@sitecore-content-sdk/angular`** re-exports **`isEditorActive`** and **`resetEditorChromes`** from **`@sitecore-content-sdk/content/editing`** for chrome detection utilities used with Experience Editor–style flows. +- **`@sitecore-content-sdk/angular`** re-exports **`isEditorActive`** and **`resetEditorChromes`** from **`@sitecore-content-sdk/content/editing`** (implementation: **`packages/content/src/editing/utils.ts`**). **Related:** [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md index e91d014fad..919352504c 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md @@ -4,7 +4,7 @@ How the Angular head avoids raw **`process.env`** in the browser and still feeds **Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) -**Shared with all heads:** [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) ( **`buildFallbackConfig`** env keys, Next vs Angular wiring) · [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) (merge pipeline, **`SitecoreConfigInput`** tables). +**Shared with all heads:** [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) (**`buildFallbackConfig`** env keys; public prefix **`CSDK_PUBLIC_*`** vs **`NEXT_PUBLIC_*`**) · [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) (merge pipeline, **`SitecoreConfigInput`** tables). ## Angular `defineConfig` wrapper @@ -18,6 +18,6 @@ Only **`CSDK_PUBLIC_*`** keys are embedded in those files; server secrets use ** ## GraphQL and `SitecoreClient` -Merged **`SitecoreConfig`** drives the same **`createGraphQLClientFactory`** and **`SitecoreClient`** as Next. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — especially **Angular usage** for **`getClient()`** + **`new SitecoreClient(scConfig)`**. +Merged **`SitecoreConfig`** drives **`createGraphQLClientFactory`** and **`SitecoreClient`** the same way as for any head using **`@sitecore-content-sdk/content`**; see [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **Angular usage** for **`getClient()`** + **`new SitecoreClient(scConfig)`**. -**Related:** [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) · [Next.js sitecore config (Next-only fields)](../content-sdk-nextjs/doc-sitecore-config.md) +**Related:** [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) · Next-only **`getNextFallbackConfig`** pipeline: [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md new file mode 100644 index 0000000000..e86f7b70b9 --- /dev/null +++ b/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md @@ -0,0 +1,33 @@ +# Locale and dictionary (Angular) — stub + +**Status: full i18n is not yet implemented** for the Angular head. This page documents what is currently available. + +## What is available now + +### Locale from the `Page` object + +The Angular template does not use locale URL segments. Locale is resolved by Sitecore on the server and returned as **`page.locale`** in the `Page` object from `SitecoreClient.getPage`. + +`resolveSitecorePage` accepts optional `options.locale`; if omitted it falls back to `sitecoreConfig.defaultLanguage`. The resolved locale is available to components via `SitecoreContextService.page()?.locale`. + +### Dictionary + +A **`dictionaryLoader`** in the route config calls **`getClient().getDictionary({ site, locale })`** (`SitecoreClient.getDictionary` from `@sitecore-content-sdk/content`). Dictionary data is accessed from route data as `data()?.dictionary`. + +**Source:** `packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts` + +### Default language env key + +`CSDK_PUBLIC_DEFAULT_LANGUAGE` maps to `defaultLanguage` in `sitecore.config.ts` via `buildFallbackConfig`. See [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md). + +## Not yet implemented + +- Locale URL segments / routing by locale +- Multi-locale route generation (`getPagePaths` with locale list) +- Third-party i18n library integration + +## Related + +- [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) — `resolveSitecorePage` options +- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — env keys +- [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) — `defaultLanguage` in config diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md b/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md index b0ac313bd6..186e2a09cf 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md @@ -13,6 +13,6 @@ A **`LoaderFn`** runs in: So anything used **inside** the loader body must be available **without** calling **`inject()`** from within that body. The supported pattern is **static imports**: default **`sitecore.config`**, **`getClient()`** factory module, and helpers such as **`resolveSitecorePage`** from **`@sitecore-content-sdk/angular`**. -The **resolver factory** itself uses **`inject()`** for **`TransferState`**, **`Router`**, **`LOADER_REGISTRY`**, etc.; that is fine because it only runs inside Angular. +The **resolver factory** itself uses **`inject()`** for **`LOADER_REGISTRY`**, **`TransferState`**, **`Router`**, **`platformId`**, and optional **`REQUEST`**; that is fine because it only runs inside Angular. **Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md index 29318fadcb..17472d02c2 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md @@ -1,6 +1,6 @@ # Multisite (Angular) — status -The PDF marked **Multisite** as TBA. **`resolveSitecorePage`** already accepts optional **`site`** (and **`locale`**) overrides on top of **`sitecore.config`** defaults, but high-level multisite resolution (cookie vs host, etc.) is not documented as a first-class Angular feature in the same way as Next middleware. +The PDF marked **Multisite** as TBA. **`resolveSitecorePage`** already accepts optional **`site`** (and **`locale`**) overrides on top of **`sitecore.config`** defaults, but cookie-based or host-header-based site resolution is not yet implemented as a first-class Angular feature. ```4:9:packages/angular/src/lib/sitecore-page-resolver.ts /** @@ -12,6 +12,6 @@ The PDF marked **Multisite** as TBA. **`resolveSitecorePage`** already accepts o * @param {string} path - Route path (e.g. `'/'` or `'/about'`). ``` -**Practical note:** apps can pass **`options.site`** / **`options.locale`** from loader logic once they determine the active site (custom resolver, headers, etc.). Shared **`SitecoreConfig`** multisite keys are described in [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md). +**Practical note:** apps can pass **`options.site`** / **`options.locale`** from loader logic once they determine the active site (for example a custom resolver or request headers). Shared **`SitecoreConfig`** **`multisite`** keys are defined in [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md). Next.js **`[site]`** routing and **`getNextFallbackConfig`** multisite behavior are described in [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) under **Multisite (Next App Router)**. **Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md index b7172f4a8e..34f3ddd480 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md @@ -2,6 +2,6 @@ The PDF marked **Personalization** as TBA. The Angular **`resolveSitecorePage`** helper is a thin **`client.getPage`** wrapper; its JSDoc explicitly calls out **future** helpers for **personalization** (and multisite) next to this call. -Until those helpers exist, personalization behavior depends on what **`SitecoreClient.getPage`** and your **`sitecore.config`** (for example **`personalize`** service settings) already provide — same underlying **`@sitecore-content-sdk/content`** stack as other heads. See [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) for config surface. +Until those helpers exist, personalization behavior depends on what **`SitecoreClient.getPage`** and your **`sitecore.config`** (the **`personalize`** block — `enabled`, `edgeTimeout`, `cdpTimeout`, `scope`, `channel`, `currency`) already provide. See [common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) for the full `SitecoreConfigInput` surface. **Related:** [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) · [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md index c4b8bab861..76af0105a0 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md @@ -4,9 +4,20 @@ Angular apps use the same root **`sitecore.config.ts`** model as other Content S **Shared reference:** [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, **`api.edge` / `api.local`**, merge pipeline. [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — env keys consumed by **`buildFallbackConfig`**. [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — how config becomes GraphQL URLs and **`SitecoreClient.getPage`**. +## `provideSitecoreAngular` + +**`provideSitecoreAngular(config: SitecoreAngularConfig): EnvironmentProviders`** — the Angular app's DI bootstrap entry point. Defined in `packages/angular/src/lib/providers.ts`. + +| Parameter | Type | Required | Role | +|-----------|------|----------|------| +| `sitecoreConfig` | `SitecoreConfig` | No | Config from `sitecore.config.ts`; bound to `SITECORE_CONFIG_TOKEN` | +| `sitecoreClient` | `SitecoreClient` | No | App-owned client singleton; bound to `SITECORE_CLIENT_TOKEN` | +| `notFoundRoute` | `string` | No | Angular route path for 404; bound to `NOT_FOUND_ROUTE_TOKEN` | +| `errorRoute` | `string` | No | Angular route path for 500; bound to `ERROR_ROUTE_TOKEN` | + ## In the template -The scaffold imports the default config into **`app.config.ts`** (`provideSitecoreAngular({ sitecoreConfig: scConfig, sitecoreClient: getClient(), ... })`) and into loaders via a **static import** of **`sitecore.config`**. +The scaffold imports the default config into **`app.config.ts`**: `provideSitecoreAngular({ sitecoreConfig: scConfig, sitecoreClient: getClient(), notFoundRoute: '/not-found', errorRoute: '/error' })`. Loaders import **`sitecore.config`** statically (no DI in loader bodies — see [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md)). ## `SitecoreClient` in generated apps diff --git a/llm-wiki/wiki/content-sdk-angular/index.md b/llm-wiki/wiki/content-sdk-angular/index.md index 770542c0c4..59877d2928 100644 --- a/llm-wiki/wiki/content-sdk-angular/index.md +++ b/llm-wiki/wiki/content-sdk-angular/index.md @@ -20,6 +20,7 @@ | [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | `SitecoreContextService`, `isEditing`, content `editing` re-exports | | [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | Multisite: PDF TBA vs `resolveSitecorePage` options + JSDoc “future” | | [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | Personalization: PDF TBA vs client/config reality | +| [doc-i18n-angular.md](doc-i18n-angular.md) | **Stub:** locale on `Page`, `dictionaryLoader`; URL-segment i18n not implemented | ## Sources @@ -35,15 +36,16 @@ - `packages/angular/src/config/define-config.ts` — Angular `defineConfig` wrapper - `packages/angular/src/server/loader-data-service-middleware.ts` — loader RPC middleware -## Shared with Next.js (common wiki) +## Shared packages (common wiki) -These topics are identical for Angular and Next at the **`@sitecore-content-sdk/content`** layer: +These topics live under **`@sitecore-content-sdk/content`** (and related packages); the **common** wiki is canonical: - [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, merge pipeline -- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys; Angular **`CSDK_PUBLIC_*`** vs Next **`NEXT_PUBLIC_*`** +- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys (**`CSDK_PUBLIC_*`** vs **`NEXT_PUBLIC_*`** on this page and in that doc’s prefix table) - [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **`createGraphQLClientFactory`**, **`SitecoreClient`**, **`getPage`** +- [../common/doc-component-map.md](../common/doc-component-map.md) — component map format, `GenerateMapArgs`, CLI generation +- [../common/doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) — platform name conventions ## See also -- [Next.js wiki index](../content-sdk-nextjs/index.md) — parallel head patterns - [Common wiki index](../common/index.md) — shared packages diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md b/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md index 8c94422048..b5dedb9261 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md @@ -11,11 +11,11 @@ From official [Architecture overview](https://doc.sitecore.com/sai/en/developers ## Runtime (this repo) -GraphQL URLs for **`SitecoreClient`** come from **`sitecore.config.ts`** via **`defineConfig`** and **env** — not primarily from `package.json`. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) and [doc-sitecore-config.md](doc-sitecore-config.md). +GraphQL URLs for **`SitecoreClient`** come from **`sitecore.config.ts`** via **`defineConfig`** and **env** — not from `package.json`. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) and [doc-sitecore-config.md](doc-sitecore-config.md). ## Templates -**Pages Router** `package.json` may still list `graphQLEndpointPath` for tooling alignment. **App Router** template may omit that block — treat **`sitecore.config` + env** as authoritative. +`package.json` may still list `graphQLEndpointPath` but it has no role. Treat **`sitecore.config` + env** as authoritative. ## Implementation @@ -24,7 +24,7 @@ GraphQL URLs for **`SitecoreClient`** come from **`sitecore.config.ts`** via **` ## Mental model -Authors compose pages in SitecoreAI. The head consumes **JSON** (GraphQL responses) from Edge or local GraphQL via **`SitecoreClient`**. Fetch wiring is **framework-specific** (Next `getPage` / App Router / middleware; Angular loaders + **`resolveSitecorePage`**); **`@sitecore-content-sdk/react`** renders typed layout data but does not replace **`SitecoreClient`**. +Authors compose pages in SitecoreAI. The head consumes **JSON** (GraphQL responses) from Edge or local GraphQL via **`SitecoreClient`**. Fetch wiring is **Next.js-specific** — Pages Router `getPage` / App Router `draftMode` / middleware. **`@sitecore-content-sdk/react`** renders typed layout data but does not replace **`SitecoreClient`**. ## Raw diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md b/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md index 0bc02c85aa..bf12fdbf8c 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md @@ -2,7 +2,7 @@ Official: [Editor integration using metadata](https://doc.sitecore.com/sai/en/developers/content-sdk/20/editor-integration-using-metadata.html). Raw: `llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md`. -**Scope:** Next.js **Pages Router** + SitecoreAI **Page builder** — metadata on placeholders, renderings, fields for visual editing. +**Scope:** Next.js **Pages Router** or **App Router** + SitecoreAI **Page builder** — metadata on placeholders, renderings, fields for visual editing. ## Head routes (template) @@ -23,15 +23,59 @@ Official: [Editor integration using metadata](https://doc.sitecore.com/sai/en/de 2. **`EditingRenderMiddleware`** sets **Next preview data**, CSP, optional preview cookies for **`mode=preview`**, then **server fetch** of the catch-all route with preview cookies + **`x-sitecore-editing-params`** header + **`__content_sdk_preview`**. 3. Catch-all uses **`getPreview`** / **`getDesignLibraryData`** when `context.preview` is set — **`EditingService`** GraphQL with **`sc_editMode` / `sc_previewMode`** headers. +## CORS (editing API routes) + +Sitecore **Pages editor** run on fixed **Sitecore cloud origins** and may call your app’s **`/api/editing/*`** routes from the browser. Those requests are **cross-origin**, so the editing handlers must validate the request’s **`Origin`** and return appropriate **`Access-Control-*`** headers (including **`OPTIONS`** preflight). + +### Default allowed origins + +The SDK ships a built-in list used for every editing handler that calls **`getEnforcedCorsHeaders`** with **`allowedOrigins: EDITING_ALLOWED_ORIGINS`**: + +```39:44:packages/content/src/editing/utils.ts +export const EDITING_ALLOWED_ORIGINS = [ + 'https://pages.sitecorecloud.io', + 'https://xmapps.sitecorecloud.io', + 'https://designlibrary.sitecorecloud.io', + 'https://app.sitecorecloud.io', +]; +``` +Custom **`JSS_ALLOWED_ORIGINS`** env needs to be set when connecting to staging or dev Sitecore AI deployments with non-default hostname. + +### `getEnforcedCorsHeaders` (`@sitecore-content-sdk/core/tools`) + +Implementation: **`packages/core/src/tools/utils.ts`**. It: + +1. Reads the request **`Origin`** header (supports both Node **`IncomingHttpHeaders`** and Fetch **`Headers`**). +2. Builds the effective allowlist from **three** sources (concatenated): **`JSS_ALLOWED_ORIGINS`** (comma-separated env list, spaces stripped), the **`allowedOrigins`** argument (above defaults), and an optional **`presetCorsHeader`** (e.g. an origin already set by Next config). +3. Accepts the request if **`Origin`** equals an entry **or** matches an entry treated as a **wildcard pattern** (`*` → `.*` in a regex anchored to the full string). +4. On success, returns headers including **`Access-Control-Allow-Origin`** set to the **request’s** `Origin` (echo), **`Access-Control-Allow-Methods`**: `GET, POST, OPTIONS, DELETE, PUT, PATCH`, **`x-middleware-cache`**: `no-cache`, **`Cache-Control`**: `no-store, must-revalidate`. For **`OPTIONS`**, it also adds **`Access-Control-Allow-Headers`**: `Content-Type, Authorization`. +5. If **`Origin`** is present but **not** allowed, returns **`null`** (callers respond with **401** and an HTML/plain message that the origin is not allowed). Debug logs mention **`JSS_ALLOWED_ORIGINS`** for operators extending the allowlist. +6. If there is **no** `Origin` header, the helper returns **`{}`** (empty object). Callers treat that as “no CORS failure from this check” but typically **do not** emit `Access-Control-Allow-*` from this path—same-origin or non-browser callers often have no `Origin`. + +### Where it is applied (Next.js) + +| Surface | Behavior | +|---------|----------| +| **`EditingRenderMiddleware`** (Pages API) | CORS checked **before** editing secret; **`OPTIONS`** → **204** with CORS headers; invalid origin → **401** JSON/html. | +| **`EditingConfigMiddleware`** | Same pattern: CORS first, then secret, then **`OPTIONS`** **204**. | +| **`FEAASRenderMiddleware`** | Same for **`/api/editing/feaas/render`** (**GET** / **OPTIONS** only after CORS). | +| **App Router** `createEditingRenderRouteHandlers` | **`GET`** / **`OPTIONS`** use the same **`getEnforcedCorsHeaders`** helper. **`POST`** (Design Library server-action proxy): CORS may be **bypassed** when the request target is **`localhost`** or **same host** as the request `Origin` (e.g. some Vercel/Netlify setups); otherwise invalid origin → **401**. Successful responses still merge CORS headers into the proxied response where applicable. | + +Operational note: set **`JSS_ALLOWED_ORIGINS`** (comma-separated origins, optional `*` wildcards per segment) when editors or previews hit your app from hosts **outside** the default Sitecore cloud list (e.g. custom staging URLs). + ## CSP / iframes Strict **`X-Frame-Options`** or **`frame-ancestors 'self'`** breaks Pages iframe — allow the Pages host. ## Code -- `packages/nextjs/src/editing/editing-render-middleware.ts`, `editing-config-middleware.ts`, `utils.ts` +- `packages/core/src/tools/utils.ts` — **`getEnforcedCorsHeaders`**, **`getAllowedOriginsFromEnv`** +- `packages/content/src/editing/utils.ts` — **`EDITING_ALLOWED_ORIGINS`** +- `packages/nextjs/src/editing/editing-render-middleware.ts`, `editing-config-middleware.ts`, `feaas-render-middleware.ts`, `editing/utils.ts` (CSP helpers) +- `packages/nextjs/src/route-handler/editing-render-route-handler.ts`, `editing-config-route-handler.ts` — App Router handlers ## Related - [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) -- [doc-terminology-platform-names.md](doc-terminology-platform-names.md) +- [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) +- [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — add **`JSS_ALLOWED_ORIGINS`** to `.env` when extending editing CORS beyond defaults diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md b/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md index 450600b8ec..e05842f8ea 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md @@ -4,30 +4,88 @@ From [Example environment variable files](https://doc.sitecore.com/sai/en/develo ## Product intent -- **`.env.*.example`** files document required variables for **container** vs **remote** SitecoreAI dev. +- **`.env.*.example`** files document two **lifecycle**-oriented setups: **container** (local stack) vs **remote** (hosted Sitecore AI / Edge). - Copy into **`.env.local`** for real values; never commit secrets into **`.example`** files. ## Where they live in this monorepo -**Pages Router** template: `packages/create-content-sdk-app/src/templates/nextjs/` +### CLI templates (source only) + +Templates under **`packages/create-content-sdk-app/src/templates/`** are **scaffolding sources** for `create-content-sdk-app`. They include committed **`.env.*.example`** files so generated apps get the right shape—but the **template folders themselves are not runnable apps** (no in-repo install/dev workflow as a full application). + +| Head / router | Template path | Example env files (in repo) | +|-----------------|---------------|------------------------------| +| **Next.js — Pages Router** | `packages/create-content-sdk-app/src/templates/nextjs/` | `.env.container.example`, `.env.remote.example` | +| **Next.js — App Router** | `packages/create-content-sdk-app/src/templates/nextjs-app-router/` | `.env.container.example`, `.env.remote.example` | | Template file | When to use | |---------------|-------------| -| `.env.container.example` | Local GraphQL against **Docker / local** Sitecore — `NEXT_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_KEY`, editing + default site/language. | -| `.env.remote.example` | **Experience Edge** / remote — `SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional `NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME`, Personalize timeouts/scope, optional Design Library auth vars. | +| `.env.container.example` | **Local development** against **Docker / local** Sitecore (or equivalent local images): local GraphQL, `NEXT_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_KEY`, editing + default site/language. | +| `.env.remote.example` | **Remote / hosted Sitecore AI** — **Experience Edge** and IDs for a cloud tenant; variables such as `SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional `NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME`, Personalize timeouts/scope, optional Design Library auth. Tuned for **authoring and editing** against a remote instance; you can still run the app in dev against remote, but that path is oriented to **Pages / editor** workflows rather than “pure local stack” day one. | -(App Router template ships its own `.env.*.example` under `templates/nextjs-app-router/` — same pattern, different variable set; align with that template when scaffolding.) +### Container vs remote (lifecycle) -## Relationship to `sitecore.config.ts` +- **Container** — You run **local** Sitecore (commonly **Docker** images) while building the head. GraphQL and keys target that **on-machine** stack; this is the usual **inner-loop development** story. +- **Remote** — The head talks to a **remote Sitecore AI** tenant: **Experience Edge** hostname, **`SITECORE_EDGE_CONTEXT_ID`**, **`NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`**, and related keys as listed in **`.env.remote.example`** for your template. That flow is **mainly for editing and authoring** in the cloud product, not for replacing local Docker—but **development can continue** against a remote instance when you intentionally point `.env.local` at that tenant (for example a shared dev environment or editor smoke tests). + +**Pages vs App Router:** both Next templates ship **`.env.container.example`** and **`.env.remote.example`** under the same **container vs remote** idea; individual variable names can differ—always copy from the **template** (or generated app) you actually use. + +### Scaffolded samples (local dev, diagnostics) + +For **local testing, diagnostics, and anything that needs a working app** (install, dev server, real `.env.local`), use **`samples/`**, not the template tree. + +1. From the **monorepo root**, run **`yarn scaffold-samples`**. That runs **`scripts/scaffold-samples.js`**, which reads **`scripts/samples.json`** and, for each entry, calls **`initialize`** from **`packages/create-content-sdk-app`** with **`destination`** set to **`./samples/<folder>/`** (folder name from **`scripts/utils.js`** **`getAppFolder`**, e.g. **`sample-nextjs-SSG`** when `template` is **`nextjs`** and **`prerender`** is **`SSG`**). +2. The scaffold **copies/transforms** the chosen template into that **`samples/<folder>/`** app. That app is a **normal generated head**: copy **`.env.container.example`** or **`.env.remote.example`** to **`.env.local`**, install deps, run **`dev`** / **`start`**, use **`yarn lint-samples`** for CI-style lint of scaffolded apps. +3. **`samples/`** is listed in **`.gitignore`**—it is **not** checked in. Each developer (or CI job) regenerates samples when needed. + +**Summary:** **Templates** = canonical **examples** and CLI input. **`samples/*`** = disposable **runnable** copies for monorepo local dev; put env files and runtime diagnostics there. + +### Template watch mode (`yarn watch`) -`defineConfig` / **`buildFallbackConfig`** read the same logical settings from **`process.env`** (and Next’s **`getNextFallbackConfig`** layers **`NEXT_PUBLIC_*`**). Keeping **`.env.local`** and **`sitecore.config.ts`** in sync (especially **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** ↔ **`defaultLanguage`**, site name, Edge IDs) avoids subtle mismatches. See [doc-sitecore-config.md](doc-sitecore-config.md). +For **template authors** iterating on files under `src/templates/`: detects changes with **chokidar** and re-scaffolds the destination sample automatically. -## Angular template (cross-head) +**Prerequisite:** Create **`watch.json`** in `packages/create-content-sdk-app/` (gitignored at package level): + +```json +{ + "template": "<template-name>", + "args": { + "yes": true, + "force": true, + "silent": false, + "appName": "<app-name>", + "destination": "..\\..\\samples\\<folder>", + "prerender": "SSG" + } +} +``` + +| Field | Notes | +|-------|-------| +| `template` | `"nextjs"`, `"nextjs-app-router"`, or `"angular"` | +| `args.destination` | Relative to `packages/create-content-sdk-app/`; typically `"../../samples/<folder>"` | +| `args.force` | Must be `true` — overwrites destination on every re-scaffold | +| `args.prerender` | `"SSG"` or `"SSR"` | + +**Run:** from `packages/create-content-sdk-app/`, run **`yarn watch`** (`ts-node ./scripts/watch-templates.ts`). + +**Lifecycle:** + +1. **Startup (`ready`):** scaffolds the sample once with a full `yarn install` in the destination. +2. **On any `src/templates/` file change:** re-scaffolds with `noInstall: true` — skips `yarn install` to keep the loop fast. +3. **After each scaffold:** `restoreLockfile` checks `git status`; if `yarn.lock` was modified, it runs `git restore ../../yarn.lock` so the sample's install changes do not pollute the monorepo lock file. + +**Env files in the sample:** copy `.env.container.example` or `.env.remote.example` to `.env.local` inside `samples/<folder>/` after the first scaffold. Re-scaffolds overwrite app source files but not `.env.local` (it is not a template file). + +**Script:** `packages/create-content-sdk-app/scripts/watch-templates.ts` + +## Relationship to `sitecore.config.ts` -The Angular scaffold does **not** use **`NEXT_PUBLIC_*`** for the browser bundle the same way. It documents **`CSDK_PUBLIC_*`** in **`.env.example`**, runs **`scripts/generate-environment.ts`**, and emits **`src/environments/environment.*.ts`** so **`defineConfig`** receives literals in the client. Server-only variables stay in **`process.env`** (loaded before `sitecore.config` on the server). Canonical table of **`buildFallbackConfig`** keys (shared with Next): [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md). +`defineConfig` / **`buildFallbackConfig`** read the same logical settings from **`process.env`** (and Next’s **`getNextFallbackConfig`** layers **`NEXT_PUBLIC_*`**). Keeping **`.env.local`** and **`sitecore.config.ts`** in sync (especially **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** ↔ **`defaultLanguage`**, site name, Edge IDs) avoids subtle mismatches. See [doc-sitecore-config.md](doc-sitecore-config.md). For another head in this monorepo, open the **[Angular wiki index](../content-sdk-angular/index.md)** (public env uses **`CSDK_PUBLIC_*`** and **`generate-environment.ts`**). ## Related - [doc-sitecore-config.md](doc-sitecore-config.md) - [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) - [doc-i18n-multilingual.md](doc-i18n-multilingual.md) — `NEXT_PUBLIC_DEFAULT_LANGUAGE` and Next `i18n.defaultLocale` +- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) — **`JSS_ALLOWED_ORIGINS`** for editing CORS diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md b/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md index 180c91abf5..b9765855e1 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md @@ -1,6 +1,6 @@ # GraphQL client factory and Edge URLs (Next hub) -Canonical **code-truth** for **`createGraphQLClientFactory`**, Edge vs local URLs, and **`SitecoreClient`** construction lives in the **common** wiki (same behavior for Angular and any **`@sitecore-content-sdk/content`** consumer): +Canonical **code-truth** for **`createGraphQLClientFactory`**, Edge vs local URLs, and **`SitecoreClient`** construction lives in the **common** wiki: - **[../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md)** diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md b/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md index 990bdc1990..1af51725d3 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md @@ -14,14 +14,106 @@ From [Page composition in Content SDK apps using SitecoreAI data](https://doc.si - Rendering names map to **registered** front-end components (`.sitecore/component-map.ts`). - **Dynamic placeholders** — supported per product doc; keep names in sync. +## `Placeholder` vs `AppPlaceholder` (React / Next) + +Both ultimately render the same **placeholder resolution** pipeline (`getPlaceholderRenderings`, component map, editing metadata). The split is **where context comes from** and **whether the tree can run as a React Server Component (RSC)**. + +### `AppPlaceholder` (`@sitecore-content-sdk/react`) + +- **No `use client`** on the module: safe to import from **server components** when the build wires **`#rsc-env`** for App Router. +- **Requires** explicit **`page`** and **`componentMap`** props (`AppPlaceholderProps`); it does **not** call **`useSitecore()`**. +- Uses **`rsc`** from **`#rsc-env`**: when **`rsc`** is true and a mapped component is a **client** component, it wraps the child in **`ClientComponentWrapper`** so the server placeholder can host client leaves without illegal boundary crossing. +- In **editing** mode, wraps output in **`PlaceholderMetadata`** for Pages chromes / hydration markers. + +**Typical use:** **Next.js App Router** root **`Layout.tsx`** in the template (`packages/create-content-sdk-app/src/templates/nextjs-app-router/src/Layout.tsx`) — default **Layout is a server component**; it renders **`<AppPlaceholder page={page} componentMap={componentMap} name="…" rendering={route} />`**. Server-only editing surfaces such as **`DesignLibraryServer`** also use **`AppPlaceholder`**. + +### `Placeholder` (React — `@sitecore-content-sdk/react`) + +- Declares **`'use client'`** and uses **`useSitecore()`** to obtain **`page`** and **`componentMap`** when callers omit them (Pages-style apps rely on **`SitecoreProvider`**). +- Runs a **`useEffect`** that calls **`PagesEditor.resetChromes()`** when the placeholder is empty and the Pages editor is active (client-only editor UX). +- Delegates rendering to **`<AppPlaceholder {...appProps} />`** after merging props. + +```1:34:packages/react/src/components/Placeholder/Placeholder.tsx +'use client'; +import React, { useEffect } from 'react'; +import { PlaceholderProps } from './models'; +import { PagesEditor } from '@sitecore-content-sdk/content/editing'; +import { getPlaceholderRenderings } from './placeholder-utils'; +import { useSitecore } from '../SitecoreProvider'; +import { AppPlaceholder } from './AppPlaceholder'; +// ... +export const Placeholder = (props: PlaceholderProps) => { + const { page, componentMap } = useSitecore(); + // ... + const appProps = { ...props, page, componentMap }; + + return <AppPlaceholder {...appProps} />; +}; +``` + +**Typical use:** **Pages Router** template **`Layout.tsx`** imports **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** and passes **`name`** + **`rendering`** only; **`SitecoreProvider`** supplies **`page`** / **`componentMap`**. Nested placeholders inside **client** route trees use the same **`Placeholder`**. + +### `Placeholder` (Next.js — `@sitecore-content-sdk/nextjs`) + +- Also **`'use client'`**; wraps the React **`Placeholder`** and merges **`getComponentData`** output from **`ComponentPropsReactContext`** into each child’s props via **`modifyComponentProps`**. + +```1:38:packages/nextjs/src/components/Placeholder.tsx +'use client'; +import React, { useContext } from 'react'; +import { + Placeholder as ReactPlaceholder, + PlaceholderComponentProps, + EnhancedOmit, + SitecoreProviderState, +} from '@sitecore-content-sdk/react'; +import { ComponentPropsReactContext } from './ComponentPropsContext'; +// ... +export const Placeholder = (props: PlaceholderProps) => { + const componentPropsContext = useContext(ComponentPropsReactContext); + + return ( + <ReactPlaceholder + {...props} + modifyComponentProps={(initialProps) => { + if (!initialProps.rendering.uid) return initialProps; + const data = componentPropsContext[initialProps.rendering.uid] as { + [key: string]: unknown; + }; + + return { ...initialProps, ...data }; + }} + /> + ); +}; +``` + +**Typical use:** **Pages Router** only (Next-specific component props hydration). App Router template uses **`AppPlaceholder`** from **`@sitecore-content-sdk/nextjs`** directly, not this wrapper. + +### HOCs + +| HOC | Declares client? | Inner component | +|-----|------------------|-----------------| +| **`withPlaceholder`** | **`'use client'`** | React **`Placeholder`** (optional `page` / `componentMap` from props or context) | +| **`withAppPlaceholder`** | No **`use client`** on the module | **`AppPlaceholder`** with required **`page`** / **`componentMap`** on the wrapper props | + +### Quick matrix + +| Stack | Root layout pattern | Component | +|--------|----------------------|-----------| +| **Pages Router** (template) | Client/SSR page tree with **`SitecoreProvider`** | **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** | +| **App Router** (template) | Server **`Layout`**; **`Providers`** (`'use client'`) wraps **`SitecoreProvider`** around children, but root placeholders still use explicit props | **`AppPlaceholder`** from **`@sitecore-content-sdk/nextjs`** | +| **RSC / server-only branches** | Must pass **`page`** + **`componentMap`** | **`AppPlaceholder`** | +| **Client subtrees** (hooks, chromes reset, Next `getComponentData` merge) | Under **`SitecoreProvider`** | **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** (App Router) or React **`Placeholder`** (any app using provider only) | + ## Code anchors -- `packages/react` — `Placeholder`, field components. -- `packages/nextjs` — editing, `getComponentData`, App Router helpers. -- Templates — `Layout.tsx`, `[[...path]].tsx` / App Router `[[...path]]/page.tsx`. +- `packages/react` — **`Placeholder`**, **`AppPlaceholder`**, **`placeholder-utils`**, **`withPlaceholder`**, **`withAppPlaceholder`** +- `packages/nextjs` — Next **`Placeholder`**, **`ComponentPropsReactContext`**, editing, `getComponentData`, App Router helpers +- Templates — Pages **`Layout.tsx`** (`Placeholder`); App Router **`Layout.tsx`** (`AppPlaceholder`); `[[...path]].tsx` / `[[...path]]/page.tsx` ## Related +- [../common/doc-component-map.md](../common/doc-component-map.md) — component map format and CLI generation (shared contract) - [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) - [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md b/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md index 9e32f4c527..b8073ead48 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md @@ -1,13 +1,24 @@ -# Plugins and adapters +# Plugins and adapters (Next.js) + +**Scope: Next.js head only.** The Angular head does not use this plugin system. For Angular bootstrap, start at the **[Angular wiki index](../content-sdk-angular/index.md)**. Official: [Plugins](https://doc.sitecore.com/sai/en/developers/content-sdk/20/plugins.html) · [Adapters](https://doc.sitecore.com/sai/en/developers/content-sdk/20/adapters.html). Raw: `llm-wiki/raw/2026-05-14-plugins.md`, `2026-05-14-adapters.md`. +## Initialization (`initContentSdk`) + +**`initContentSdk`** (`packages/core/src/initialization/init-content-sdk.ts`) is called from **`Bootstrap.tsx`** in Next.js templates. It: + +1. Resolves core context from `{ contextId, edgeUrl, siteName }`. +2. Registers all supplied plugins into an internal map keyed by plugin name. +3. Calls each plugin's `init()` function (if present) asynchronously and awaits completion. + +Called from: `packages/create-content-sdk-app/src/templates/nextjs/src/Bootstrap.tsx`. + ## Plugins -- Declarative, typed extensions with **`name`**, **`options`**, **`dependencies`**, **`init`**, optional **`adapter`**. -- Typical entry: **`initContentSdk`** (see templates / `packages/nextjs` init patterns). +Declarative typed extensions with `name`, `options`, `dependencies`, optional `init`, and optional `adapter`. -## Built-in stack (per doc) +## Built-in stack | Plugin | Role | Package | |--------|------|---------| @@ -15,8 +26,6 @@ Official: [Plugins](https://doc.sitecore.com/sai/en/developers/content-sdk/20/pl | `eventsPlugin` | Page view / custom events | `@sitecore-content-sdk/events` | | `personalizeBrowserPlugin` / `personalizeServerPlugin` | Personalization | `@sitecore-content-sdk/personalize` | -Further reading: [Initializing tracking, events, and personalization](https://doc.sitecore.com/sai/en/developers/content-sdk/20/initializing-tracking,-events,-and-personalization-in-the-content-sdk.html). - ## Adapters Environment-specific implementations for plugins (browser vs server: cookies, headers, location). Analytics adapters extend **`PluginAdapter`** / **`AnalyticsAdapter`** from **`@sitecore-content-sdk/core`**. diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md index 7fa459051e..baa27d5ccb 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md @@ -7,14 +7,12 @@ Synthesized from official [The Sitecore configuration file](https://doc.sitecore ## Where it lives - Generated apps: root **`sitecore.config.ts`**. -- Templates: `packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts`, `nextjs-app-router/sitecore.config.ts`, Angular template equivalents. +- Templates: `packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts`, `nextjs-app-router/sitecore.config.ts`. ## Next.js resolution pipeline -1. **`defineConfig`** from **`@sitecore-content-sdk/nextjs/config`** (`packages/nextjs/src/config/define-config.ts`) runs **`getNextFallbackConfig`**: merges **`NEXT_PUBLIC_*`**, **`VERCEL_ENV === 'preview'`** for multisite cookie resolution, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`**, etc. -2. Passes result to **`defineConfig`** from **`@sitecore-content-sdk/content/config`** (`packages/content/src/config/define-config.ts`). -3. Content **`defineConfig(config, env?)`**: **`buildFallbackConfig(env)`** → **`resolveConfig`** (**`deepMerge`**, skips `undefined` and **`''`** overrides) → **`resolveEdgeUrl`** on merged `api.edge.edgeUrl`. (Details: [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md).) -4. **CLI mode** (`SITECORE_CLI_MODE=true`): lazy validation **Proxy** on sensitive paths; else immediate **`validateApiConfiguration`** (server needs Edge **`contextId`** or local **`apiHost`+`apiKey`**). +1. **`defineConfig`** from **`@sitecore-content-sdk/nextjs/config`** (`packages/nextjs/src/config/define-config.ts`) runs **`getNextFallbackConfig`**: merges **`NEXT_PUBLIC_*`**, **`VERCEL_ENV === 'preview'`** for multisite cookie resolution, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`** (see `packages/nextjs/src/config/define-config.ts` for the complete list). +2. Passes merged env to content **`defineConfig`** — the shared `buildFallbackConfig` → `deepMerge` → `resolveEdgeUrl` → CLI validation pipeline applies. See [common merge pipeline](../common/doc-sitecore-config-input.md). ## Next-only `SitecoreConfigInput` fields @@ -42,8 +40,8 @@ When using the **`[site]`** segment pattern, keep **`multisite.enabled`** consis - [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — `.env.*.example` vs `defineConfig` / env. - [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) — `editingSecret`, render host. -- [doc-terminology-platform-names.md](doc-terminology-platform-names.md) -- [../content-sdk-angular/doc-environment-and-define-config-angular.md](../content-sdk-angular/doc-environment-and-define-config-angular.md) — Angular **`CSDK_PUBLIC_*`** → `environment*.ts` +- [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) +- [../content-sdk-angular/index.md](../content-sdk-angular/index.md) — other heads in this monorepo (start here for Angular) ## Raw diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md b/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md index 9980a0ea38..97fd25af2e 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md +++ b/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md @@ -1,13 +1,5 @@ -# Platform naming (SAI / XM Cloud / XMC) +# Platform naming (moved) -In official docs, URLs, and template comments you will see **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC**. For Content SDK work in **this monorepo**, treat them as the **same hosted platform context** unless code explicitly branches on a label. +**Canonical page:** [../common/doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) -## Practical guidance - -- Do not treat mixed labels as conflicting products when reading issues, PRs, or wiki notes. -- Template `sitecore.config` comments may use **XMC**-style URLs while SAI doc URLs use `/sai/` — same product family for this wiki. - -## See also - -- [overview-content-sdk.md](overview-content-sdk.md) -- [doc-sitecore-config.md](doc-sitecore-config.md) — example of mixed URL prefixes in comments vs docs +This stub remains so older links under `content-sdk-nextjs/` still resolve. All new links should target the **common** wiki. diff --git a/llm-wiki/wiki/content-sdk-nextjs/index.md b/llm-wiki/wiki/content-sdk-nextjs/index.md index 22a964b176..0197c42058 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/index.md +++ b/llm-wiki/wiki/content-sdk-nextjs/index.md @@ -15,7 +15,7 @@ Catalog of **Next.js head** pages (`@sitecore-content-sdk/nextjs`, Pages/App Rou | Page | Summary | |------|---------| | [overview-content-sdk.md](overview-content-sdk.md) | Monorepo purpose, package map, doc topic map, head-app vs SDK scope | -| [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | **SAI / Sitecore AI / XMC / XM Cloud** — interchangeable names in docs and comments | +| [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) | **SAI / Sitecore AI / XMC / XM Cloud** — interchangeable names in docs and comments | ## Official docs (SAI 2.x) — synthesized @@ -24,13 +24,13 @@ Catalog of **Next.js head** pages (`@sitecore-content-sdk/nextjs`, Pages/App Rou | [doc-sitecore-config.md](doc-sitecore-config.md) | `sitecore.config.ts` + Next **`defineConfig`** / **`getNextFallbackConfig`**; links to **common** for full **`SitecoreConfigInput`**, env keys, GraphQL + `SitecoreClient` | | [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) | Experience Edge, GraphQL; runtime vs `package.json` doc note; points to **common** for implementation | | [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) | Next hub → **common** canonical factory doc; Next dev proxy pointer | -| [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | Authoring vs GraphQL JSON; Layout + component map | +| [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | Authoring vs GraphQL JSON; **`Placeholder`** vs **`AppPlaceholder`** (Pages vs App Router, RSC); component map | | [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | Catch-all, `getPage` / preview / `getComponentData`; LayoutService path under `packages/content` | | [doc-i18n-multilingual.md](doc-i18n-multilingual.md) | i18n: App Router `next-intl` (raw) + **Pages Router** code (`next.config` i18n, `extractPath`, `getDictionary`, `next-localization`) | -| [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | `.env.container` / `.env.remote` examples, template paths, Angular **`CSDK_PUBLIC_*`** cross-link, link to `sitecore.config` | +| [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | `.env` examples: **Pages + App Router** template paths; **`yarn scaffold-samples`** → **`samples/`** for runnable local dev | | [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) | **Next** `SitecoreNextjsClient` extensions; **common** for base `SitecoreClient` + GraphQL | | [doc-plugins-and-adapters.md](doc-plugins-and-adapters.md) | Plugins, adapters, analytics / personalize stack | -| [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | Page builder, editing API routes, preview, FEaaS, CSP | +| [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | Page builder, editing API routes, preview, FEaaS, **CORS** (`getEnforcedCorsHeaders`, `JSS_ALLOWED_ORIGINS`), CSP | ## Concepts & flows diff --git a/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md b/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md index 61ff0557e0..ef874f6af4 100644 --- a/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md +++ b/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md @@ -4,7 +4,7 @@ ## Platform naming (read this first) -**Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC** (in URLs and comments) refer to the **same** platform context for Content SDK work in this repo. See [doc-terminology-platform-names.md](doc-terminology-platform-names.md). +**Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC** (in URLs and comments) refer to the **same** platform context for Content SDK work in this repo. See [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md). ## Purpose @@ -14,7 +14,7 @@ This repository ships **TypeScript packages**, a **scaffolding CLI** (`create-co | Topic | Wiki | |--------|------| -| Naming | [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | +| Naming | [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) | | Config types (all heads) | [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) | | Env fallbacks / `buildFallbackConfig` (all heads) | [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) | | `SitecoreClient` + GraphQL factory (all heads) | [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md index 8d4e8353ab..144bb15b08 100644 --- a/llm-wiki/wiki/index.md +++ b/llm-wiki/wiki/index.md @@ -13,6 +13,8 @@ Agent-maintained markdown under `llm-wiki/wiki/`, split by **head stack** and ** | | | |--|--| | [log.md](log.md) | Append-only ingest / query / lint log for **all** wiki areas | +| [wiki-boundary-and-token-audit.md](wiki-boundary-and-token-audit.md) | Boundary rules (Next vs Angular vs **common**), LLM routing, conformance checklist | +| [plans/](plans/) | In-progress feature and wiki-change plans ([plans/README.md](plans/README.md)) | | [AGENTS.md](../AGENTS.md) | LLM Wiki schema, workflows, truth hierarchy | -**Agents:** For Next-specific answers, open **`content-sdk-nextjs/index.md`** then the linked page. For **`sitecore.config`**, env fallbacks, or **`SitecoreClient`** / GraphQL behavior shared by all heads, start with **`common/index.md`**. For Angular integration (loaders, SSR), use **`content-sdk-angular/`**. +**Agents:** For Next-specific answers, open **`content-sdk-nextjs/index.md`** then the linked page. For **`sitecore.config`**, env fallbacks, or **`SitecoreClient`** / GraphQL behavior shared by all heads, start with **`common/index.md`**. For Angular integration (loaders, SSR), use **`content-sdk-angular/`**. For wiki structure and vague-language rules, read **`wiki-boundary-and-token-audit.md`** first when auditing docs. For **in-progress** wiki or feature notes, see **`plans/`**. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index b23177aace..0839de4795 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -6,6 +6,14 @@ Prefix suggestion for parseability: `## [YYYY-MM-DD] ingest | <short title>` / ` --- +## [2026-05-14] wiki | Boundary and LLM-usability audit + +Added **`wiki/wiki-boundary-and-token-audit.md`** (routing rules, conformance checklist, delta table, diagram). Stubbed **`content-sdk-nextjs/doc-terminology-platform-names.md`** → **common** canonical. Applied link and wording fixes (Angular multisite → common config, editing chrome → `content/editing`, removed **etc.**, Angular index “Shared packages”, **AGENTS.md** / **README** terminology paths). + +## [2026-05-14] wiki | Editor integration — CORS (`getEnforcedCorsHeaders`, `JSS_ALLOWED_ORIGINS`) + +Expanded **`content-sdk-nextjs/doc-editor-integration-metadata.md`** from **`packages/core`** / **`packages/content`** / **`packages/nextjs`** editing handlers (Pages + App Router POST bypass). + ## [2026-05-14] wiki | Common wiki — config, env, SitecoreClient + GraphQL Extracted framework-agnostic material from **Next.js** wiki into **`common/doc-sitecore-config-input.md`**, **`common/doc-config-environment-variables.md`**, **`common/doc-sitecore-client-and-graphql.md`**. Trimmed **`content-sdk-nextjs/doc-sitecore-config.md`**, **`doc-graphql-client-and-edge-urls.md`**, **`doc-sitecore-client-apis.md`** to Next-specific deltas; pointed **`doc-architecture-edge-graphql`**, **`doc-route-handling-data-fetching`**, **`overview-content-sdk`**, root **`index.md`** at **common**. Expanded **Angular** env + `sitecore.config` pages and **`doc-example-environment-variable-files`** (Angular **`CSDK_PUBLIC_*`**). diff --git a/llm-wiki/wiki/plans/README.md b/llm-wiki/wiki/plans/README.md new file mode 100644 index 0000000000..c9902d1d56 --- /dev/null +++ b/llm-wiki/wiki/plans/README.md @@ -0,0 +1,13 @@ +# Wiki plans (in progress) + +This folder holds **in-progress** plans for wiki updates and **in-progress product or documentation features** that are not yet fully captured in canonical wiki pages under `wiki/common/`, `wiki/content-sdk-nextjs/`, or `wiki/content-sdk-angular/`. + +## Conventions + +- **Naming:** Use clear filenames (for example `feature-name-outline.md`, `doc-topic-refactor.md`). +- **Lifecycle:** When work is done, fold outcomes into the appropriate wiki pages (and [log.md](../log.md) if the change is significant), then **delete** or **archive** the plan file so agents do not treat stale plans as truth. +- **Authority:** Plans here are **not** the truth hierarchy. Prefer [wiki/index.md](../index.md), [wiki/wiki-boundary-and-token-audit.md](../wiki-boundary-and-token-audit.md), and repository `packages/**` sources. + +## Relation to IDE plans + +Editor-local plan files (for example under **`.cursor/plans/`**) are outside this tree. **`wiki/plans/`** is **git-tracked** under `llm-wiki` so the team can share in-flight wiki or feature notes in-repo. From 594f5bb2200c1b9ff11a990bb40e42e01cccc645 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Fri, 22 May 2026 17:05:49 -0400 Subject: [PATCH 03/14] v1/phase 1 of isr-like --- .../doc-architecture-loaders-and-ssr.md | 1 + llm-wiki/wiki/content-sdk-angular/index.md | 4 + .../angular/src/loaders/loader-resolver.ts | 44 ++-- packages/angular/src/loaders/utils.ts | 11 + .../angular/src/server/cache/cache-key.ts | 77 +++++++ packages/angular/src/server/cache/index.ts | 17 ++ .../angular/src/server/cache/loader-cache.ts | 160 +++++++++++++++ packages/angular/src/server/cache/models.ts | 188 ++++++++++++++++++ .../src/server/cache/resolve-loader-data.ts | 83 ++++++++ packages/angular/src/server/index.ts | 5 + .../server/loader-data-service-middleware.ts | 74 +++---- packages/angular/src/server/models.ts | 6 + 12 files changed, 607 insertions(+), 63 deletions(-) create mode 100644 packages/angular/src/server/cache/cache-key.ts create mode 100644 packages/angular/src/server/cache/index.ts create mode 100644 packages/angular/src/server/cache/loader-cache.ts create mode 100644 packages/angular/src/server/cache/models.ts create mode 100644 packages/angular/src/server/cache/resolve-loader-data.ts diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md index 2914fa5e80..d896d9170f 100644 --- a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md +++ b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md @@ -23,6 +23,7 @@ The Angular head fetches Sitecore layout data through a **loader system**: route | Editing / page context | [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | | Multisite | [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | | Personalization | [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | +| ISR / server-side caching (investigation) | [doc-isr-investigation-and-caching.md](doc-isr-investigation-and-caching.md) | ## See also diff --git a/llm-wiki/wiki/content-sdk-angular/index.md b/llm-wiki/wiki/content-sdk-angular/index.md index 59877d2928..bf0406b11b 100644 --- a/llm-wiki/wiki/content-sdk-angular/index.md +++ b/llm-wiki/wiki/content-sdk-angular/index.md @@ -21,6 +21,8 @@ | [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | Multisite: PDF TBA vs `resolveSitecorePage` options + JSDoc “future” | | [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | Personalization: PDF TBA vs client/config reality | | [doc-i18n-angular.md](doc-i18n-angular.md) | **Stub:** locale on `Page`, `dictionaryLoader`; URL-segment i18n not implemented | +| [doc-isr-investigation-and-caching.md](doc-isr-investigation-and-caching.md) | **ISR investigation** — unstorage, POC approaches 2–4, mainline vs POC; decision TBD | +| [doc-loader-cache-plan.md](doc-loader-cache-plan.md) | **Loader cache plan** — Option 3 implementation: server-only module, persistence, on/off, prerender, invalidation | ## Sources @@ -28,6 +30,8 @@ |--------|----------| | JSS-Angular Live Design PDF (ingest 2026-05-14) | **`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`** | | Text extract (same document) | `llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md` | +| JSS-Angular ISR PDF (ingest 2026-05-22) | **`llm-wiki/raw/design/JSS-Angular-ISR-220526-175323.pdf`** | +| Text extract (ISR document) | `llm-wiki/raw/2026-05-22-jss-angular-isr-investigation.md` | ## Code anchors diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index d981ad3ff7..7d0c0dd3d8 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -1,4 +1,4 @@ -import { inject, TransferState, PLATFORM_ID, REQUEST, makeStateKey } from '@angular/core'; +import { inject, TransferState, PLATFORM_ID, REQUEST, REQUEST_CONTEXT, makeStateKey } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { ActivatedRouteSnapshot, @@ -16,10 +16,11 @@ import { DEFAULT_NOT_FOUND_ROUTE, LoaderHttpError, NotFoundNavigationError, - isLoaderRedirectResult, } from './models'; import { redirectOnNavigationError } from './router-error-handling'; import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN } from '../lib/tokens'; +import { resolveLoaderData } from '../server/cache/resolve-loader-data'; +import type { LoaderCache } from '../server/cache/models'; /** * Create a state key for the loader @@ -123,20 +124,37 @@ export const loaderResolver = (loaderId: LoaderId): ResolveFn<unknown> => { if (!loader) { throw new Error(`No loader registered for id "${loaderId}"`); } - const requestContext = request ? extractRequestContext(request) : undefined; + const ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as + | { cache?: LoaderCache } + | undefined; + const cache = ssrContext?.cache; + + const result = await resolveLoaderData( + { + loaderId, + url, + params: route.params, + query: route.queryParams as Record<string, string | string[]>, + }, + registry, + cache, + requestContext + ); + + if (result.kind === 'redirect') { + return applyRedirect(router, result.redirect.loaderRedirectTarget); + } - const result = await loader({ - url, - params: route.params, - query: route.queryParams, - requestContext, - }); - if (isLoaderRedirectResult(result)) { - return applyRedirect(router, result.loaderRedirectTarget); + if (result.kind === 'error') { + const cause = (result as { cause?: unknown }).cause; + if (cause instanceof NotFoundNavigationError) throw cause; + if (cause instanceof LoaderHttpError) throw cause; + throw new LoaderHttpError(result.status, result.message); } - transferState.set(key, result); - return result; + + transferState.set(key, result.data); + return result.data; }; resolver[LOADER_ID] = loaderId; diff --git a/packages/angular/src/loaders/utils.ts b/packages/angular/src/loaders/utils.ts index b09cddefce..18997b04d5 100644 --- a/packages/angular/src/loaders/utils.ts +++ b/packages/angular/src/loaders/utils.ts @@ -130,9 +130,20 @@ export function extractRequestContext(req: Request | ExpressLikeRequest): Reques } // Express-like request object + const hostHeader = req.headers?.['host']; + const hostname = pickHostnameFromHostHeader( + Array.isArray(hostHeader) ? hostHeader[0] : hostHeader + ); return { + hostname, headers: req.headers, cookies: req.cookies, query: req.query, }; } + +function pickHostnameFromHostHeader(host: string | undefined): string | undefined { + if (!host) return undefined; + const colon = host.indexOf(':'); + return colon === -1 ? host : host.slice(0, colon); +} diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts new file mode 100644 index 0000000000..0395da2f1e --- /dev/null +++ b/packages/angular/src/server/cache/cache-key.ts @@ -0,0 +1,77 @@ +import type { LoaderContext } from '../../loaders/models'; +import { CacheKeyDimensions, dimensionsFromContext, InvalidateFilter } from './models'; + +const KEY_PREFIX = 'loader'; + +/** + * Compose the canonical cache key. + * Format: loader:<namespace?>:<site>:<language>:<variantId>:<loaderId>:<route>:<paramsHash> + */ +export function buildCacheKey( + loaderId: string, + ctx: LoaderContext, + namespace?: string +): { key: string; dimensions: CacheKeyDimensions } { + const dims = dimensionsFromContext(loaderId, ctx); + const key = serializeKey(dims, namespace); + return { key, dimensions: dims }; +} + +export function serializeKey(d: CacheKeyDimensions, namespace?: string): string { + const ns = namespace ? `:${escapeSegment(namespace)}` : ''; + return [ + KEY_PREFIX + ns, + escapeSegment(d.site), + escapeSegment(d.language), + escapeSegment(d.variantId), + escapeSegment(d.loaderId), + escapeSegment(d.route), + d.paramsHash, + ].join(':'); +} + +/** + * Tag list mirrored alongside each entry — used by invalidate() to find matching keys. + */ +export function buildTags(d: CacheKeyDimensions, namespace?: string): string[] { + const ns = namespace ? `${escapeSegment(namespace)}:` : ''; + return [ + `${ns}site:${escapeSegment(d.site)}`, + `${ns}language:${escapeSegment(d.language)}`, + `${ns}variant:${escapeSegment(d.variantId)}`, + `${ns}loader:${escapeSegment(d.loaderId)}`, + `${ns}route:${escapeSegment(d.route)}`, + ]; +} + +/** + * Resolve an InvalidateFilter into the set of tags that an entry must carry to match. + * Omitted dimensions widen to "all" (no tag constraint on that axis); + * `site` defaults to `defaultSiteName` unless explicitly '*'. + */ +export function filterToRequiredTags( + filter: InvalidateFilter, + defaultSiteName: string, + namespace?: string +): string[] { + const ns = namespace ? `${escapeSegment(namespace)}:` : ''; + const required: string[] = []; + + const site = filter.site === '*' ? null : filter.site ?? defaultSiteName; + if (site) required.push(`${ns}site:${escapeSegment(site)}`); + if (filter.language) required.push(`${ns}language:${escapeSegment(filter.language)}`); + if (filter.variantId) required.push(`${ns}variant:${escapeSegment(filter.variantId)}`); + if (filter.loaderId) required.push(`${ns}loader:${escapeSegment(filter.loaderId)}`); + + required.push(`${ns}route:${escapeSegment(filter.route)}`); + return required; +} + +/** + * Segments are delimited by ':' in the key; any ':' in a segment value would + * collide. URL-encode the colon (and a few other unsafe chars) to keep parsing + * trivial. + */ +function escapeSegment(s: string): string { + return s.replace(/[:%]/g, (c) => encodeURIComponent(c)); +} diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts new file mode 100644 index 0000000000..a12446a38f --- /dev/null +++ b/packages/angular/src/server/cache/index.ts @@ -0,0 +1,17 @@ +export type { + LoaderCache, + LoaderCacheConfig, + LoaderCacheLoaderConfig, + LoaderCacheEntry, + LoaderCacheEntryInfo, + InvalidateFilter, + CacheKeyDimensions, +} from './models'; +export { createLoaderCache } from './loader-cache'; +export { resolveLoaderData, type ResolveLoaderDataResult } from './resolve-loader-data'; +export { + createCacheAdminMiddleware, + type CacheAdminMiddlewareOptions, +} from './cache-admin-middleware'; +export { buildCacheKey, buildTags, filterToRequiredTags, serializeKey } from './cache-key'; +export { dimensionsFromContext } from './models'; diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts new file mode 100644 index 0000000000..bc60f827e6 --- /dev/null +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -0,0 +1,160 @@ +import { + InvalidateFilter, + LoaderCache, + LoaderCacheConfig, + LoaderCacheEntry, + LoaderCacheEntryInfo, + LoaderCacheLoaderConfig, +} from './models'; +import { filterToRequiredTags } from './cache-key'; + +const DEFAULT_TTL_SECONDS = 300; + +interface ResolvedConfig { + namespace: string; + defaultTtl: number | 'infinite'; + enabled: boolean; + loaders: Record<string, LoaderCacheLoaderConfig>; + defaultSiteName: string; +} + +/** + * Public factory for the v1 in-memory loader cache. Returns a {@link LoaderCache} + * backed by an internal {@link InMemoryLoaderCache} class. + * + * The class is intentionally not exported: callers should depend on the + * {@link LoaderCache} interface, so we can swap the implementation (unstorage, + * Redis, etc.) without touching public types. See plan §4.3. + * @public + */ +export function createLoaderCache(config: LoaderCacheConfig = {}): LoaderCache { + return new InMemoryLoaderCache(config); +} + +/** + * Default LoaderCache implementation: single in-process Map, O(N) tag-scan + * invalidation. Suitable for single-process deployments and demos. + * + * Not exported. Driver variants (unstorage memory/fs/redis) live in sibling + * classes that implement the same {@link LoaderCache} interface. + * @internal + */ +class InMemoryLoaderCache implements LoaderCache { + private readonly resolved: ResolvedConfig; + private readonly store = new Map<string, LoaderCacheEntry>(); + + constructor(config: LoaderCacheConfig) { + this.resolved = resolveConfig(config); + } + + async get(key: string): Promise<LoaderCacheEntry | null> { + const entry = this.store.get(key); + if (!entry) return null; + if (this.isExpired(entry)) { + this.store.delete(key); + return null; + } + return entry; + } + + async set( + key: string, + value: unknown, + ttlSeconds: number | 'infinite', + tags: string[] + ): Promise<void> { + const ttl = ttlSeconds === 'infinite' ? null : ttlSeconds; + const expiresAt = ttl === null ? null : this.now() + ttl * 1000; + this.store.set(key, { + value, + tags: [...tags], + storedAt: this.now(), + expiresAt, + }); + } + + async invalidate(filter: InvalidateFilter): Promise<number> { + const required = filterToRequiredTags( + filter, + this.resolved.defaultSiteName, + this.resolved.namespace + ); + let deleted = 0; + for (const [key, entry] of this.store) { + if (required.every((tag) => entry.tags.includes(tag))) { + this.store.delete(key); + deleted++; + } + } + return deleted; + } + + async delete(key: string): Promise<boolean> { + return this.store.delete(key); + } + + async flush(): Promise<void> { + this.store.clear(); + } + + async entries(): Promise<LoaderCacheEntryInfo[]> { + const out: LoaderCacheEntryInfo[] = []; + for (const [key, entry] of this.store) { + if (this.isExpired(entry)) { + this.store.delete(key); + continue; + } + out.push({ + key, + tags: [...entry.tags], + storedAt: entry.storedAt, + expiresAt: entry.expiresAt, + approxBytes: approxByteSize(entry.value), + }); + } + return out; + } + + resolveTtl(loaderId: string): number | 'infinite' { + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; + return this.resolved.defaultTtl; + } + + isEnabled(loaderId: string): boolean { + if (!this.resolved.enabled) return false; + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.enabled === false) return false; + return true; + } + + getConfig(): ResolvedConfig { + return this.resolved; + } + + private now(): number { + return Date.now(); + } + + private isExpired(entry: LoaderCacheEntry): boolean { + return entry.expiresAt !== null && entry.expiresAt <= this.now(); + } +} + +function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { + return { + namespace: config.namespace ?? '', + defaultTtl: config.defaultTtl ?? DEFAULT_TTL_SECONDS, + enabled: config.enabled ?? true, + loaders: config.loaders ?? {}, + defaultSiteName: config.defaultSiteName ?? 'default', + }; +} + +function approxByteSize(value: unknown): number { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts new file mode 100644 index 0000000000..177eff2058 --- /dev/null +++ b/packages/angular/src/server/cache/models.ts @@ -0,0 +1,188 @@ +import type { LoaderContext } from '../../loaders/models'; + +/** + * Persisted cache entry shape. Stored under the composite cache key built by + * buildCacheKey(); see cache-key.ts. + * @public + */ +export interface LoaderCacheEntry { + value: unknown; + tags: string[]; + storedAt: number; + expiresAt: number | null; // null = never expire +} + +/** + * Per-loader config overrides. Most-specific wins over defaults from + * LoaderCacheConfig.defaultTtl / enabled. + * @public + */ +export interface LoaderCacheLoaderConfig { + enabled?: boolean; + ttl?: number | 'infinite'; + /** Reserved for Phase 4 (variant-aware keying). Accepted but ignored in v1. */ + personalize?: boolean; +} + +/** + * Config passed to createLoaderCache(). + * @public + */ +export interface LoaderCacheConfig { + /** unique app id used to scope keys when multiple apps share a store */ + namespace?: string; + /** default TTL in seconds; pass 'infinite' to never expire */ + defaultTtl?: number | 'infinite'; + /** master switch — set to false to make every call fall through to the raw loader */ + enabled?: boolean; + /** per-loader overrides keyed by loaderId */ + loaders?: Record<string, LoaderCacheLoaderConfig>; + /** + * Site name used by `invalidate({ route })` when no `site` is supplied. + * Should match `scConfig.defaultSite`. Defaults to `'default'`. + */ + defaultSiteName?: string; +} + +/** + * Filter accepted by cache.invalidate(). `route` is required; other dimensions + * narrow when supplied. See the loader-cache plan doc, §6. + * @public + */ +export interface InvalidateFilter { + route: string; + site?: string | '*'; + language?: string; + /** accepted but ignored in v1 (every entry keys as variant:default) */ + variantId?: string; + loaderId?: string; +} + +/** + * Metadata returned by cache.entries() — sufficient for an admin UI without + * shipping the cached values themselves (which can be large). + * @public + */ +export interface LoaderCacheEntryInfo { + key: string; + tags: string[]; + storedAt: number; + expiresAt: number | null; + /** Approximate serialized byte length of the cached value. */ + approxBytes: number; +} + +/** + * Server-only cache instance. Constructed once in server.ts via + * createLoaderCache() and passed by reference to the middleware factories + * (`createLoaderDataServiceMiddleware`, `createCacheAdminMiddleware`) and to + * Angular SSR through `angularApp.handle(req, { cache })`. + * @public + */ +export interface LoaderCache { + get(key: string): Promise<LoaderCacheEntry | null>; + set(key: string, value: unknown, ttlSeconds: number | 'infinite', tags: string[]): Promise<void>; + /** Per-path invalidation. Returns number of entries deleted. */ + invalidate(filter: InvalidateFilter): Promise<number>; + /** Direct delete by exact key. */ + delete(key: string): Promise<boolean>; + /** Nuke every entry. */ + flush(): Promise<void>; + /** Returns lightweight metadata for every live entry — used by admin tooling. */ + entries(): Promise<LoaderCacheEntryInfo[]>; + resolveTtl(loaderId: string): number | 'infinite'; + isEnabled(loaderId: string): boolean; + /** Reads back the resolved config (useful for admin UI). */ + getConfig(): Readonly<Required<Omit<LoaderCacheConfig, 'loaders'>> & { loaders: Record<string, LoaderCacheLoaderConfig> }>; +} + +/** + * Identity dimensions of a cache key. Derived from LoaderContext by buildCacheKey(). + * @public + */ +export interface CacheKeyDimensions { + site: string; + language: string; + variantId: string; // always 'default' in v1 + loaderId: string; + route: string; + paramsHash: string; +} + +/** + * Builder hook for tests and the admin endpoint: turn a LoaderContext into the + * dimensions used by the key + tag builders. + * @internal + */ +export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { + const params = (ctx.params ?? {}) as Record<string, unknown>; + const headers = (ctx.requestContext?.headers ?? {}) as Record<string, string | string[] | undefined>; + + const site = + (typeof params['site'] === 'string' && (params['site'] as string)) || + pickHeader(headers['x-sitecore-site']) || + ctx.requestContext?.hostname || + 'default'; + + const language = + (typeof params['language'] === 'string' && (params['language'] as string)) || + (typeof params['locale'] === 'string' && (params['locale'] as string)) || + pickHeader(headers['x-sitecore-language']) || + 'en'; + + const route = stripQuery(ctx.url || '/'); + const paramsHash = hashRecord({ + params: ctx.params ?? {}, + query: ctx.query ?? {}, + }); + + return { + site, + language, + variantId: 'default', + loaderId, + route, + paramsHash, + }; +} + +function pickHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +function stripQuery(url: string): string { + const i = url.indexOf('?'); + return i === -1 ? url : url.slice(0, i); +} + +/** + * Stable JSON stringify used to hash params/query so the cache key is + * order-insensitive. Not cryptographic — a fast non-secure hash is enough. + */ +function hashRecord(obj: unknown): string { + const canonical = canonicalStringify(obj); + return fnv1a(canonical).toString(16).padStart(8, '0'); +} + +function canonicalStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(canonicalStringify).join(',') + ']'; + const keys = Object.keys(value as Record<string, unknown>).sort(); + return ( + '{' + + keys + .map((k) => JSON.stringify(k) + ':' + canonicalStringify((value as Record<string, unknown>)[k])) + .join(',') + + '}' + ); +} + +function fnv1a(str: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash >>> 0; +} diff --git a/packages/angular/src/server/cache/resolve-loader-data.ts b/packages/angular/src/server/cache/resolve-loader-data.ts new file mode 100644 index 0000000000..9cbb07ee3f --- /dev/null +++ b/packages/angular/src/server/cache/resolve-loader-data.ts @@ -0,0 +1,83 @@ +import { + LoaderApiRequest, + LoaderContext, + LoaderRedirectResult, + RequestContext, + isLoaderRedirectResult, +} from '../../loaders/models'; +import { LoaderRegistry } from '../models'; +import { buildCacheKey, buildTags } from './cache-key'; +import { LoaderCache } from './models'; + +/** + * Result returned to call sites. Mirrors the raw loader return shape so the + * SSR resolver branch and the Express middleware can wrap as they need. + * @public + */ +export type ResolveLoaderDataResult = + | { kind: 'data'; data: unknown } + | { kind: 'redirect'; redirect: LoaderRedirectResult } + | { kind: 'error'; status: number; message: string }; + +/** + * Shared server-only entry point used by both: + * - loader-resolver.ts (SSR branch) + * - loader-data-service-middleware.ts (/_data endpoint) + * + * Mirrors browser LoaderDataService.getData semantically: check cache -> on + * miss, run loader -> store. Errors are surfaced as `{ kind: 'error' }` to keep + * the contract simple; callers map them to their wire/Router shapes. + * @public + */ +export async function resolveLoaderData( + request: LoaderApiRequest, + registry: LoaderRegistry, + cache: LoaderCache | undefined, + requestContext?: RequestContext +): Promise<ResolveLoaderDataResult> { + const { loaderId, url, params, query } = request; + const loader = registry[loaderId]; + if (!loader) { + return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` }; + } + + const ctx: LoaderContext = { url, params, query, requestContext }; + + const cacheable = cache && cache.isEnabled(loaderId); + + if (cacheable) { + const { key } = buildCacheKey(loaderId, ctx); + const hit = await cache.get(key); + if (hit) { + return { kind: 'data', data: hit.value }; + } + } + + let value: unknown; + try { + value = await loader(ctx); + } catch (err) { + // Preserve the existing wire-level error shape from executeLoader; the SSR + // resolver re-throws to keep the Router error contract. + const message = err instanceof Error ? err.message : 'Loader failed'; + return { kind: 'error', status: 500, message, ...wrapError(err) }; + } + + if (isLoaderRedirectResult(value)) { + return { kind: 'redirect', redirect: value }; + } + + if (cacheable) { + const { key, dimensions } = buildCacheKey(loaderId, ctx); + const tags = buildTags(dimensions); + await cache.set(key, value, cache.resolveTtl(loaderId), tags); + } + + return { kind: 'data', data: value }; +} + +// Lets the SSR resolver re-throw with the original Error subclass when it +// needs to (LoaderHttpError, NotFoundNavigationError). +function wrapError(err: unknown): { cause?: unknown } { + return err instanceof Error ? { cause: err } : {}; +} diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index 7a7d8e5937..dadcf1fdd2 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -12,3 +12,8 @@ export { } from './models'; export { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; + +// Loader cache (server-only). Browser code must not reach createLoaderCache — +// see plan §1 (Browser safety). The exports here are types + server factories; +// they tree-shake out of the browser bundle when not referenced. +export * from './cache'; diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/loader-data-service-middleware.ts index 794789587f..fddf49f239 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/loader-data-service-middleware.ts @@ -1,8 +1,6 @@ import { LoaderApiRequest, LoaderApiResponse, - LoaderContext, - isLoaderRedirectResult, NotFoundNavigationError, RequestContext, LoaderHttpError, @@ -17,6 +15,8 @@ import { LoaderRegistry, } from './models'; import { LOADER_DATA_ENDPOINT } from './constants'; +import { resolveLoaderData } from './cache/resolve-loader-data'; +import type { LoaderCache } from './cache/models'; /** * Execute a loader and return the API response @@ -28,62 +28,35 @@ import { LOADER_DATA_ENDPOINT } from './constants'; async function executeLoader( request: LoaderApiRequest, loaders: LoaderRegistry, - requestContext?: RequestContext + requestContext: RequestContext | undefined, + cache: LoaderCache | undefined ): Promise<LoaderApiResponse> { - const { loaderId, url, params, query } = request; + const result = await resolveLoaderData(request, loaders, cache, requestContext); - const loader = loaders[loaderId]; - if (!loader) { + if (result.kind === 'redirect') { return { - kind: 'error', - status: 500, - message: `No loader registered for id "${loaderId}"`, + kind: 'redirect', + redirect: { + loaderRedirectTarget: result.redirect.loaderRedirectTarget, + status: result.redirect.status, + }, }; } - const context: LoaderContext = { - url, - params, - query, - requestContext, - }; - - try { - const result = await loader(context); - if (isLoaderRedirectResult(result)) { - return { - kind: 'redirect', - redirect: { - loaderRedirectTarget: result.loaderRedirectTarget, - status: result.status, - }, - }; - } - return { - kind: 'data', - data: result, - }; - } catch (error) { - if (error instanceof NotFoundNavigationError) { - return { - kind: 'notFound', - status: 404, - }; + if (result.kind === 'error') { + // Map known loader errors back to wire envelopes; resolveLoaderData + // attaches the original Error via `cause` so we can pattern-match. + const cause = (result as { cause?: unknown }).cause; + if (cause instanceof NotFoundNavigationError) { + return { kind: 'notFound', status: 404 }; } - if (error instanceof LoaderHttpError) { - return { - kind: 'error', - status: error.status, - message: error.message, - }; + if (cause instanceof LoaderHttpError) { + return { kind: 'error', status: cause.status, message: cause.message }; } - const message = error instanceof Error ? error.message : 'Loader failed'; - return { - kind: 'error', - status: 500, - message, - }; + return { kind: 'error', status: result.status, message: result.message }; } + + return { kind: 'data', data: result.data }; } /** @@ -151,6 +124,7 @@ export function createLoaderDataServiceMiddleware( ): ExpressMiddleware { const { loaders, + cache, endpoint = LOADER_DATA_ENDPOINT, extractRequestContext: extractReq = extractRequestContext, } = options; @@ -167,7 +141,7 @@ export function createLoaderDataServiceMiddleware( try { const parsed = parseLoaderRequest(req); if ('loaderId' in parsed) { - const result = await executeLoader(parsed, loaders, requestContext); + const result = await executeLoader(parsed, loaders, requestContext, cache); sendResponse(res, result); } else { res diff --git a/packages/angular/src/server/models.ts b/packages/angular/src/server/models.ts index 904a5c98de..47c2775c28 100644 --- a/packages/angular/src/server/models.ts +++ b/packages/angular/src/server/models.ts @@ -1,6 +1,7 @@ import { InjectionToken } from '@angular/core'; import type { RequestContext } from '../loaders/models'; import type { LoaderFn } from '../loaders/models'; +import type { LoaderCache } from './cache/models'; /** * Injection token for the request context extractor (used by tests to provide a mock via TestBed). @@ -82,6 +83,11 @@ export interface ExpressDataHandlerOptions extends DataHandlerConfig { * The loader registry containing all registered loaders */ loaders: LoaderRegistry; + /** + * Optional loader cache. When supplied, /_data responses go through + * cache-aside; omit to run loaders directly on every request. + */ + cache?: LoaderCache; /** * Optional request context extractor (e.g. for testing via TestBed). * If not provided, uses the default implementation from loaders/utils. From 86db35f56aa02039a1ca03f9c8a61005080102f3 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Mon, 25 May 2026 08:50:18 -0400 Subject: [PATCH 04/14] cache admin demo middleware --- .../server/cache/cache-admin-middleware.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 packages/angular/src/server/cache/cache-admin-middleware.ts diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/cache-admin-middleware.ts new file mode 100644 index 0000000000..4437edcc99 --- /dev/null +++ b/packages/angular/src/server/cache/cache-admin-middleware.ts @@ -0,0 +1,93 @@ +import { + ExpressMiddleware, + ExpressNextFunction, + ExpressRequest, + ExpressResponse, +} from '../models'; +import { InvalidateFilter, LoaderCache } from './models'; + +/** + * Options for the admin middleware. + * @public + */ +export interface CacheAdminMiddlewareOptions { + /** The cache instance to expose. Capture once in `server.ts`. */ + cache: LoaderCache; + /** Base path. Defaults to `/api/_cache`. */ + endpoint?: string; + /** + * Optional auth gate. Return true to allow. Defaults to allowing everything, + * which is fine for local demos — *do not* leave that default in a deploy. + */ + auth?: (req: ExpressRequest) => boolean; +} + +const DEFAULT_ENDPOINT = '/api/_cache'; + +/** + * Lightweight admin surface for the loader cache: + * GET <endpoint>/entries → list entries (metadata only, no values) + * POST <endpoint>/invalidate → invalidate by InvalidateFilter (JSON body) + * POST <endpoint>/flush → flush every entry + * GET <endpoint>/config → resolved config (for the demo UI) + * + * The cache reference is captured in closure at construction time; this + * middleware does not read `req.loaderCache` or any other property off the + * request. + * @public + */ +export function createCacheAdminMiddleware( + options: CacheAdminMiddlewareOptions +): ExpressMiddleware { + const { cache } = options; + const endpoint = options.endpoint ?? DEFAULT_ENDPOINT; + const auth = options.auth ?? (() => true); + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + if (!req.path.startsWith(endpoint + '/')) { + next(); + return; + } + if (!auth(req)) { + res.status(403).json({ error: 'forbidden' }); + return; + } + + const action = req.path.slice(endpoint.length + 1); + + try { + if (action === 'entries' && req.method === 'GET') { + const entries = await cache.entries(); + res.status(200).json({ entries, now: Date.now() }); + return; + } + + if (action === 'config' && req.method === 'GET') { + res.status(200).json(cache.getConfig()); + return; + } + + if (action === 'invalidate' && req.method === 'POST') { + const body = (req.body ?? {}) as Partial<InvalidateFilter>; + if (!body.route || typeof body.route !== 'string') { + res.status(400).json({ error: 'route is required' }); + return; + } + const deleted = await cache.invalidate(body as InvalidateFilter); + res.status(200).json({ deleted }); + return; + } + + if (action === 'flush' && req.method === 'POST') { + await cache.flush(); + res.status(200).json({ ok: true }); + return; + } + + res.status(404).json({ error: `unknown cache admin action: ${action}` }); + } catch (err) { + const message = err instanceof Error ? err.message : 'cache admin error'; + res.status(500).json({ error: message }); + } + }; +} From e0e26904d926c23ea4b4688300c8dc2cce6eeae3 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Mon, 25 May 2026 11:13:15 -0400 Subject: [PATCH 05/14] git reindex app.config file --- .../templates/angular/src/app/app.config.ts | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts index e88e60604f..91fa8b4cd6 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts @@ -1,38 +1,38 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; -import { provideRouter, withNavigationErrorHandler } from '@angular/router'; -import { provideHttpClient, withFetch } from '@angular/common/http'; -import { - provideLoaderRegistry, - handleNavigationError, - provideSitecoreAngular, - PreLoaderDataService, - SITECORE_COMPONENT_MAP, -} from '@sitecore-content-sdk/angular'; -import { routes } from './app.routes'; -import scConfig from '../../sitecore.config'; -import { getClient } from '../content-sdk/client/sitecore-client'; -import { LOADERS } from '../content-sdk/loaders'; -import { componentMap } from '.sitecore/component-map'; - -/** - * Client hydration is disabled so that RouterLink and other directives attach correctly - * after bootstrap. With provideClientHydration(), server-rendered DOM is reused and - * directive event listeners (e.g. RouterLink click) can fail to attach. Without - * hydration, the client re-renders the app and routing works as expected. - */ -export const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - provideHttpClient(withFetch()), - provideRouter(routes, withNavigationErrorHandler(handleNavigationError())), - provideSitecoreAngular({ - notFoundRoute: '/404', - errorRoute: '/500', - sitecoreConfig: scConfig, - sitecoreClient: getClient(), - }), - provideLoaderRegistry(LOADERS), - PreLoaderDataService, - { provide: SITECORE_COMPONENT_MAP, useValue: componentMap }, - ], -}; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter, withNavigationErrorHandler } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { + provideLoaderRegistry, + handleNavigationError, + provideSitecoreAngular, + PreLoaderDataService, + SITECORE_COMPONENT_MAP, +} from '@sitecore-content-sdk/angular'; +import { routes } from './app.routes'; +import scConfig from '../../sitecore.config'; +import { getClient } from '../content-sdk/client/sitecore-client'; +import { LOADERS } from '../content-sdk/loaders'; +import { componentMap } from '.sitecore/component-map'; + +/** + * Client hydration is disabled so that RouterLink and other directives attach correctly + * after bootstrap. With provideClientHydration(), server-rendered DOM is reused and + * directive event listeners (e.g. RouterLink click) can fail to attach. Without + * hydration, the client re-renders the app and routing works as expected. + */ +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(withFetch()), + provideRouter(routes, withNavigationErrorHandler(handleNavigationError())), + provideSitecoreAngular({ + notFoundRoute: '/404', + errorRoute: '/500', + sitecoreConfig: scConfig, + sitecoreClient: getClient(), + }), + provideLoaderRegistry(LOADERS), + PreLoaderDataService, + { provide: SITECORE_COMPONENT_MAP, useValue: componentMap }, + ], +}; From 9a3e5a69185e0b7fa3d9c7c03d552986990e743d Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Tue, 26 May 2026 11:27:33 -0400 Subject: [PATCH 06/14] phase 2.part1 of isr like --- packages/angular/ng-package.json | 3 +- packages/angular/package.json | 3 +- .../server/cache/cache-admin-middleware.ts | 17 +- .../angular/src/server/cache/cache-key.ts | 8 +- .../angular/src/server/cache/loader-cache.ts | 69 +++-- packages/angular/src/server/cache/models.ts | 40 +-- .../server/cache/unstorage-loader-cache.ts | 136 +++++++++ .../src/templates/angular/.env.example | 4 + .../src/templates/angular/.gitignore | 3 + .../src/templates/angular/package.json | 1 + .../src/app/admin/cache-demo.component.ts | 267 ++++++++++++++++++ .../templates/angular/src/app/app.routes.ts | 5 + .../src/templates/angular/src/server.ts | 45 ++- 13 files changed, 532 insertions(+), 69 deletions(-) create mode 100644 packages/angular/src/server/cache/unstorage-loader-cache.ts create mode 100644 packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index 84caa32f06..af28d213e0 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -9,6 +9,7 @@ "@sitecore-content-sdk/content", "@angular/common", "@angular/core", - "@angular/router" + "@angular/router", + "unstorage" ] } diff --git a/packages/angular/package.json b/packages/angular/package.json index 9ccc11ca92..49c17ac692 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -60,7 +60,8 @@ "dependencies": { "@sitecore-content-sdk/content": "^2.1.0", "@sitecore-content-sdk/core": "^2.1.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "unstorage": "^1.17.5" }, "devDependencies": { "@angular/build": "^21.1.4", diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/cache-admin-middleware.ts index 4437edcc99..d0d6ff1ddb 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.ts @@ -1,10 +1,5 @@ -import { - ExpressMiddleware, - ExpressNextFunction, - ExpressRequest, - ExpressResponse, -} from '../models'; -import { InvalidateFilter, LoaderCache } from './models'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { InvalidateInput, LoaderCache } from './models'; /** * Options for the admin middleware. @@ -27,7 +22,7 @@ const DEFAULT_ENDPOINT = '/api/_cache'; /** * Lightweight admin surface for the loader cache: * GET <endpoint>/entries → list entries (metadata only, no values) - * POST <endpoint>/invalidate → invalidate by InvalidateFilter (JSON body) + * POST <endpoint>/invalidate → invalidate by InvalidateInput (JSON body) * POST <endpoint>/flush → flush every entry * GET <endpoint>/config → resolved config (for the demo UI) * @@ -63,17 +58,17 @@ export function createCacheAdminMiddleware( } if (action === 'config' && req.method === 'GET') { - res.status(200).json(cache.getConfig()); + res.status(200).json({ ...cache.getConfig() }); return; } if (action === 'invalidate' && req.method === 'POST') { - const body = (req.body ?? {}) as Partial<InvalidateFilter>; + const body = (req.body ?? {}) as Partial<InvalidateInput>; if (!body.route || typeof body.route !== 'string') { res.status(400).json({ error: 'route is required' }); return; } - const deleted = await cache.invalidate(body as InvalidateFilter); + const deleted = await cache.invalidate(body as InvalidateInput); res.status(200).json({ deleted }); return; } diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index 0395da2f1e..6c7bf885fc 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -1,5 +1,5 @@ import type { LoaderContext } from '../../loaders/models'; -import { CacheKeyDimensions, dimensionsFromContext, InvalidateFilter } from './models'; +import { CacheKeyDimensions, dimensionsFromContext, InvalidateInput } from './models'; const KEY_PREFIX = 'loader'; @@ -49,11 +49,7 @@ export function buildTags(d: CacheKeyDimensions, namespace?: string): string[] { * Omitted dimensions widen to "all" (no tag constraint on that axis); * `site` defaults to `defaultSiteName` unless explicitly '*'. */ -export function filterToRequiredTags( - filter: InvalidateFilter, - defaultSiteName: string, - namespace?: string -): string[] { +export function filterToRequiredTags(filter: InvalidateInput, namespace?: string): string[] { const ns = namespace ? `${escapeSegment(namespace)}:` : ''; const required: string[] = []; diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index bc60f827e6..d94e2bb9f9 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,5 +1,6 @@ +import { createStorage } from 'unstorage'; import { - InvalidateFilter, + InvalidateInput, LoaderCache, LoaderCacheConfig, LoaderCacheEntry, @@ -7,27 +8,42 @@ import { LoaderCacheLoaderConfig, } from './models'; import { filterToRequiredTags } from './cache-key'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; const DEFAULT_TTL_SECONDS = 300; -interface ResolvedConfig { +/** + * Resolved (fully defaulted) config used by every {@link LoaderCache} + * implementation. Exported as `@internal` so sibling impls can share the same + * shape and helper. + * @internal + */ +export interface ResolvedConfig { namespace: string; - defaultTtl: number | 'infinite'; + revalidate: number; enabled: boolean; loaders: Record<string, LoaderCacheLoaderConfig>; defaultSiteName: string; } /** - * Public factory for the v1 in-memory loader cache. Returns a {@link LoaderCache} - * backed by an internal {@link InMemoryLoaderCache} class. + * Public factory for the loader cache. Dispatches to the right backend: * - * The class is intentionally not exported: callers should depend on the - * {@link LoaderCache} interface, so we can swap the implementation (unstorage, - * Redis, etc.) without touching public types. See plan §4.3. + * - `config.storage` → {@link UnstorageLoaderCache} using that Storage + * - `config.driver` → {@link UnstorageLoaderCache} wrapping the driver + * in `createStorage({ driver })` + * - otherwise → {@link InMemoryLoaderCache} (plain Map) + * + * Callers depend on the {@link LoaderCache} interface; concrete classes are + * not exported, so we can swap implementations without touching public types. + * See plan §4.3. * @public */ export function createLoaderCache(config: LoaderCacheConfig = {}): LoaderCache { + const resolved = resolveConfig(config); + if (config.driver) { + return new UnstorageLoaderCache(createStorage(), resolved); + } return new InMemoryLoaderCache(config); } @@ -57,23 +73,17 @@ class InMemoryLoaderCache implements LoaderCache { return entry; } - async set( - key: string, - value: unknown, - ttlSeconds: number | 'infinite', - tags: string[] - ): Promise<void> { - const ttl = ttlSeconds === 'infinite' ? null : ttlSeconds; - const expiresAt = ttl === null ? null : this.now() + ttl * 1000; + async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + const expiresAt = ttlSeconds > 0 ? null : Date.now() + ttlSeconds * 1000; this.store.set(key, { value, tags: [...tags], - storedAt: this.now(), + storedAt: Date.now(), expiresAt, }); } - async invalidate(filter: InvalidateFilter): Promise<number> { + async invalidate(filter: InvalidateInput): Promise<number> { const required = filterToRequiredTags( filter, this.resolved.defaultSiteName, @@ -115,10 +125,10 @@ class InMemoryLoaderCache implements LoaderCache { return out; } - resolveTtl(loaderId: string): number | 'infinite' { + resolveTtl(loaderId: string): number { const perLoader = this.resolved.loaders[loaderId]; if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; - return this.resolved.defaultTtl; + return this.resolved.revalidate; } isEnabled(loaderId: string): boolean { @@ -132,25 +142,28 @@ class InMemoryLoaderCache implements LoaderCache { return this.resolved; } - private now(): number { - return Date.now(); - } - private isExpired(entry: LoaderCacheEntry): boolean { - return entry.expiresAt !== null && entry.expiresAt <= this.now(); + return entry.expiresAt !== null && entry.expiresAt <= Date.now(); } } -function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { +/** + * Build a {@link ResolvedConfig} from a {@link LoaderCacheConfig}. Shared by + * every backend so config semantics stay identical regardless of driver. + * @internal + */ +export function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { return { namespace: config.namespace ?? '', - defaultTtl: config.defaultTtl ?? DEFAULT_TTL_SECONDS, + revalidate: config.revalidate ?? DEFAULT_TTL_SECONDS, enabled: config.enabled ?? true, loaders: config.loaders ?? {}, - defaultSiteName: config.defaultSiteName ?? 'default', }; } +/** + * @deprecated only used for demo purposes. remove before release. + */ function approxByteSize(value: unknown): number { try { return JSON.stringify(value).length; diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index 177eff2058..b2b2d347c7 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -1,3 +1,4 @@ +import type { DriverFlags } from 'unstorage'; import type { LoaderContext } from '../../loaders/models'; /** @@ -14,12 +15,12 @@ export interface LoaderCacheEntry { /** * Per-loader config overrides. Most-specific wins over defaults from - * LoaderCacheConfig.defaultTtl / enabled. + * LoaderCacheConfig.revalidate / enabled. * @public */ export interface LoaderCacheLoaderConfig { enabled?: boolean; - ttl?: number | 'infinite'; + ttl?: number; /** Reserved for Phase 4 (variant-aware keying). Accepted but ignored in v1. */ personalize?: boolean; } @@ -32,28 +33,24 @@ export interface LoaderCacheConfig { /** unique app id used to scope keys when multiple apps share a store */ namespace?: string; /** default TTL in seconds; pass 'infinite' to never expire */ - defaultTtl?: number | 'infinite'; + revalidate?: number; /** master switch — set to false to make every call fall through to the raw loader */ enabled?: boolean; /** per-loader overrides keyed by loaderId */ loaders?: Record<string, LoaderCacheLoaderConfig>; - /** - * Site name used by `invalidate({ route })` when no `site` is supplied. - * Should match `scConfig.defaultSite`. Defaults to `'default'`. - */ - defaultSiteName?: string; + /** Unstorage `Driver` shorthand. The cache wraps it with `createStorage({ driver })` internally. Use this for single-driver setups (`fs`, `redis`, `memory`, etc). */ + driver?: DriverFlags; } /** - * Filter accepted by cache.invalidate(). `route` is required; other dimensions - * narrow when supplied. See the loader-cache plan doc, §6. + * Filter accepted by cache.invalidate(). `route` is required + * other fields are optional and will be used to narrow the invalidation. * @public */ -export interface InvalidateFilter { +export interface InvalidateInput { route: string; site?: string | '*'; language?: string; - /** accepted but ignored in v1 (every entry keys as variant:default) */ variantId?: string; loaderId?: string; } @@ -83,7 +80,7 @@ export interface LoaderCache { get(key: string): Promise<LoaderCacheEntry | null>; set(key: string, value: unknown, ttlSeconds: number | 'infinite', tags: string[]): Promise<void>; /** Per-path invalidation. Returns number of entries deleted. */ - invalidate(filter: InvalidateFilter): Promise<number>; + invalidate(filter: InvalidateInput): Promise<number>; /** Direct delete by exact key. */ delete(key: string): Promise<boolean>; /** Nuke every entry. */ @@ -93,7 +90,11 @@ export interface LoaderCache { resolveTtl(loaderId: string): number | 'infinite'; isEnabled(loaderId: string): boolean; /** Reads back the resolved config (useful for admin UI). */ - getConfig(): Readonly<Required<Omit<LoaderCacheConfig, 'loaders'>> & { loaders: Record<string, LoaderCacheLoaderConfig> }>; + getConfig(): Readonly< + Required<Omit<LoaderCacheConfig, 'loaders' | 'storage' | 'driver'>> & { + loaders: Record<string, LoaderCacheLoaderConfig>; + } + >; } /** @@ -103,7 +104,7 @@ export interface LoaderCache { export interface CacheKeyDimensions { site: string; language: string; - variantId: string; // always 'default' in v1 + variantId: string; loaderId: string; route: string; paramsHash: string; @@ -116,7 +117,10 @@ export interface CacheKeyDimensions { */ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { const params = (ctx.params ?? {}) as Record<string, unknown>; - const headers = (ctx.requestContext?.headers ?? {}) as Record<string, string | string[] | undefined>; + const headers = (ctx.requestContext?.headers ?? {}) as Record< + string, + string | string[] | undefined + >; const site = (typeof params['site'] === 'string' && (params['site'] as string)) || @@ -172,7 +176,9 @@ function canonicalStringify(value: unknown): string { return ( '{' + keys - .map((k) => JSON.stringify(k) + ':' + canonicalStringify((value as Record<string, unknown>)[k])) + .map( + (k) => JSON.stringify(k) + ':' + canonicalStringify((value as Record<string, unknown>)[k]) + ) .join(',') + '}' ); diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts new file mode 100644 index 0000000000..af1ae735f7 --- /dev/null +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -0,0 +1,136 @@ +import type { Storage } from 'unstorage'; +import { InvalidateInput, LoaderCache, LoaderCacheEntry, LoaderCacheEntryInfo } from './models'; +import { filterToRequiredTags } from './cache-key'; +import type { ResolvedConfig } from './loader-cache'; + +/** + * Unstorage-backed {@link LoaderCache}. Pluggable across `unstorage` drivers — + * `memory` for dev, `fs` / `fsLite` for single-process persistence, `redis` / + * `vercelKv` / `cloudflareKv` for multi-worker deployments. + * + * Entries are stored under the same composite key built by + * {@link buildCacheKey} so prerendered, runtime, and admin-CLI writes collide + * by design (the cross-process consistency promised in plan §4.3). + * + * Invalidation walks every key under the cache prefix and reads each entry's + * tags — O(N) over the cache size. Acceptable up to thousands of entries; a + * driver-native tag index (Redis `SADD`, etc.) is a Phase 2b optimization. + * @internal + */ +export class UnstorageLoaderCache implements LoaderCache { + readonly backend = 'unstorage' as const; + private readonly storage: Storage; + private readonly resolved: ResolvedConfig; + /** Prefix passed to `storage.getKeys()` / `storage.clear()` for scoped scans. */ + private readonly keyPrefix: string; + + constructor(storage: Storage, resolved: ResolvedConfig) { + this.storage = storage; + this.resolved = resolved; + // Mirrors the serializeKey() prefix in cache-key.ts so getKeys() returns + // only this cache's entries — never anything else the user stores in the + // same Storage instance. + this.keyPrefix = resolved.namespace ? `loader:${resolved.namespace}:` : 'loader:'; + } + + async get(key: string): Promise<LoaderCacheEntry | null> { + const entry = await this.storage.getItem<LoaderCacheEntry>(key); + if (!entry) return null; + if (this.isExpired(entry)) { + await this.storage.removeItem(key); + return null; + } + return entry; + } + + async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; + const entry: LoaderCacheEntry = { + value, + tags: [...tags], + storedAt: Date.now(), + expiresAt, + }; + await this.storage.setItem(key, entry); + } + + async invalidate(filter: InvalidateInput): Promise<number> { + const required = filterToRequiredTags( + filter, + this.resolved.defaultSiteName, + this.resolved.namespace + ); + const keys = await this.storage.getKeys(this.keyPrefix); + let deleted = 0; + for (const key of keys) { + const entry = await this.storage.getItem<LoaderCacheEntry>(key); + if (!entry) continue; + if (required.every((tag) => entry.tags.includes(tag))) { + await this.storage.removeItem(key); + deleted++; + } + } + return deleted; + } + + async delete(key: string): Promise<boolean> { + const had = await this.storage.hasItem(key); + if (!had) return false; + await this.storage.removeItem(key); + return true; + } + + async flush(): Promise<void> { + await this.storage.clear(this.keyPrefix); + } + + async entries(): Promise<LoaderCacheEntryInfo[]> { + const keys = await this.storage.getKeys(this.keyPrefix); + const out: LoaderCacheEntryInfo[] = []; + for (const key of keys) { + const entry = await this.storage.getItem<LoaderCacheEntry>(key); + if (!entry) continue; + if (this.isExpired(entry)) { + await this.storage.removeItem(key); + continue; + } + out.push({ + key, + tags: [...entry.tags], + storedAt: entry.storedAt, + expiresAt: entry.expiresAt, + approxBytes: approxByteSize(entry.value), + }); + } + return out; + } + + resolveTtl(loaderId: string): number { + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; + return this.resolved.revalidate; + } + + isEnabled(loaderId: string): boolean { + if (!this.resolved.enabled) return false; + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.enabled === false) return false; + return true; + } + + getConfig(): ResolvedConfig { + return this.resolved; + } + + private isExpired(entry: LoaderCacheEntry): boolean { + return entry.expiresAt !== null && entry.expiresAt <= Date.now(); + } +} + +function approxByteSize(value: unknown): number { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} diff --git a/packages/create-content-sdk-app/src/templates/angular/.env.example b/packages/create-content-sdk-app/src/templates/angular/.env.example index 7cfe651d9c..0012e3f719 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.env.example +++ b/packages/create-content-sdk-app/src/templates/angular/.env.example @@ -14,3 +14,7 @@ # Site / language # CSDK_PUBLIC_SITECORE_DEFAULT_SITE= # CSDK_PUBLIC_SITECORE_DEFAULT_LANGUAGE=en + +# Loader cache (server only; see src/server.ts) +# LOADER_CACHE_DRIVER=unstorage-memory +# LOADER_CACHE_DRIVER=unstorage-fs diff --git a/packages/create-content-sdk-app/src/templates/angular/.gitignore b/packages/create-content-sdk-app/src/templates/angular/.gitignore index 31c0fcbe27..28ea6991ce 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.gitignore +++ b/packages/create-content-sdk-app/src/templates/angular/.gitignore @@ -37,6 +37,9 @@ yarn-error.log .env.prod .env.local +# Loader cache (unstorage fs driver) +.cache/ + # Miscellaneous /.angular/cache .sass-cache/ diff --git a/packages/create-content-sdk-app/src/templates/angular/package.json b/packages/create-content-sdk-app/src/templates/angular/package.json index 54e604dcbb..402103abba 100644 --- a/packages/create-content-sdk-app/src/templates/angular/package.json +++ b/packages/create-content-sdk-app/src/templates/angular/package.json @@ -48,6 +48,7 @@ "dotenv": "^16.5.0", "express": "^5.1.0", "rxjs": "~7.8.0", + "unstorage": "^1.17.5", "tailwind-bootstrap-grid": "^6.0.0", "tslib": "^2.3.0" }, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts new file mode 100644 index 0000000000..5406528860 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts @@ -0,0 +1,267 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +interface AdminEntry { + key: string; + tags: string[]; + storedAt: number; + expiresAt: number | null; + approxBytes: number; +} + +interface EntriesResponse { + entries: AdminEntry[]; + now: number; +} + +interface ConfigResponse { + namespace: string; + revalidate: number; + enabled: boolean; + loaders: Record<string, unknown>; + defaultSiteName: string; + backend: 'memory' | 'unstorage'; +} + +const ADMIN_BASE = '/api/_cache'; + +/** + * Demo page that lists every loader-cache entry held by the running server and + * lets you invalidate them per-path or flush the whole cache. + * + * Hits the admin endpoints exposed by createCacheAdminMiddleware() in + * server.ts. Plan: llm-wiki/wiki/plans/doc-loader-cache-plan.md + */ +@Component({ + selector: 'app-cache-demo', + standalone: true, + imports: [DatePipe, DecimalPipe, FormsModule], + template: ` + <section class="cache-demo"> + <header> + <h1>Loader cache</h1> + @if (config(); as c) { + <p class="backend"> + backend: <strong>{{ c.backend }}</strong> + · revalidate: <strong>{{ c.revalidate }}</strong>s + · defaultSite: <strong>{{ c.defaultSiteName }}</strong> + @if (c.namespace) { + · namespace: <strong>{{ c.namespace }}</strong> + } + </p> + } + <p class="hint"> + Lists entries written by SSR + /_data middleware. Navigate to any page in another tab, + then click <em>Refresh</em> to see them appear. Invalidate to evict. + </p> + </header> + + <div class="toolbar"> + <button type="button" (click)="refresh()" [disabled]="loading()">Refresh</button> + <button type="button" (click)="flush()" [disabled]="loading() || entries().length === 0"> + Flush all + </button> + @if (lastMessage()) { + <span class="status">{{ lastMessage() }}</span> + } + </div> + + <form class="invalidate" (submit)="invalidate($event)"> + <fieldset> + <legend>Invalidate by path</legend> + <label> + Route + <input type="text" name="route" [(ngModel)]="invalidateRoute" placeholder="/about" required /> + </label> + <label> + Site (optional) + <input type="text" name="site" [(ngModel)]="invalidateSite" placeholder="default" /> + </label> + <label> + Language (optional) + <input type="text" name="language" [(ngModel)]="invalidateLanguage" placeholder="en" /> + </label> + <label> + Loader (optional) + <input type="text" name="loaderId" [(ngModel)]="invalidateLoaderId" placeholder="page" /> + </label> + <button type="submit" [disabled]="loading() || !invalidateRoute.trim()">Invalidate</button> + </fieldset> + </form> + + @if (!loading() && entries().length > 0) { + <div class="meta"> + Total entries: <strong>{{ entries().length }}</strong> + · total size: <strong>{{ totalBytes() | number }} bytes</strong> + </div> + } + + @if (loading()) { + <div class="loading">Loading…</div> + } @else if (entries().length === 0) { + <div class="empty">No cache entries yet. Visit a page first.</div> + } @else { + <ul class="entries"> + @for (entry of entries(); track entry.key) { + <li> + <div class="key">{{ entry.key }}</div> + <div class="tags"> + @for (t of entry.tags; track t) { + <span class="tag">{{ t }}</span> + } + </div> + <div class="meta-row"> + <span>stored: {{ entry.storedAt | date: 'medium' }}</span> + @if (entry.expiresAt) { + <span>expires: {{ entry.expiresAt | date: 'medium' }}</span> + } @else { + <span>expires: never</span> + } + <span>size: {{ entry.approxBytes | number }} B</span> + </div> + <div class="actions"> + <button type="button" (click)="deleteByTagMatch(entry)" [disabled]="loading()"> + Invalidate this route + </button> + </div> + </li> + } + </ul> + } + </section> + `, + styles: [ + ` + .cache-demo { padding: 1.5rem; font-family: ui-sans-serif, system-ui, sans-serif; } + .backend { color: #444; font-size: .9rem; margin-top: .25rem; } + .hint { color: #666; max-width: 60ch; } + .toolbar { display: flex; gap: .5rem; align-items: center; margin: 1rem 0; } + .toolbar .status { color: #2a7; font-size: .9rem; } + .invalidate fieldset { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; } + .invalidate label { display: flex; flex-direction: column; font-size: .85rem; color: #444; } + .invalidate input { padding: .25rem .5rem; min-width: 8rem; } + .meta { color: #666; margin: .75rem 0; font-size: .9rem; } + .loading, .empty { color: #888; padding: 1rem 0; } + .entries { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .75rem; } + .entries li { border: 1px solid #ddd; padding: .75rem; border-radius: 6px; background: #fafafa; } + .key { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; word-break: break-all; } + .tags { margin: .35rem 0; display: flex; flex-wrap: wrap; gap: .25rem; } + .tag { font-size: .7rem; background: #eef; padding: .1rem .4rem; border-radius: 3px; font-family: ui-monospace, Consolas, monospace; } + .meta-row { display: flex; gap: 1rem; font-size: .8rem; color: #555; margin-top: .25rem; } + .actions { margin-top: .5rem; } + button { padding: .35rem .75rem; cursor: pointer; } + button:disabled { opacity: .5; cursor: not-allowed; } + `, + ], +}) +export class CacheDemoComponent { + private http = inject(HttpClient); + + readonly entries = signal<AdminEntry[]>([]); + readonly config = signal<ConfigResponse | null>(null); + readonly loading = signal(false); + readonly lastMessage = signal<string>(''); + readonly totalBytes = computed(() => + this.entries().reduce((sum, e) => sum + e.approxBytes, 0) + ); + + invalidateRoute = ''; + invalidateSite = ''; + invalidateLanguage = ''; + invalidateLoaderId = ''; + + constructor() { + this.refresh(); + } + + async refresh(): Promise<void> { + this.loading.set(true); + try { + const [entries, config] = await Promise.all([ + firstValueFrom(this.http.get<EntriesResponse>(`${ADMIN_BASE}/entries`)), + firstValueFrom(this.http.get<ConfigResponse>(`${ADMIN_BASE}/config`)), + ]); + this.entries.set(entries.entries); + this.config.set(config); + this.lastMessage.set(''); + } catch (err) { + this.lastMessage.set(`Refresh failed: ${(err as Error).message}`); + } finally { + this.loading.set(false); + } + } + + async flush(): Promise<void> { + if (!confirm('Flush every cache entry?')) return; + this.loading.set(true); + try { + await firstValueFrom(this.http.post(`${ADMIN_BASE}/flush`, {})); + this.lastMessage.set('Flushed.'); + await this.refresh(); + } finally { + this.loading.set(false); + } + } + + async invalidate(event: Event): Promise<void> { + event.preventDefault(); + const route = this.invalidateRoute.trim(); + if (!route) return; + + const body: Record<string, string> = { route }; + if (this.invalidateSite.trim()) body['site'] = this.invalidateSite.trim(); + if (this.invalidateLanguage.trim()) body['language'] = this.invalidateLanguage.trim(); + if (this.invalidateLoaderId.trim()) body['loaderId'] = this.invalidateLoaderId.trim(); + + this.loading.set(true); + try { + const resp = await firstValueFrom( + this.http.post<{ deleted: number }>(`${ADMIN_BASE}/invalidate`, body) + ); + this.lastMessage.set(`Invalidated ${resp.deleted} entr${resp.deleted === 1 ? 'y' : 'ies'}.`); + await this.refresh(); + } catch (err) { + this.lastMessage.set(`Invalidate failed: ${(err as Error).message}`); + } finally { + this.loading.set(false); + } + } + + /** + * Convenience: invalidate using the route + loader tags read off the row. + */ + async deleteByTagMatch(entry: AdminEntry): Promise<void> { + const route = readTag(entry.tags, 'route:'); + const site = readTag(entry.tags, 'site:'); + const language = readTag(entry.tags, 'language:'); + const loaderId = readTag(entry.tags, 'loader:'); + if (!route) { + this.lastMessage.set('Entry has no route tag.'); + return; + } + + const body: Record<string, string> = { route }; + if (site) body['site'] = site; + if (language) body['language'] = language; + if (loaderId) body['loaderId'] = loaderId; + + this.loading.set(true); + try { + const resp = await firstValueFrom( + this.http.post<{ deleted: number }>(`${ADMIN_BASE}/invalidate`, body) + ); + this.lastMessage.set(`Invalidated ${resp.deleted} entr${resp.deleted === 1 ? 'y' : 'ies'}.`); + await this.refresh(); + } finally { + this.loading.set(false); + } + } +} + +function readTag(tags: string[], prefix: string): string | undefined { + const t = tags.find((x) => x.startsWith(prefix)); + return t ? decodeURIComponent(t.slice(prefix.length)) : undefined; +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts index 37d2f55da4..ab9ba68c91 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts @@ -3,11 +3,16 @@ import { loaderResolver } from '@sitecore-content-sdk/angular'; import { PageComponent } from './pages/page.component'; import { NotFoundComponent } from './pages/not-found.component'; import { ErrorComponent } from './pages/error.component'; +import { CacheDemoComponent } from './admin/cache-demo.component'; export const routes: Routes = [ { path: '', children: [ + { + path: 'admin/cache', + component: CacheDemoComponent, + }, { path: '500', component: ErrorComponent, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 45909e85cb..8b6d39b0dd 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -7,7 +7,13 @@ import { } from '@angular/ssr/node'; import express from 'express'; import { join } from 'node:path'; -import { createLoaderDataServiceMiddleware } from '@sitecore-content-sdk/angular'; +import fsDriver from 'unstorage/drivers/fs'; +import memoryDriver from 'unstorage/drivers/memory'; +import { + createCacheAdminMiddleware, + createLoaderCache, + createLoaderDataServiceMiddleware, +} from '@sitecore-content-sdk/angular'; import { LOADERS } from './content-sdk/loaders'; const browserDistFolder = join(import.meta.dirname, '../browser'); @@ -15,12 +21,40 @@ const browserDistFolder = join(import.meta.dirname, '../browser'); const app = express(); const angularApp = new AngularNodeAppEngine(); +/** + * Loader cache driver selection (server only). + * + * LOADER_CACHE_DRIVER unset → in-memory Map (default) + * LOADER_CACHE_DRIVER=unstorage-memory → unstorage with memory driver + * LOADER_CACHE_DRIVER=unstorage-fs → unstorage with fs driver (persists) + * + * The fs driver writes to `./.cache/loaders/<key>.json`, surviving process restarts. + */ +const driverChoice = process.env.LOADER_CACHE_DRIVER; +const driver = + driverChoice === 'unstorage-fs' + ? fsDriver({ base: './.cache/loaders' }) + : driverChoice === 'unstorage-memory' + ? memoryDriver() + : undefined; + +const loaderCache = createLoaderCache({ + revalidate: 300, + defaultSiteName: 'localhost', + ...(driver ? { driver } : {}), +}); + +app.use(express.json()); + +/** Admin endpoints for cache inspection and invalidation (see `/api/_cache`). */ +app.use(createCacheAdminMiddleware({ cache: loaderCache, endpoint: '/api/_cache' })); + /** * Loader data endpoint (/_data). Must use the same loaders as the client registry * so client-side navigation can fetch route data via POST /_data. */ -app.use(express.json()); -app.use(createLoaderDataServiceMiddleware({ loaders: LOADERS })); +app.use(createLoaderDataServiceMiddleware({ loaders: LOADERS, cache: loaderCache })); + /** * Serve static files from /browser */ @@ -34,11 +68,12 @@ app.use( /** * Handle all other requests by rendering the Angular application. - * Catches ExternalRedirectError from loaders so server-side external redirects send HTTP 302. + * The cache reference rides on REQUEST_CONTEXT so the SSR loader resolver + * picks it up via inject(REQUEST_CONTEXT). */ app.use((req, res, next) => { angularApp - .handle(req) + .handle(req, { cache: loaderCache }) .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) .catch((err) => { next(err); From ed84be03a0b7de0df33884555dfad248bc76a543 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Tue, 26 May 2026 17:06:25 -0400 Subject: [PATCH 07/14] refactor midway --- packages/angular/src/config/define-config.ts | 13 ++ .../angular/src/loaders/loader-resolver.ts | 19 +- packages/angular/src/loaders/models.ts | 83 ++++++++ .../server/cache/cache-admin-middleware.ts | 7 +- .../angular/src/server/cache/cache-key.ts | 74 ++++--- .../server/cache/default-in-memory-cache.ts | 100 ++++++++++ packages/angular/src/server/cache/index.ts | 2 +- .../angular/src/server/cache/loader-cache.ts | 155 +-------------- packages/angular/src/server/cache/models.ts | 187 +----------------- .../src/server/cache/resolve-loader-data.ts | 16 +- .../server/cache/unstorage-loader-cache.ts | 26 +-- packages/angular/src/server/cache/utils.ts | 50 +++++ .../server/loader-data-service-middleware.ts | 17 +- 13 files changed, 331 insertions(+), 418 deletions(-) create mode 100644 packages/angular/src/server/cache/default-in-memory-cache.ts create mode 100644 packages/angular/src/server/cache/utils.ts diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index 7212b528f7..72e0328252 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -32,6 +32,19 @@ export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'r * `defaultLanguage` is prepended automatically when absent. */ locales?: string[]; + /** + * Configuration for the ISR-like cache. + */ + isrCache: { + /** + * Whether the cache is enabled. + */ + enabled: boolean; + /** + * The global revalidate time in seconds. + */ + revalidate: number; + }; }; } diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 16acf9445e..eedd424b28 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -23,12 +23,13 @@ import { DEFAULT_NOT_FOUND_ROUTE, LoaderHttpError, NotFoundNavigationError, + LoaderCache, + LoaderCacheConfig, } from './models'; import { redirectOnNavigationError } from './router-error-handling'; import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN } from '../lib/tokens'; import { resolveLoaderData } from '../server/cache/resolve-loader-data'; -import type { LoaderCache } from '../server/cache/models'; - +import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; /** * Create a state key for the loader * @param {string} loaderId - The loader ID @@ -121,11 +122,14 @@ async function resolveOnBrowser( return resp.data; } -export const loaderResolver = (loaderId: LoaderId): ResolveFn<unknown> => { +export const loaderResolver = ( + loaderId: LoaderId, + cacheOptions?: LoaderCacheConfig +): ResolveFn<unknown> => { const resolver = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const transferState = inject(TransferState); const platformId = inject(PLATFORM_ID); - const registry = inject(LOADER_REGISTRY); + const loaderRegistry = inject(LOADER_REGISTRY); const request = inject(REQUEST, { optional: true }); const notFoundRoute = inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE; @@ -145,7 +149,7 @@ export const loaderResolver = (loaderId: LoaderId): ResolveFn<unknown> => { } } - const loader = registry[loaderId]; + const loader = loaderRegistry[loaderId]; if (!loader) { throw new Error(`No loader registered for id "${loaderId}"`); @@ -163,9 +167,10 @@ export const loaderResolver = (loaderId: LoaderId): ResolveFn<unknown> => { params: route.params, query: route.queryParams as Record<string, string | string[]>, }, - registry, + loaderRegistry, cache, - requestContext + requestContext, + cacheOptions ); if (result.kind === 'redirect') { diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index 860ee981f7..5af1c2ff74 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -131,3 +131,86 @@ export class LoaderHttpError extends Error { super(message); } } + +/** + * Base config for loader cache. Can be applied per loader. + * @public + */ +export interface LoaderCacheConfig { + /** default TTL in seconds; pass 'infinite' to never expire */ + revalidate?: number; + /** master switch — set to false to make every call fall through to the raw loader */ + enabled?: boolean; +} + +/** + * Metadata returned by cache.entries() — sufficient for an admin UI without + * shipping the cached values themselves (which can be large). + * @public + */ +export interface LoaderCacheEntryInfo { + key: string; + tags: string[]; + storedAt: number; + expiresAt: number | null; +} + +/** + * Persisted cache entry shape. Stored under the composite cache key built by + * buildCacheKey(); see cache-key.ts. + * @public + */ +export interface LoaderCacheEntry { + value: unknown; + tags: string[]; + storedAt: number; + expiresAt: number | null; // null = never expire +} + +/** + * Filter accepted by cache.invalidate(). `route` is required + * other fields are optional and will be used to narrow the invalidation. + * @public + */ +export interface InvalidateInput { + route: string; + site?: string | '*'; + language?: string; + variantId?: string; + loaderId?: string; +} + +/** + * Global config for the loader cache. + * @public + */ +export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { + /** + * unstorage driver for the cache. Default in-memory cache is used when empty. + */ + driver?: 'memory' | 'redis' | 'fs'; +} + +/** + * Server-only cache instance. Constructed once in server.ts via + * createLoaderCache() and passed by reference to the middleware factories + * (`createLoaderDataServiceMiddleware`, `createCacheAdminMiddleware`) and to + * Angular SSR through `angularApp.handle(req, { cache })`. + * @public + */ +export interface LoaderCache { + get(key: string): Promise<LoaderCacheEntry | null>; + set(key: string, value: unknown, ttlSeconds: number | 'infinite', tags: string[]): Promise<void>; + /** Per-path invalidation. Returns number of entries deleted. */ + invalidate(filter: InvalidateInput): Promise<number>; + /** Direct delete by exact key. */ + delete(key: string): Promise<boolean>; + /** Nuke every entry. */ + flush(): Promise<void>; + /** Returns lightweight metadata for every live entry — used by admin tooling. */ + entries(): Promise<LoaderCacheEntryInfo[]>; + resolveTtl(loaderId: string): number | 'infinite'; + isEnabled(loaderId: string): boolean; + /** Reads back the resolved config (useful for admin UI). */ + getConfig(): Readonly<LoaderCacheConfig>; +} diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/cache-admin-middleware.ts index d0d6ff1ddb..ed0e79adeb 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.ts @@ -1,5 +1,10 @@ +/* eslint-disable */ +/** + * This middleware is only used for testing and should be removed before release. + * TODO: Remove this middleware before release. + */ import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; -import { InvalidateInput, LoaderCache } from './models'; +import { InvalidateInput, LoaderCache } from '../../loaders/models'; /** * Options for the admin middleware. diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index 6c7bf885fc..f32bab75f0 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -1,7 +1,9 @@ import type { LoaderContext } from '../../loaders/models'; -import { CacheKeyDimensions, dimensionsFromContext, InvalidateInput } from './models'; +import { CacheKeyDimensions } from './models'; +import { dimensionsFromContext } from './utils'; +import { InvalidateInput } from '../../loaders/models'; -const KEY_PREFIX = 'loader'; +const CACHE_KEY_PREFIX = 'scLoader'; /** * Compose the canonical cache key. @@ -9,38 +11,35 @@ const KEY_PREFIX = 'loader'; */ export function buildCacheKey( loaderId: string, - ctx: LoaderContext, - namespace?: string + ctx: LoaderContext ): { key: string; dimensions: CacheKeyDimensions } { - const dims = dimensionsFromContext(loaderId, ctx); - const key = serializeKey(dims, namespace); - return { key, dimensions: dims }; + const dimensions = dimensionsFromContext(loaderId, ctx); + const key = serializeKey(dimensions); + return { key, dimensions }; } -export function serializeKey(d: CacheKeyDimensions, namespace?: string): string { - const ns = namespace ? `:${escapeSegment(namespace)}` : ''; +export function serializeKey(dimensions: CacheKeyDimensions): string { return [ - KEY_PREFIX + ns, - escapeSegment(d.site), - escapeSegment(d.language), - escapeSegment(d.variantId), - escapeSegment(d.loaderId), - escapeSegment(d.route), - d.paramsHash, + CACHE_KEY_PREFIX, + encodeURIComponent(dimensions.site), + encodeURIComponent(dimensions.locale), + encodeURIComponent(dimensions.variantId), + encodeURIComponent(dimensions.loaderId), + encodeURIComponent(dimensions.route), + ...(dimensions.customTags ?? []), ].join(':'); } /** * Tag list mirrored alongside each entry — used by invalidate() to find matching keys. */ -export function buildTags(d: CacheKeyDimensions, namespace?: string): string[] { - const ns = namespace ? `${escapeSegment(namespace)}:` : ''; +export function buildDefaultTags(dimensions: CacheKeyDimensions): string[] { return [ - `${ns}site:${escapeSegment(d.site)}`, - `${ns}language:${escapeSegment(d.language)}`, - `${ns}variant:${escapeSegment(d.variantId)}`, - `${ns}loader:${escapeSegment(d.loaderId)}`, - `${ns}route:${escapeSegment(d.route)}`, + `site:${encodeURIComponent(dimensions.site)}`, + `language:${encodeURIComponent(dimensions.locale)}`, + `variant:${encodeURIComponent(dimensions.variantId)}`, + `loader:${encodeURIComponent(dimensions.loaderId)}`, + `route:${encodeURIComponent(dimensions.route)}`, ]; } @@ -49,25 +48,18 @@ export function buildTags(d: CacheKeyDimensions, namespace?: string): string[] { * Omitted dimensions widen to "all" (no tag constraint on that axis); * `site` defaults to `defaultSiteName` unless explicitly '*'. */ -export function filterToRequiredTags(filter: InvalidateInput, namespace?: string): string[] { - const ns = namespace ? `${escapeSegment(namespace)}:` : ''; - const required: string[] = []; +export function resolveTagsToInvalidate( + filter: InvalidateInput, + defaultSiteName: string +): string[] { + const tags: string[] = []; const site = filter.site === '*' ? null : filter.site ?? defaultSiteName; - if (site) required.push(`${ns}site:${escapeSegment(site)}`); - if (filter.language) required.push(`${ns}language:${escapeSegment(filter.language)}`); - if (filter.variantId) required.push(`${ns}variant:${escapeSegment(filter.variantId)}`); - if (filter.loaderId) required.push(`${ns}loader:${escapeSegment(filter.loaderId)}`); + if (site) tags.push(`site:${encodeURIComponent(site)}`); + if (filter.language) tags.push(`language:${encodeURIComponent(filter.language)}`); + if (filter.variantId) tags.push(`variant:${encodeURIComponent(filter.variantId)}`); + if (filter.loaderId) tags.push(`loader:${encodeURIComponent(filter.loaderId)}`); - required.push(`${ns}route:${escapeSegment(filter.route)}`); - return required; -} - -/** - * Segments are delimited by ':' in the key; any ':' in a segment value would - * collide. URL-encode the colon (and a few other unsafe chars) to keep parsing - * trivial. - */ -function escapeSegment(s: string): string { - return s.replace(/[:%]/g, (c) => encodeURIComponent(c)); + tags.push(`route:${encodeURIComponent(filter.route)}`); + return tags; } diff --git a/packages/angular/src/server/cache/default-in-memory-cache.ts b/packages/angular/src/server/cache/default-in-memory-cache.ts new file mode 100644 index 0000000000..48a32d5ad3 --- /dev/null +++ b/packages/angular/src/server/cache/default-in-memory-cache.ts @@ -0,0 +1,100 @@ +import { resolveTagsToInvalidate } from './cache-key'; +import { GlobalLoaderCacheConfig, LoaderCache, LoaderCacheEntry } from '../../loaders/models'; +import { resolveConfig } from './utils'; +import { ResolvedConfig } from './models'; +import { InvalidateInput, LoaderCacheEntryInfo } from '../../loaders/models'; + +/** + * Default LoaderCache implementation: single in-process Map, O(N) tag-scan + * invalidation. Suitable for single-process deployments and demos. + * + * Not exported. Driver variants (unstorage memory/fs/redis) live in sibling + * classes that implement the same {@link LoaderCache} interface. + * @internal + */ +export class InMemoryLoaderCache implements LoaderCache { + private readonly resolved: ResolvedConfig; + private readonly store = new Map<string, LoaderCacheEntry>(); + + constructor(config: GlobalLoaderCacheConfig) { + this.resolved = resolveConfig(config); + } + + async get(key: string): Promise<LoaderCacheEntry | null> { + const entry = this.store.get(key); + if (!entry) return null; + if (this.isExpired(entry)) { + this.store.delete(key); + return null; + } + return entry; + } + + async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + const expiresAt = ttlSeconds > 0 ? null : Date.now() + ttlSeconds * 1000; + this.store.set(key, { + value, + tags: [...tags], + storedAt: Date.now(), + expiresAt, + }); + } + + async invalidate(filter: InvalidateInput): Promise<number> { + const required = resolveTagsToInvalidate(filter); + let deleted = 0; + for (const [key, entry] of this.store) { + if (required.every((tag) => entry.tags.includes(tag))) { + this.store.delete(key); + deleted++; + } + } + return deleted; + } + + async delete(key: string): Promise<boolean> { + return this.store.delete(key); + } + + async flush(): Promise<void> { + this.store.clear(); + } + + async entries(): Promise<LoaderCacheEntryInfo[]> { + const out: LoaderCacheEntryInfo[] = []; + for (const [key, entry] of this.store) { + if (this.isExpired(entry)) { + this.store.delete(key); + continue; + } + out.push({ + key, + tags: [...entry.tags], + storedAt: entry.storedAt, + expiresAt: entry.expiresAt, + }); + } + return out; + } + + resolveTtl(loaderId: string): number { + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; + return this.resolved.revalidate; + } + + isEnabled(loaderId: string): boolean { + if (!this.resolved.enabled) return false; + const perLoader = this.resolved.loaders[loaderId]; + if (perLoader && perLoader.enabled === false) return false; + return true; + } + + getConfig(): ResolvedConfig { + return this.resolved; + } + + private isExpired(entry: LoaderCacheEntry): boolean { + return entry.expiresAt !== null && entry.expiresAt <= Date.now(); + } +} diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts index a12446a38f..39e746ac89 100644 --- a/packages/angular/src/server/cache/index.ts +++ b/packages/angular/src/server/cache/index.ts @@ -13,5 +13,5 @@ export { createCacheAdminMiddleware, type CacheAdminMiddlewareOptions, } from './cache-admin-middleware'; -export { buildCacheKey, buildTags, filterToRequiredTags, serializeKey } from './cache-key'; +export { buildCacheKey, buildDefaultTags, filterToRequiredTags, serializeKey } from './cache-key'; export { dimensionsFromContext } from './models'; diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index d94e2bb9f9..55d9287c72 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,30 +1,8 @@ import { createStorage } from 'unstorage'; -import { - InvalidateInput, - LoaderCache, - LoaderCacheConfig, - LoaderCacheEntry, - LoaderCacheEntryInfo, - LoaderCacheLoaderConfig, -} from './models'; -import { filterToRequiredTags } from './cache-key'; +import { LoaderCache, GlobalLoaderCacheConfig } from '../../loaders/models'; +import { InMemoryLoaderCache } from './default-in-memory-cache'; import { UnstorageLoaderCache } from './unstorage-loader-cache'; - -const DEFAULT_TTL_SECONDS = 300; - -/** - * Resolved (fully defaulted) config used by every {@link LoaderCache} - * implementation. Exported as `@internal` so sibling impls can share the same - * shape and helper. - * @internal - */ -export interface ResolvedConfig { - namespace: string; - revalidate: number; - enabled: boolean; - loaders: Record<string, LoaderCacheLoaderConfig>; - defaultSiteName: string; -} +import { resolveConfig } from './utils'; /** * Public factory for the loader cache. Dispatches to the right backend: @@ -39,135 +17,10 @@ export interface ResolvedConfig { * See plan §4.3. * @public */ -export function createLoaderCache(config: LoaderCacheConfig = {}): LoaderCache { +export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { const resolved = resolveConfig(config); if (config.driver) { return new UnstorageLoaderCache(createStorage(), resolved); } return new InMemoryLoaderCache(config); } - -/** - * Default LoaderCache implementation: single in-process Map, O(N) tag-scan - * invalidation. Suitable for single-process deployments and demos. - * - * Not exported. Driver variants (unstorage memory/fs/redis) live in sibling - * classes that implement the same {@link LoaderCache} interface. - * @internal - */ -class InMemoryLoaderCache implements LoaderCache { - private readonly resolved: ResolvedConfig; - private readonly store = new Map<string, LoaderCacheEntry>(); - - constructor(config: LoaderCacheConfig) { - this.resolved = resolveConfig(config); - } - - async get(key: string): Promise<LoaderCacheEntry | null> { - const entry = this.store.get(key); - if (!entry) return null; - if (this.isExpired(entry)) { - this.store.delete(key); - return null; - } - return entry; - } - - async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { - const expiresAt = ttlSeconds > 0 ? null : Date.now() + ttlSeconds * 1000; - this.store.set(key, { - value, - tags: [...tags], - storedAt: Date.now(), - expiresAt, - }); - } - - async invalidate(filter: InvalidateInput): Promise<number> { - const required = filterToRequiredTags( - filter, - this.resolved.defaultSiteName, - this.resolved.namespace - ); - let deleted = 0; - for (const [key, entry] of this.store) { - if (required.every((tag) => entry.tags.includes(tag))) { - this.store.delete(key); - deleted++; - } - } - return deleted; - } - - async delete(key: string): Promise<boolean> { - return this.store.delete(key); - } - - async flush(): Promise<void> { - this.store.clear(); - } - - async entries(): Promise<LoaderCacheEntryInfo[]> { - const out: LoaderCacheEntryInfo[] = []; - for (const [key, entry] of this.store) { - if (this.isExpired(entry)) { - this.store.delete(key); - continue; - } - out.push({ - key, - tags: [...entry.tags], - storedAt: entry.storedAt, - expiresAt: entry.expiresAt, - approxBytes: approxByteSize(entry.value), - }); - } - return out; - } - - resolveTtl(loaderId: string): number { - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; - return this.resolved.revalidate; - } - - isEnabled(loaderId: string): boolean { - if (!this.resolved.enabled) return false; - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.enabled === false) return false; - return true; - } - - getConfig(): ResolvedConfig { - return this.resolved; - } - - private isExpired(entry: LoaderCacheEntry): boolean { - return entry.expiresAt !== null && entry.expiresAt <= Date.now(); - } -} - -/** - * Build a {@link ResolvedConfig} from a {@link LoaderCacheConfig}. Shared by - * every backend so config semantics stay identical regardless of driver. - * @internal - */ -export function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { - return { - namespace: config.namespace ?? '', - revalidate: config.revalidate ?? DEFAULT_TTL_SECONDS, - enabled: config.enabled ?? true, - loaders: config.loaders ?? {}, - }; -} - -/** - * @deprecated only used for demo purposes. remove before release. - */ -function approxByteSize(value: unknown): number { - try { - return JSON.stringify(value).length; - } catch { - return 0; - } -} diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index b2b2d347c7..b754b65841 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -1,101 +1,4 @@ -import type { DriverFlags } from 'unstorage'; -import type { LoaderContext } from '../../loaders/models'; - -/** - * Persisted cache entry shape. Stored under the composite cache key built by - * buildCacheKey(); see cache-key.ts. - * @public - */ -export interface LoaderCacheEntry { - value: unknown; - tags: string[]; - storedAt: number; - expiresAt: number | null; // null = never expire -} - -/** - * Per-loader config overrides. Most-specific wins over defaults from - * LoaderCacheConfig.revalidate / enabled. - * @public - */ -export interface LoaderCacheLoaderConfig { - enabled?: boolean; - ttl?: number; - /** Reserved for Phase 4 (variant-aware keying). Accepted but ignored in v1. */ - personalize?: boolean; -} - -/** - * Config passed to createLoaderCache(). - * @public - */ -export interface LoaderCacheConfig { - /** unique app id used to scope keys when multiple apps share a store */ - namespace?: string; - /** default TTL in seconds; pass 'infinite' to never expire */ - revalidate?: number; - /** master switch — set to false to make every call fall through to the raw loader */ - enabled?: boolean; - /** per-loader overrides keyed by loaderId */ - loaders?: Record<string, LoaderCacheLoaderConfig>; - /** Unstorage `Driver` shorthand. The cache wraps it with `createStorage({ driver })` internally. Use this for single-driver setups (`fs`, `redis`, `memory`, etc). */ - driver?: DriverFlags; -} - -/** - * Filter accepted by cache.invalidate(). `route` is required - * other fields are optional and will be used to narrow the invalidation. - * @public - */ -export interface InvalidateInput { - route: string; - site?: string | '*'; - language?: string; - variantId?: string; - loaderId?: string; -} - -/** - * Metadata returned by cache.entries() — sufficient for an admin UI without - * shipping the cached values themselves (which can be large). - * @public - */ -export interface LoaderCacheEntryInfo { - key: string; - tags: string[]; - storedAt: number; - expiresAt: number | null; - /** Approximate serialized byte length of the cached value. */ - approxBytes: number; -} - -/** - * Server-only cache instance. Constructed once in server.ts via - * createLoaderCache() and passed by reference to the middleware factories - * (`createLoaderDataServiceMiddleware`, `createCacheAdminMiddleware`) and to - * Angular SSR through `angularApp.handle(req, { cache })`. - * @public - */ -export interface LoaderCache { - get(key: string): Promise<LoaderCacheEntry | null>; - set(key: string, value: unknown, ttlSeconds: number | 'infinite', tags: string[]): Promise<void>; - /** Per-path invalidation. Returns number of entries deleted. */ - invalidate(filter: InvalidateInput): Promise<number>; - /** Direct delete by exact key. */ - delete(key: string): Promise<boolean>; - /** Nuke every entry. */ - flush(): Promise<void>; - /** Returns lightweight metadata for every live entry — used by admin tooling. */ - entries(): Promise<LoaderCacheEntryInfo[]>; - resolveTtl(loaderId: string): number | 'infinite'; - isEnabled(loaderId: string): boolean; - /** Reads back the resolved config (useful for admin UI). */ - getConfig(): Readonly< - Required<Omit<LoaderCacheConfig, 'loaders' | 'storage' | 'driver'>> & { - loaders: Record<string, LoaderCacheLoaderConfig>; - } - >; -} +export const DEFAULT_CACHE_TTL = 300; /** * Identity dimensions of a cache key. Derived from LoaderContext by buildCacheKey(). @@ -103,92 +6,20 @@ export interface LoaderCache { */ export interface CacheKeyDimensions { site: string; - language: string; + locale: string; variantId: string; loaderId: string; route: string; - paramsHash: string; + customTags?: string[]; } /** - * Builder hook for tests and the admin endpoint: turn a LoaderContext into the - * dimensions used by the key + tag builders. + * Resolved (fully defaulted) config used by every {@link LoaderCache} + * implementation. Exported as `@internal` so sibling impls can share the same + * shape and helper. * @internal */ -export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { - const params = (ctx.params ?? {}) as Record<string, unknown>; - const headers = (ctx.requestContext?.headers ?? {}) as Record< - string, - string | string[] | undefined - >; - - const site = - (typeof params['site'] === 'string' && (params['site'] as string)) || - pickHeader(headers['x-sitecore-site']) || - ctx.requestContext?.hostname || - 'default'; - - const language = - (typeof params['language'] === 'string' && (params['language'] as string)) || - (typeof params['locale'] === 'string' && (params['locale'] as string)) || - pickHeader(headers['x-sitecore-language']) || - 'en'; - - const route = stripQuery(ctx.url || '/'); - const paramsHash = hashRecord({ - params: ctx.params ?? {}, - query: ctx.query ?? {}, - }); - - return { - site, - language, - variantId: 'default', - loaderId, - route, - paramsHash, - }; -} - -function pickHeader(value: string | string[] | undefined): string | undefined { - if (Array.isArray(value)) return value[0]; - return value; -} - -function stripQuery(url: string): string { - const i = url.indexOf('?'); - return i === -1 ? url : url.slice(0, i); -} - -/** - * Stable JSON stringify used to hash params/query so the cache key is - * order-insensitive. Not cryptographic — a fast non-secure hash is enough. - */ -function hashRecord(obj: unknown): string { - const canonical = canonicalStringify(obj); - return fnv1a(canonical).toString(16).padStart(8, '0'); -} - -function canonicalStringify(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return '[' + value.map(canonicalStringify).join(',') + ']'; - const keys = Object.keys(value as Record<string, unknown>).sort(); - return ( - '{' + - keys - .map( - (k) => JSON.stringify(k) + ':' + canonicalStringify((value as Record<string, unknown>)[k]) - ) - .join(',') + - '}' - ); -} - -function fnv1a(str: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < str.length; i++) { - hash ^= str.charCodeAt(i); - hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; - } - return hash >>> 0; +export interface ResolvedConfig { + revalidate: number; + enabled: boolean; } diff --git a/packages/angular/src/server/cache/resolve-loader-data.ts b/packages/angular/src/server/cache/resolve-loader-data.ts index 9cbb07ee3f..d299c31ea0 100644 --- a/packages/angular/src/server/cache/resolve-loader-data.ts +++ b/packages/angular/src/server/cache/resolve-loader-data.ts @@ -2,12 +2,13 @@ import { LoaderApiRequest, LoaderContext, LoaderRedirectResult, - RequestContext, isLoaderRedirectResult, + LoaderCache, + LoaderCacheConfig, } from '../../loaders/models'; +import { extractRequestContext } from '../../loaders/utils'; import { LoaderRegistry } from '../models'; -import { buildCacheKey, buildTags } from './cache-key'; -import { LoaderCache } from './models'; +import { buildCacheKey, buildDefaultTags } from './cache-key'; /** * Result returned to call sites. Mirrors the raw loader return shape so the @@ -33,9 +34,10 @@ export async function resolveLoaderData( request: LoaderApiRequest, registry: LoaderRegistry, cache: LoaderCache | undefined, - requestContext?: RequestContext + cacheOptions?: LoaderCacheConfig ): Promise<ResolveLoaderDataResult> { const { loaderId, url, params, query } = request; + const requestContext = extractRequestContext(request); const loader = registry[loaderId]; if (!loader) { return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` }; @@ -43,7 +45,7 @@ export async function resolveLoaderData( const ctx: LoaderContext = { url, params, query, requestContext }; - const cacheable = cache && cache.isEnabled(loaderId); + const cacheable = cacheOptions?.enabled && cache && cache.isEnabled(loaderId); if (cacheable) { const { key } = buildCacheKey(loaderId, ctx); @@ -69,8 +71,8 @@ export async function resolveLoaderData( if (cacheable) { const { key, dimensions } = buildCacheKey(loaderId, ctx); - const tags = buildTags(dimensions); - await cache.set(key, value, cache.resolveTtl(loaderId), tags); + const tags = buildDefaultTags(dimensions); + await cache.set(key, value, cacheOptions?.revalidate ?? cache.resolveTtl(loaderId), tags); } return { kind: 'data', data: value }; diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index af1ae735f7..767e0016e8 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -1,6 +1,6 @@ -import type { Storage } from 'unstorage'; +import { Storage, createStorage, Driver } from 'unstorage'; import { InvalidateInput, LoaderCache, LoaderCacheEntry, LoaderCacheEntryInfo } from './models'; -import { filterToRequiredTags } from './cache-key'; +import { resolveTagsToInvalidate } from './cache-key'; import type { ResolvedConfig } from './loader-cache'; /** @@ -18,14 +18,13 @@ import type { ResolvedConfig } from './loader-cache'; * @internal */ export class UnstorageLoaderCache implements LoaderCache { - readonly backend = 'unstorage' as const; private readonly storage: Storage; private readonly resolved: ResolvedConfig; /** Prefix passed to `storage.getKeys()` / `storage.clear()` for scoped scans. */ private readonly keyPrefix: string; - constructor(storage: Storage, resolved: ResolvedConfig) { - this.storage = storage; + constructor(driver: Driver, resolved: ResolvedConfig) { + this.storage = createStorage({ driver: driver }); this.resolved = resolved; // Mirrors the serializeKey() prefix in cache-key.ts so getKeys() returns // only this cache's entries — never anything else the user stores in the @@ -55,17 +54,13 @@ export class UnstorageLoaderCache implements LoaderCache { } async invalidate(filter: InvalidateInput): Promise<number> { - const required = filterToRequiredTags( - filter, - this.resolved.defaultSiteName, - this.resolved.namespace - ); + const tags = resolveTagsToInvalidate(filter, this.resolved.defaultSiteName); const keys = await this.storage.getKeys(this.keyPrefix); let deleted = 0; for (const key of keys) { const entry = await this.storage.getItem<LoaderCacheEntry>(key); if (!entry) continue; - if (required.every((tag) => entry.tags.includes(tag))) { + if (tags.every((tag) => entry.tags.includes(tag))) { await this.storage.removeItem(key); deleted++; } @@ -99,7 +94,6 @@ export class UnstorageLoaderCache implements LoaderCache { tags: [...entry.tags], storedAt: entry.storedAt, expiresAt: entry.expiresAt, - approxBytes: approxByteSize(entry.value), }); } return out; @@ -126,11 +120,3 @@ export class UnstorageLoaderCache implements LoaderCache { return entry.expiresAt !== null && entry.expiresAt <= Date.now(); } } - -function approxByteSize(value: unknown): number { - try { - return JSON.stringify(value).length; - } catch { - return 0; - } -} diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts new file mode 100644 index 0000000000..90e60482c9 --- /dev/null +++ b/packages/angular/src/server/cache/utils.ts @@ -0,0 +1,50 @@ +import { LoaderCacheConfig, ResolvedConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; +import { LoaderContext } from '../../loaders/models'; + +/** + * @deprecated only used for demo purposes. remove before release. + */ +export function approxByteSize(value: unknown): number { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} + +/** + * Builder hook for tests and the admin endpoint: turn a LoaderContext into the + * dimensions used by the key + tag builders. + * @internal + */ +export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { + const params = (ctx.params ?? {}) as Record<string, unknown>; + const site = (params?.['site'] as string) || 'default'; + const locale = (params?.['locale'] as string) || 'en'; + const route = stripQuery(ctx.url || '/'); + + return { + site, + locale, + variantId: 'default', + loaderId, + route, + }; +} + +function stripQuery(url: string): string { + const i = url.indexOf('?'); + return i === -1 ? url : url.slice(0, i); +} + +/** + * Build a {@link ResolvedConfig} from a {@link LoaderCacheConfig}. Shared by + * every backend so config semantics stay identical regardless of driver. + * @internal + */ +export function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { + return { + revalidate: config.revalidate ?? DEFAULT_CACHE_TTL, + enabled: config.enabled ?? true, + }; +} diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/loader-data-service-middleware.ts index fddf49f239..50c7578c2e 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/loader-data-service-middleware.ts @@ -2,8 +2,8 @@ import { LoaderApiRequest, LoaderApiResponse, NotFoundNavigationError, - RequestContext, LoaderHttpError, + LoaderCache, } from '../loaders/models'; import { extractRequestContext } from '../loaders/utils'; import { @@ -16,7 +16,6 @@ import { } from './models'; import { LOADER_DATA_ENDPOINT } from './constants'; import { resolveLoaderData } from './cache/resolve-loader-data'; -import type { LoaderCache } from './cache/models'; /** * Execute a loader and return the API response @@ -28,10 +27,9 @@ import type { LoaderCache } from './cache/models'; async function executeLoader( request: LoaderApiRequest, loaders: LoaderRegistry, - requestContext: RequestContext | undefined, cache: LoaderCache | undefined ): Promise<LoaderApiResponse> { - const result = await resolveLoaderData(request, loaders, cache, requestContext); + const result = await resolveLoaderData(request, loaders, cache); if (result.kind === 'redirect') { return { @@ -92,6 +90,7 @@ function parseLoaderRequest( url: String(req.query?.url ?? ''), params: {}, query, + angularRequestContext: extractRequestContext(req), }; } return { status: 405, message: 'Method not allowed' }; @@ -122,12 +121,7 @@ function parseLoaderRequest( export function createLoaderDataServiceMiddleware( options: ExpressDataHandlerOptions ): ExpressMiddleware { - const { - loaders, - cache, - endpoint = LOADER_DATA_ENDPOINT, - extractRequestContext: extractReq = extractRequestContext, - } = options; + const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options; return async ( req: ExpressRequest, res: ExpressResponse, @@ -137,11 +131,10 @@ export function createLoaderDataServiceMiddleware( next(); return; } - const requestContext = extractReq(req); try { const parsed = parseLoaderRequest(req); if ('loaderId' in parsed) { - const result = await executeLoader(parsed, loaders, requestContext, cache); + const result = await executeLoader(parsed, loaders, cache); sendResponse(res, result); } else { res From 90a0a58dd9fc60c2e492ad515237eb3cd4d8a40d Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Tue, 26 May 2026 18:58:28 -0400 Subject: [PATCH 08/14] phase2.part2 --- packages/angular/ng-package.json | 2 +- packages/angular/src/config/define-config.ts | 35 ++- .../src/loaders/loader-data.service.spec.ts | 10 +- .../src/loaders/loader-data.service.ts | 67 ++--- .../src/loaders/loader-registry.token.ts | 21 +- .../src/loaders/loader-resolver.spec.ts | 51 +++- .../angular/src/loaders/loader-resolver.ts | 72 +++-- packages/angular/src/loaders/models.ts | 94 ++++++- .../src/loaders/pre-loader-data.service.ts | 4 +- .../server-loader-data-provider.token.ts | 26 ++ packages/angular/src/public-api.ts | 6 + .../cache/cache-admin-middleware.spec.ts | 245 ++++++++++++++++++ .../server/cache/cache-admin-middleware.ts | 6 +- .../src/server/cache/cache-key.spec.ts | 140 ++++++++++ .../angular/src/server/cache/cache-key.ts | 19 +- .../server/cache/default-in-memory-cache.ts | 37 ++- packages/angular/src/server/cache/index.ts | 21 +- .../src/server/cache/loader-cache.spec.ts | 216 +++++++++++++++ .../angular/src/server/cache/loader-cache.ts | 16 +- packages/angular/src/server/cache/models.ts | 13 +- .../src/server/cache/resolve-loader-data.ts | 85 ------ .../server/cache/unstorage-loader-cache.ts | 47 ++-- .../angular/src/server/cache/utils.spec.ts | 84 ++++++ packages/angular/src/server/cache/utils.ts | 13 +- packages/angular/src/server/index.ts | 2 + .../loader-data-service-middleware.spec.ts | 43 ++- .../server/loader-data-service-middleware.ts | 41 ++- .../src/server/loader-data.provider.spec.ts | 211 +++++++++++++++ .../src/server/loader-data.provider.ts | 73 ++++++ packages/angular/src/server/models.ts | 10 +- .../provide-server-loader-data-provider.ts | 37 +++ .../src/templates/angular/package.json | 1 + .../angular/src/app/app.config.server.ts | 5 +- .../src/templates/angular/src/server.ts | 5 +- 34 files changed, 1451 insertions(+), 307 deletions(-) create mode 100644 packages/angular/src/loaders/server-loader-data-provider.token.ts create mode 100644 packages/angular/src/server/cache/cache-admin-middleware.spec.ts create mode 100644 packages/angular/src/server/cache/cache-key.spec.ts create mode 100644 packages/angular/src/server/cache/loader-cache.spec.ts delete mode 100644 packages/angular/src/server/cache/resolve-loader-data.ts create mode 100644 packages/angular/src/server/cache/utils.spec.ts create mode 100644 packages/angular/src/server/loader-data.provider.spec.ts create mode 100644 packages/angular/src/server/loader-data.provider.ts create mode 100644 packages/angular/src/server/provide-server-loader-data-provider.ts diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index 0db9c3a07e..a840ee32a6 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -10,7 +10,7 @@ "@angular/common", "@angular/core", "@angular/router", - "unstorage" + "unstorage", "@ngx-translate/core" ] } diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index 72e0328252..7c81af6a8b 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -33,17 +33,14 @@ export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'r */ locales?: string[]; /** - * Configuration for the ISR-like cache. + * Configuration for the ISR-like cache. Both fields default when omitted + * (`enabled: true`, `revalidate: 300`). */ - isrCache: { - /** - * Whether the cache is enabled. - */ - enabled: boolean; - /** - * The global revalidate time in seconds. - */ - revalidate: number; + isrCache?: { + /** Whether the cache is enabled. */ + enabled?: boolean; + /** The global revalidate time in seconds. */ + revalidate?: number; }; }; } @@ -60,9 +57,20 @@ export interface AngularSitecoreConfig extends Omit<SitecoreConfig, 'redirects'> angular: { /** Resolved locales for the Angular app. Always contains at least `defaultLanguage`. */ locales: string[]; + /** + * Resolved configuration for the ISR-like cache. Defaults are applied by + * `defineConfig`: `enabled: true`, `revalidate: 300`. + */ + isrCache: { + enabled: boolean; + revalidate: number; + }; }; } +/** Defaults applied to `angular.isrCache` when input omits fields. */ +const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 } as const; + /** * Ensures `defaultLanguage` is present in the locales list (prepended when missing) and * returns an empty-input fallback of `[defaultLanguage]`. @@ -109,8 +117,13 @@ export function defineConfig( scConfig.redirects.locales = locales; + const isrCache = { + enabled: angular?.isrCache?.enabled ?? DEFAULT_ISR_CACHE.enabled, + revalidate: angular?.isrCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate, + }; + return { ...scConfig, - angular: { locales }, + angular: { locales, isrCache }, } as AngularSitecoreConfig; } diff --git a/packages/angular/src/loaders/loader-data.service.spec.ts b/packages/angular/src/loaders/loader-data.service.spec.ts index 69944e89d6..25479001fd 100644 --- a/packages/angular/src/loaders/loader-data.service.spec.ts +++ b/packages/angular/src/loaders/loader-data.service.spec.ts @@ -46,7 +46,7 @@ describe('LoaderDataService', () => { }); describe('getData', () => { - it('should make new data request when no pending requests and data not in cache', async () => { + it('should make new data request when no pending requests and no staged prefetched response', async () => { setupTestBed(); const request = { url: '/test', loaderId: 'page' }; const resultPromise = service.getData(request); @@ -92,7 +92,7 @@ describe('LoaderDataService', () => { }); describe('prefetch', () => { - it('should populate cache without consuming so getData can read it without a new request', async () => { + it('should stage prefetched response without consuming so getData can read it without a new request', async () => { setupTestBed(); const request = { url: '/prefetched', loaderId: 'page' }; service.prefetch(request); @@ -111,12 +111,12 @@ describe('LoaderDataService', () => { httpController.expectNone(LOADER_DATA_ENDPOINT); }); - it('should not make a new request when cache is already populated', async () => { + it('should not make a new request when prefetched response is already staged', async () => { setupTestBed(); - const request = { url: '/cached', loaderId: 'page' }; + const request = { url: '/staged', loaderId: 'page' }; service.prefetch(request); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); - req.flush({ kind: 'data', data: { cached: true } }); + req.flush({ kind: 'data', data: { staged: true } }); await new Promise((r) => setTimeout(r, 0)); service.prefetch(request); diff --git a/packages/angular/src/loaders/loader-data.service.ts b/packages/angular/src/loaders/loader-data.service.ts index d7c3aa523f..1054ac0852 100644 --- a/packages/angular/src/loaders/loader-data.service.ts +++ b/packages/angular/src/loaders/loader-data.service.ts @@ -3,17 +3,17 @@ import { isPlatformBrowser } from '@angular/common'; import { firstValueFrom } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { Params } from '@angular/router'; -import { LoaderApiRequest, LoaderApiResponse } from './models'; +import { LoaderApiRequest, LoaderApiResponse, LoaderCacheConfig } from './models'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import { FETCH_DATA_ENDPOINT } from './loader-registry.token'; /** - * Cache key generator for loader data. + * Staging key for prefetched loader responses (browser-only, consume-once). * @param {string} loaderId - Loader identifier * @param {string} url - Request URL - * @returns Cache key string + * @returns Staging key string */ -function cacheKey(loaderId: string, url: string): string { +function requestKey(loaderId: string, url: string): string { return `loader:${loaderId}:${url}`; } @@ -26,13 +26,25 @@ export interface LoaderDataRequest { loaderId: string; params?: Params; query?: Record<string, string | string[]>; + /** + * Per-route cache overrides from `loaderResolver(id, cacheOptions)`. Sent + * to the server in the POST body so server-side cache policy matches the + * route's intent on CSR navigations. Phase 5 of the refactor plan. + */ + cacheOptions?: LoaderCacheConfig; } +/** + * Browser-only loader data client. POSTs to the `/_data` endpoint and holds + * short-lived prefetched responses for parallel navigation prefetching. + * Not aware of the server-side {@link LoaderCache}. + * @public + */ @Injectable({ providedIn: 'root', }) export class LoaderDataService { - private readonly cache = new Map<string, LoaderApiResponse>(); + private readonly prefetchedResponses = new Map<string, LoaderApiResponse>(); private readonly pending = new Map<string, Promise<LoaderApiResponse>>(); private readonly http = inject(HttpClient); private readonly platformId = inject(PLATFORM_ID); @@ -40,57 +52,53 @@ export class LoaderDataService { inject(FETCH_DATA_ENDPOINT, { optional: true }) ?? LOADER_DATA_ENDPOINT; /** - * Prefetch loader data for the given request without consuming the cache. - * If data is already cached or a request is pending, does nothing. - * Otherwise starts a fetch and stores the result in cache for a later getData() call. - * Used by PreLoaderDataService to warm the cache for all loaders in a route in parallel. + * Prefetch loader data for the given request without consuming staged responses. + * If a response is already staged or a request is pending, does nothing. + * Otherwise starts a fetch and stores the result for a later getData() call. + * Used by PreLoaderDataService to warm responses for all loaders in a route in parallel. * @param {LoaderDataRequest} loaderRequest - The loader data request */ prefetch(loaderRequest: LoaderDataRequest): void { if (!isPlatformBrowser(this.platformId)) { return; } - const key = cacheKey(loaderRequest.loaderId, loaderRequest.url); - if (this.cache.has(key) || this.pending.has(key)) { + const key = requestKey(loaderRequest.loaderId, loaderRequest.url); + if (this.prefetchedResponses.has(key) || this.pending.has(key)) { return; } const promise = this.fetchData(loaderRequest); this.pending.set(key, promise); promise.then(() => { - // Result is already stored in cache by fetchData; nothing to consume + // Result is already stored in prefetchedResponses by fetchData }); } /** - * Get data for the given request, using cache or fetching if needed. + * Get data for the given request, using staged prefetched responses or fetching if needed. * If a request is already pending for this URL/loader combination, * waits for it to complete instead of making a duplicate request. - * Consumes (removes) cached data after retrieval. + * Consumes (removes) staged responses after retrieval. * @param {LoaderDataRequest} request - The loader data request * @returns {Promise<LoaderApiResponse>} Promise resolving to the API response */ async getData(request: LoaderDataRequest): Promise<LoaderApiResponse> { - // Only fetch in browser if (!isPlatformBrowser(this.platformId)) { return { kind: 'error', status: 500, message: 'LoaderDataService only works in browser' }; } - const key = cacheKey(request.loaderId, request.url); + const key = requestKey(request.loaderId, request.url); - // Return cached response if available (consume on use); supports data and redirect - const cached = this.cache.get(key); - if (cached !== undefined) { - this.cache.delete(key); - return cached; + const staged = this.prefetchedResponses.get(key); + if (staged !== undefined) { + this.prefetchedResponses.delete(key); + return staged; } - // Wait for pending request if one exists const pendingRequest = this.pending.get(key); if (pendingRequest) { return pendingRequest; } - // Make new request; add to pending so concurrent callers reuse the same promise const pendingFetchData = this.fetchData(request); this.pending.set(key, pendingFetchData); return pendingFetchData; @@ -104,15 +112,15 @@ export class LoaderDataService { * @returns {Promise<LoaderApiResponse>} Promise resolving to the API response */ private async fetchData(request: LoaderDataRequest): Promise<LoaderApiResponse> { - const key = cacheKey(request.loaderId, request.url); + const key = requestKey(request.loaderId, request.url); const endpoint = this.fetchDataEndpoint; const reqBody: LoaderApiRequest = { loaderId: request.loaderId, url: request.url, params: request.params ?? {}, query: request.query ?? {}, + cacheOptions: request.cacheOptions, }; - console.log('DEBUG: LoaderDataService fetchData', endpoint, reqBody); try { const resp = await firstValueFrom( @@ -120,18 +128,13 @@ export class LoaderDataService { ); if (!resp) { const message = `No response from ${endpoint}`; - console.log(`DEBUG: LoaderDataService fetchData: ${message}`); return { kind: 'error', status: 500, message } as LoaderApiResponse; } - if (resp.kind === 'data') { - console.log('DEBUG: LoaderDataService fetchData: data', resp.data); - this.cache.set(key, resp); - } else if (resp.kind === 'redirect') { - this.cache.set(key, resp); + if (resp.kind === 'data' || resp.kind === 'redirect') { + this.prefetchedResponses.set(key, resp); } return resp; } catch (error) { - console.log('DEBUG: LoaderDataService fetchData: error', error); const message = error instanceof Error ? error.message : 'Fetch failed'; return { kind: 'error', status: 500, message } as LoaderApiResponse; } finally { diff --git a/packages/angular/src/loaders/loader-registry.token.ts b/packages/angular/src/loaders/loader-registry.token.ts index 96ebf7a63c..4e465a5d22 100644 --- a/packages/angular/src/loaders/loader-registry.token.ts +++ b/packages/angular/src/loaders/loader-registry.token.ts @@ -12,16 +12,25 @@ export const FETCH_DATA_ENDPOINT = new InjectionToken<string | null | undefined> 'FETCH_DATA_ENDPOINT' ); -export const LOADER_REGISTRY = new InjectionToken<Record<string, LoaderFn>>('LOADER_REGISTRY'); +/** + * Cross-boundary loader registry — maps loader IDs to loader functions. + * The same registry is used for SSR, CSR (`/_data`), and route resolvers. + * There is no separate server vs client loader set. + * @public + */ +export type LoaderRegistry = Record<string, LoaderFn>; + +export const LOADER_REGISTRY = new InjectionToken<LoaderRegistry>('LOADER_REGISTRY'); /** - * Provides the loader registry for DI. Pass the loaders your app uses (e.g. page, '404', '500'). - * The same loader set must be registered on the server in createLoaderDataServiceMiddleware so - * client-side navigation can fetch route data via the data endpoint. - * @param {Record<string, LoaderFn>} loaders - Map of loader id to loader function + * Registers the app's loader registry for DI. Pass the loaders your app uses + * (e.g. page, '404', '500'). Use the **same object** with + * {@link createLoaderDataServiceMiddleware} in `server.ts` so SSR and CSR + * navigations resolve the same loader functions. + * @param {LoaderRegistry} loaders - Map of loader id to loader function * @public */ -export const provideLoaderRegistry = (loaders: Record<string, LoaderFn>): Provider[] => { +export const provideLoaderRegistry = (loaders: LoaderRegistry): Provider[] => { return [ { provide: LOADER_REGISTRY, diff --git a/packages/angular/src/loaders/loader-resolver.spec.ts b/packages/angular/src/loaders/loader-resolver.spec.ts index 14463317c2..6dc9d6873e 100644 --- a/packages/angular/src/loaders/loader-resolver.spec.ts +++ b/packages/angular/src/loaders/loader-resolver.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { TestBed } from '@angular/core/testing'; -import { PLATFORM_ID, REQUEST, TransferState, makeStateKey } from '@angular/core'; +import { PLATFORM_ID, REQUEST, TransferState, makeStateKey, REQUEST_CONTEXT } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { provideRouter, RedirectCommand, Router } from '@angular/router'; @@ -8,7 +8,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loaderResolver } from './loader-resolver'; import { LOADER_ID, LOADER_REGISTRY } from './loader-registry.token'; import { LoaderDataService } from './loader-data.service'; +import { provideServerLoaderDataProvider } from '../server/provide-server-loader-data-provider'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; +import { createLoaderCache } from '../server/cache/loader-cache'; import type { LoaderFn } from './models'; import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; @@ -276,6 +278,7 @@ describe('loaderResolver', () => { { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderDataProvider(), ], }); transferState = TestBed.inject(TransferState); @@ -322,7 +325,7 @@ describe('loaderResolver', () => { expect(transferState.get(key, null)).toEqual({ server: true, title: 'SSR' }); }); - it('should throw when loader id is not in registry', async () => { + it('should throw LoaderHttpError when loader id is not in registry', async () => { const resolver = loaderResolver('missing' as 'page'); const route = makeRouteSnapshot(); const state = makeRouterStateSnapshot('/path'); @@ -333,7 +336,9 @@ describe('loaderResolver', () => { resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise<unknown> )(route, state); }) - ).rejects.toThrow('No loader registered for id "missing"'); + ).rejects.toMatchObject({ + message: 'No loader registered for id "missing"', + }); }); it('should rethrow when loader throws', async () => { @@ -352,6 +357,44 @@ describe('loaderResolver', () => { }) ).rejects.toThrow('Loader failed'); }); + + it('should reuse cached loader output on SSR when REQUEST_CONTEXT provides a cache', async () => { + TestBed.resetTestingModule(); + const cachedLoader = vi.fn().mockResolvedValue({ cached: true }) as ReturnType< + typeof vi.fn + > & + LoaderFn; + const cache = createLoaderCache({ revalidate: 300 }); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + TransferState, + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: LOADER_REGISTRY, useValue: { page: cachedLoader } }, + { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: REQUEST_CONTEXT, useValue: { cache } }, + provideServerLoaderDataProvider(), + ], + }); + + const resolver = loaderResolver('page'); + const route = makeRouteSnapshot({ pathFromRoot: [{ params: { site: 'demo' } }] }); + const state = makeRouterStateSnapshot('/cached-ssr'); + + await TestBed.runInInjectionContext(async () => { + return ( + resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise<unknown> + )(route, state); + }); + const second = await TestBed.runInInjectionContext(async () => { + return ( + resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise<unknown> + )(route, state); + }); + + expect(cachedLoader).toHaveBeenCalledTimes(1); + expect(second).toEqual({ cached: true }); + }); }); describe('server with REQUEST', () => { @@ -372,6 +415,7 @@ describe('loaderResolver', () => { { provide: LOADER_REGISTRY, useValue: { page: loaderWithRequest } }, { provide: LoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST, useValue: mockRequest }, + provideServerLoaderDataProvider(), ], }); }); @@ -420,6 +464,7 @@ describe('loaderResolver', () => { { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderDataProvider(), { provide: SITECORE_CONFIG_TOKEN, useValue: { diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index eedd424b28..0a234f0be1 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -1,11 +1,4 @@ -import { - inject, - TransferState, - PLATFORM_ID, - REQUEST, - REQUEST_CONTEXT, - makeStateKey, -} from '@angular/core'; +import { inject, TransferState, PLATFORM_ID, REQUEST, makeStateKey } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { ActivatedRouteSnapshot, @@ -15,7 +8,7 @@ import { Router, RedirectCommand, } from '@angular/router'; -import { LOADER_REGISTRY, LOADER_ID } from './loader-registry.token'; +import { LOADER_ID } from './loader-registry.token'; import { LoaderDataService } from './loader-data.service'; import { extractRequestContext, applyRedirect } from './utils'; import { @@ -23,12 +16,11 @@ import { DEFAULT_NOT_FOUND_ROUTE, LoaderHttpError, NotFoundNavigationError, - LoaderCache, LoaderCacheConfig, } from './models'; import { redirectOnNavigationError } from './router-error-handling'; import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN } from '../lib/tokens'; -import { resolveLoaderData } from '../server/cache/resolve-loader-data'; +import { SERVER_LOADER_DATA_PROVIDER } from './server-loader-data-provider.token'; import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; /** * Create a state key for the loader @@ -87,10 +79,11 @@ async function resolveOnBrowser( state: RouterStateSnapshot, loaderId: string, router: Router, - defaultLanguage?: string + defaultLanguage?: string, + cacheOptions?: LoaderCacheConfig ): Promise<unknown | RedirectCommand> { const transferState = inject(TransferState); - const loaderData = inject(LoaderDataService); + const browserLoaderData = inject(LoaderDataService); const url = state.url; const key = stateKey(loaderId, url); @@ -103,11 +96,12 @@ async function resolveOnBrowser( const allParams = buildLoaderParams(route, defaultLanguage); - const resp = await loaderData.getData({ + const resp = await browserLoaderData.getData({ url, loaderId, params: allParams, query: route.queryParams as Record<string, string | string[]>, + cacheOptions, }); if (resp.kind === 'error') { @@ -129,7 +123,6 @@ export const loaderResolver = ( const resolver = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const transferState = inject(TransferState); const platformId = inject(PLATFORM_ID); - const loaderRegistry = inject(LOADER_REGISTRY); const request = inject(REQUEST, { optional: true }); const notFoundRoute = inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE; @@ -142,43 +135,44 @@ export const loaderResolver = ( if (isPlatformBrowser(platformId)) { try { - return await resolveOnBrowser(route, state, loaderId, router, defaultLanguage); + return await resolveOnBrowser( + route, + state, + loaderId, + router, + defaultLanguage, + cacheOptions + ); } catch (e) { // special handling for browser, as navigation error for handleNavigationError is only generated on server return redirectOnNavigationError(e as Error, url, notFoundRoute, errorRoute, router); } } - const loader = loaderRegistry[loaderId]; - - if (!loader) { - throw new Error(`No loader registered for id "${loaderId}"`); + const serverLoaderData = inject(SERVER_LOADER_DATA_PROVIDER, { optional: true }); + if (!serverLoaderData) { + throw new Error( + 'SSR loader resolution requires provideServerLoaderDataProvider() in server application providers' + ); } - const requestContext = request ? extractRequestContext(request) : undefined; - const ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as - | { cache?: LoaderCache } - | undefined; - const cache = ssrContext?.cache; - - const result = await resolveLoaderData( - { - loaderId, - url, - params: route.params, - query: route.queryParams as Record<string, string | string[]>, - }, - loaderRegistry, - cache, - requestContext, - cacheOptions - ); + + const angularRequestContext = request ? extractRequestContext(request) : undefined; + + const result = await serverLoaderData.resolve({ + loaderId, + url, + params: buildLoaderParams(route, defaultLanguage), + query: route.queryParams as Record<string, string | string[]>, + angularRequestContext, + cacheOptions, + }); if (result.kind === 'redirect') { return applyRedirect(router, result.redirect.loaderRedirectTarget); } if (result.kind === 'error') { - const cause = (result as { cause?: unknown }).cause; + const cause = result.cause; if (cause instanceof NotFoundNavigationError) throw cause; if (cause instanceof LoaderHttpError) throw cause; throw new LoaderHttpError(result.status, result.message); diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index 5af1c2ff74..2d9a2ad54d 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -1,4 +1,5 @@ import type { Params } from '@angular/router'; +import type { Driver } from 'unstorage'; export const DEFAULT_NOT_FOUND_ROUTE = '/404'; export const DEFAULT_ERROR_ROUTE = '/500'; @@ -83,6 +84,19 @@ export type LoaderApiRequest = { url: string; params: Params; query: Record<string, any>; + /** + * Server-derived request context (hostname, headers, cookies, query). + * Populated once at the request boundary (`/_data` middleware closure or the + * SSR resolver). Downstream code reads this directly; nobody re-extracts. + * Phase 2 of the refactor plan. + */ + angularRequestContext?: RequestContext; + /** + * Per-route cache overrides supplied at the `loaderResolver(id, cacheOptions)` + * call site. The browser includes them in the `/_data` POST body so the same + * per-route policy applies on CSR navigations. Phase 5 of the refactor plan. + */ + cacheOptions?: LoaderCacheConfig; }; export type LoaderRedirectResult = { @@ -110,6 +124,16 @@ export type LoaderApiResponse = | { kind: 'error'; status: number; message: string } | { kind: 'notFound'; status: number }; +/** + * Result returned by loader resolution on the server (SSR and `/_data` endpoint). + * Uses the shared cross-boundary loader registry; not a separate server loader set. + * @public + */ +export type LoaderDataResult = + | { kind: 'data'; data: unknown } + | { kind: 'redirect'; redirect: LoaderRedirectResult } + | { kind: 'error'; status: number; message: string; cause?: unknown }; + /** * Loader function type. * A loader is an async function that receives context, can be applied in route resolvers and can return: @@ -134,13 +158,27 @@ export class LoaderHttpError extends Error { /** * Base config for loader cache. Can be applied per loader. + * + * `revalidate` is in seconds. A positive value caches the entry for that many + * seconds; `0` or a negative value means "never expire" (rely on explicit + * invalidation). There is no `'infinite'` sentinel. * @public */ export interface LoaderCacheConfig { - /** default TTL in seconds; pass 'infinite' to never expire */ + /** TTL in seconds. Positive → expires after N seconds; `0` or negative → never expires. */ revalidate?: number; - /** master switch — set to false to make every call fall through to the raw loader */ + /** Master switch — when false, every call falls through to the raw loader. */ enabled?: boolean; + /** + * Custom tags applied to every entry this loader writes. Merged with the + * default identity tags (`site:`, `locale:`, `variant:`, `loader:`, `route:`). + * Used for grouped invalidation — `invalidate({ tags: ['featured'] })` wipes + * every entry that carries this tag, regardless of route. + * + * Tags are arbitrary strings; conventional shapes like `'category:news'` are + * fine and just match verbatim. + */ + tags?: string[]; } /** @@ -168,12 +206,18 @@ export interface LoaderCacheEntry { } /** - * Filter accepted by cache.invalidate(). `route` is required - * other fields are optional and will be used to narrow the invalidation. + * Filter accepted by cache.invalidate(). At least one of `route` or `tags` + * must be supplied. All provided dimensions narrow the match (AND-intersection). + * + * - `route` matches the `route:<path>` identity tag. + * - `tags` matches custom tags written via `LoaderCacheConfig.tags`. + * - `site` defaults to `defaultSiteName` when omitted; pass `'*'` to span all sites. + * - `language`/`variantId`/`loaderId` narrow when supplied; otherwise unconstrained. * @public */ export interface InvalidateInput { - route: string; + route?: string; + tags?: string[]; site?: string | '*'; language?: string; variantId?: string; @@ -181,14 +225,36 @@ export interface InvalidateInput { } /** - * Global config for the loader cache. + * Global config for the loader cache. Consumed by `createLoaderCache()` in + * the app's `server.ts`. + * + * Drivers are imported and instantiated in the app (e.g. + * `fsDriver({ base: './.cache/loaders' })`) — the package does not own driver + * selection. When `driver` is omitted, the cache falls back to its built-in + * in-memory implementation. * @public */ export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { /** - * unstorage driver for the cache. Default in-memory cache is used when empty. + * Unstorage `Driver` instance. Pass an imported driver — the cache wraps it + * with `createStorage({ driver })` internally. Omit for the in-memory default. + */ + driver?: Driver; + /** + * Site name used by `invalidate({ route })` when no `site` is supplied. + * Should match `scConfig.defaultSiteName`. Defaults to `'default'`. + */ + defaultSiteName?: string; + /** + * Prefix applied to every cache key. Useful for multi-app shared storage. + * Defaults to empty (no prefix beyond the built-in `scLoader:` namespace). + */ + namespace?: string; + /** + * Per-loader config overrides keyed by loaderId. Per-route overrides on + * `loaderResolver()` take precedence over this map. */ - driver?: 'memory' | 'redis' | 'fs'; + loaders?: Record<string, LoaderCacheConfig>; } /** @@ -200,7 +266,11 @@ export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { */ export interface LoaderCache { get(key: string): Promise<LoaderCacheEntry | null>; - set(key: string, value: unknown, ttlSeconds: number | 'infinite', tags: string[]): Promise<void>; + /** + * Stores an entry. `ttlSeconds > 0` makes the entry expire after that many + * seconds; `0` or negative means "never expire". + */ + set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void>; /** Per-path invalidation. Returns number of entries deleted. */ invalidate(filter: InvalidateInput): Promise<number>; /** Direct delete by exact key. */ @@ -209,8 +279,8 @@ export interface LoaderCache { flush(): Promise<void>; /** Returns lightweight metadata for every live entry — used by admin tooling. */ entries(): Promise<LoaderCacheEntryInfo[]>; - resolveTtl(loaderId: string): number | 'infinite'; - isEnabled(loaderId: string): boolean; + resolveTtl(): number; + enabled(): boolean; /** Reads back the resolved config (useful for admin UI). */ - getConfig(): Readonly<LoaderCacheConfig>; + getConfig(): Readonly<GlobalLoaderCacheConfig>; } diff --git a/packages/angular/src/loaders/pre-loader-data.service.ts b/packages/angular/src/loaders/pre-loader-data.service.ts index dcb590a8ed..84fd45e4c4 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.ts @@ -24,13 +24,13 @@ interface ResolverWithLoaderId { /** * PreLoaderDataService kicks off loader data fetches for all loaders in the current route * and its parent routes in parallel, so that when Angular runs resolvers sequentially, - * resolvers get cache hits or join already-pending requests instead of waiting. + * resolvers get staged prefetched responses or join already-pending requests instead of waiting. * * Subscribes to the router's ActivationStart event and prefetches for the * ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader * resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then * calls LoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches - * run in parallel; results are stored in LoaderDataService cache for getData() to consume. + * run in parallel; results are stored in LoaderDataService prefetchedResponses for getData() to consume. * @public */ @Injectable({ diff --git a/packages/angular/src/loaders/server-loader-data-provider.token.ts b/packages/angular/src/loaders/server-loader-data-provider.token.ts new file mode 100644 index 0000000000..7e9f42e430 --- /dev/null +++ b/packages/angular/src/loaders/server-loader-data-provider.token.ts @@ -0,0 +1,26 @@ +import { InjectionToken } from '@angular/core'; +import { LoaderApiRequest, LoaderDataResult } from './models'; + +/** + * SSR injection port for cache-aware loader resolution. + * Implemented by {@link ServerLoaderDataProvider} and wired via + * {@link provideServerLoaderDataProvider}. + * @public + */ +export interface ServerLoaderDataProviderPort { + /** + * Resolve loader data on the server (cache-aware) using the shared {@link LOADER_REGISTRY}. + * @param {LoaderApiRequest} request - Loader request payload + * @returns {Promise<LoaderDataResult>} Resolved loader result + */ + resolve(request: LoaderApiRequest): Promise<LoaderDataResult>; +} + +/** + * Injection token for SSR loader data resolution. + * Must be provided via `provideServerLoaderDataProvider()` in server application config. + * @public + */ +export const SERVER_LOADER_DATA_PROVIDER = new InjectionToken<ServerLoaderDataProviderPort>( + 'SERVER_LOADER_DATA_PROVIDER' +); diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index e24d4d6d89..093321bc89 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -73,11 +73,17 @@ export * from './loaders/loader-resolver'; export * from './loaders/loader-registry.token'; export * from './loaders/loader-data.service'; export * from './loaders/pre-loader-data.service'; +export { + SERVER_LOADER_DATA_PROVIDER, + type ServerLoaderDataProviderPort, +} from './loaders/server-loader-data-provider.token'; +export { type LoaderRegistry } from './loaders/loader-registry.token'; export { NotFoundNavigationError, LoaderHttpError, type LoaderFn, type LoaderContext, + type LoaderDataResult, } from './loaders/models'; export { handleNavigationError } from './loaders/router-error-handling'; export { applyRedirect } from './loaders/utils'; diff --git a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts new file mode 100644 index 0000000000..839ae66c30 --- /dev/null +++ b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts @@ -0,0 +1,245 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCacheAdminMiddleware } from './cache-admin-middleware'; +import { createLoaderCache } from './loader-cache'; +import { buildCacheKey, buildDefaultTags } from './cache-key'; +import type { ExpressRequest, ExpressResponse } from '../models'; + +function createMockRes() { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { + status: ReturnType<typeof vi.fn>; + json: ReturnType<typeof vi.fn>; + }; +} + +function createMockNext() { + return vi.fn(); +} + +describe('createCacheAdminMiddleware', () => { + const endpoint = '/api/_cache'; + let cache: ReturnType<typeof createLoaderCache>; + + beforeEach(async () => { + cache = createLoaderCache({ revalidate: 300, defaultSiteName: 'demo' }); + const ctx = { + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + const { key, dimensions } = buildCacheKey('page', ctx); + await cache.set(key, { title: 'About' }, 300, buildDefaultTags(dimensions)); + }); + + describe('when the request path is outside the admin endpoint', () => { + it('delegates to the next middleware', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const next = createMockNext(); + const res = createMockRes(); + + await middleware( + { method: 'GET', path: '/other', url: '/other', body: {}, query: {} } as ExpressRequest, + res, + next + ); + + expect(next).toHaveBeenCalledWith(); + expect(res.json).not.toHaveBeenCalled(); + }); + }); + + describe('when auth rejects the caller', () => { + it('responds with forbidden and does not touch the cache', async () => { + const middleware = createCacheAdminMiddleware({ + cache, + endpoint, + auth: () => false, + }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'forbidden' }); + }); + }); + + describe('when listing cache entries', () => { + it('returns metadata for live entries without exposing cached values', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0] as { entries: Array<{ key: string }> }; + expect(payload.entries.length).toBeGreaterThan(0); + expect(payload.entries[0]).not.toHaveProperty('value'); + }); + }); + + describe('when reading cache configuration', () => { + it('returns the resolved cache config', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/config`, + url: `${endpoint}/config`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ revalidate: 300, defaultSiteName: 'demo' }) + ); + }); + }); + + describe('when invalidating cache entries', () => { + it('requires at least a route or custom tags in the request body', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: { site: 'demo' }, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'at least one of `route` or `tags` is required', + }); + }); + + it('deletes matching entries and reports how many were removed', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: { route: '/about' }, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ deleted: 1 }); + expect(await cache.entries()).toHaveLength(0); + }); + }); + + describe('when flushing the entire cache', () => { + it('removes every entry and returns ok', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/flush`, + url: `${endpoint}/flush`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + expect(await cache.entries()).toHaveLength(0); + }); + }); + + describe('when the admin action is unknown', () => { + it('responds with not found', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/unknown`, + url: `${endpoint}/unknown`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'unknown cache admin action: unknown', + }); + }); + }); + + describe('when the cache throws while handling a request', () => { + it('returns a 500 with the error message', async () => { + const brokenCache = { + ...cache, + entries: vi.fn().mockRejectedValue(new Error('storage offline')), + }; + const middleware = createCacheAdminMiddleware({ cache: brokenCache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'storage offline' }); + }); + }); +}); diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/cache-admin-middleware.ts index ed0e79adeb..dffd810ca7 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.ts @@ -69,8 +69,10 @@ export function createCacheAdminMiddleware( if (action === 'invalidate' && req.method === 'POST') { const body = (req.body ?? {}) as Partial<InvalidateInput>; - if (!body.route || typeof body.route !== 'string') { - res.status(400).json({ error: 'route is required' }); + const hasRoute = typeof body.route === 'string' && body.route.length > 0; + const hasTags = Array.isArray(body.tags) && body.tags.length > 0; + if (!hasRoute && !hasTags) { + res.status(400).json({ error: 'at least one of `route` or `tags` is required' }); return; } const deleted = await cache.invalidate(body as InvalidateInput); diff --git a/packages/angular/src/server/cache/cache-key.spec.ts b/packages/angular/src/server/cache/cache-key.spec.ts new file mode 100644 index 0000000000..3ff9255b52 --- /dev/null +++ b/packages/angular/src/server/cache/cache-key.spec.ts @@ -0,0 +1,140 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import type { LoaderContext } from '../../loaders/models'; +import { + buildCacheKey, + buildDefaultTags, + CACHE_KEY_PREFIX, + resolveTagsToInvalidate, + serializeKey, +} from './cache-key'; +import type { CacheKeyDimensions } from './models'; + +function makeContext(overrides: Partial<LoaderContext> = {}): LoaderContext { + return { + url: '/about', + params: { site: 'mysite', locale: 'en' }, + query: {}, + ...overrides, + }; +} + +describe('buildCacheKey', () => { + describe('when a loader runs for a localized page route', () => { + it('builds a composite key from site, locale, loader id, and path', () => { + const { key, dimensions } = buildCacheKey('page', makeContext({ url: '/about?preview=1' })); + + expect(dimensions).toEqual({ + site: 'mysite', + locale: 'en', + variantId: 'default', + loaderId: 'page', + route: '/about', + }); + expect(key).toBe( + `${CACHE_KEY_PREFIX}:mysite:en:default:page:${encodeURIComponent('/about')}` + ); + }); + }); + + describe('when route params omit site or locale', () => { + it('defaults site to "default" and locale to "en"', () => { + const { dimensions } = buildCacheKey( + 'page', + makeContext({ params: {}, url: '/home' }) + ); + + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.route).toBe('/home'); + }); + }); +}); + +describe('serializeKey', () => { + it('joins identity dimensions with the scLoader prefix', () => { + const dimensions: CacheKeyDimensions = { + site: 'demo', + locale: 'de', + variantId: 'default', + loaderId: 'page', + route: '/products/shoes', + }; + + expect(serializeKey(dimensions)).toBe( + `${CACHE_KEY_PREFIX}:demo:de:default:page:${encodeURIComponent('/products/shoes')}` + ); + }); +}); + +describe('buildDefaultTags', () => { + it('mirrors each cache dimension as a tag for grouped invalidation', () => { + const dimensions: CacheKeyDimensions = { + site: 'demo', + locale: 'fr', + variantId: 'default', + loaderId: 'page', + route: '/news', + }; + + expect(buildDefaultTags(dimensions)).toEqual([ + 'site:demo', + 'locale:fr', + 'variant:default', + 'loader:page', + `route:${encodeURIComponent('/news')}`, + ]); + }); +}); + +describe('resolveTagsToInvalidate', () => { + const defaultSite = 'corporate'; + + describe('when invalidating a single route on the default site', () => { + it('requires both the default site tag and the route tag', () => { + expect(resolveTagsToInvalidate({ route: '/about' }, defaultSite)).toEqual([ + `site:${encodeURIComponent('corporate')}`, + `route:${encodeURIComponent('/about')}`, + ]); + }); + }); + + describe('when invalidating across every site', () => { + it('omits the site tag when site is "*"', () => { + expect(resolveTagsToInvalidate({ route: '/about', site: '*' }, defaultSite)).toEqual([ + `route:${encodeURIComponent('/about')}`, + ]); + }); + }); + + describe('when narrowing by language, loader, or variant', () => { + it('adds a tag for each supplied dimension', () => { + expect( + resolveTagsToInvalidate( + { + route: '/products', + site: 'shop', + language: 'de', + loaderId: 'page', + variantId: 'personalized-a', + }, + defaultSite + ) + ).toEqual([ + `site:${encodeURIComponent('shop')}`, + `locale:${encodeURIComponent('de')}`, + `variant:${encodeURIComponent('personalized-a')}`, + `loader:${encodeURIComponent('page')}`, + `route:${encodeURIComponent('/products')}`, + ]); + }); + }); + + describe('when invalidating by custom tags', () => { + it('passes custom tags through without adding a prefix', () => { + expect( + resolveTagsToInvalidate({ tags: ['featured', 'category:news'] }, defaultSite) + ).toEqual(['site:corporate', 'featured', 'category:news']); + }); + }); +}); diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index f32bab75f0..7baf3b36fa 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -3,7 +3,7 @@ import { CacheKeyDimensions } from './models'; import { dimensionsFromContext } from './utils'; import { InvalidateInput } from '../../loaders/models'; -const CACHE_KEY_PREFIX = 'scLoader'; +export const CACHE_KEY_PREFIX = 'scLoader'; /** * Compose the canonical cache key. @@ -26,7 +26,6 @@ export function serializeKey(dimensions: CacheKeyDimensions): string { encodeURIComponent(dimensions.variantId), encodeURIComponent(dimensions.loaderId), encodeURIComponent(dimensions.route), - ...(dimensions.customTags ?? []), ].join(':'); } @@ -36,7 +35,7 @@ export function serializeKey(dimensions: CacheKeyDimensions): string { export function buildDefaultTags(dimensions: CacheKeyDimensions): string[] { return [ `site:${encodeURIComponent(dimensions.site)}`, - `language:${encodeURIComponent(dimensions.locale)}`, + `locale:${encodeURIComponent(dimensions.locale)}`, `variant:${encodeURIComponent(dimensions.variantId)}`, `loader:${encodeURIComponent(dimensions.loaderId)}`, `route:${encodeURIComponent(dimensions.route)}`, @@ -44,9 +43,14 @@ export function buildDefaultTags(dimensions: CacheKeyDimensions): string[] { } /** - * Resolve an InvalidateFilter into the set of tags that an entry must carry to match. + * Resolve an InvalidateInput into the set of tags that an entry must carry to match. * Omitted dimensions widen to "all" (no tag constraint on that axis); * `site` defaults to `defaultSiteName` unless explicitly '*'. + * + * At least one of `filter.route` or `filter.tags` should be set; otherwise the + * returned list contains only the site constraint (which would match every + * entry on the default site). Callers (admin middleware, CLI) enforce that + * precondition. */ export function resolveTagsToInvalidate( filter: InvalidateInput, @@ -56,10 +60,11 @@ export function resolveTagsToInvalidate( const site = filter.site === '*' ? null : filter.site ?? defaultSiteName; if (site) tags.push(`site:${encodeURIComponent(site)}`); - if (filter.language) tags.push(`language:${encodeURIComponent(filter.language)}`); + if (filter.language) tags.push(`locale:${encodeURIComponent(filter.language)}`); if (filter.variantId) tags.push(`variant:${encodeURIComponent(filter.variantId)}`); if (filter.loaderId) tags.push(`loader:${encodeURIComponent(filter.loaderId)}`); - - tags.push(`route:${encodeURIComponent(filter.route)}`); + if (filter.route) tags.push(`route:${encodeURIComponent(filter.route)}`); + // Custom tags are matched verbatim (no prefix transformation). + if (filter.tags?.length) tags.push(...filter.tags); return tags; } diff --git a/packages/angular/src/server/cache/default-in-memory-cache.ts b/packages/angular/src/server/cache/default-in-memory-cache.ts index 48a32d5ad3..7dfc1c90af 100644 --- a/packages/angular/src/server/cache/default-in-memory-cache.ts +++ b/packages/angular/src/server/cache/default-in-memory-cache.ts @@ -1,8 +1,12 @@ import { resolveTagsToInvalidate } from './cache-key'; -import { GlobalLoaderCacheConfig, LoaderCache, LoaderCacheEntry } from '../../loaders/models'; -import { resolveConfig } from './utils'; +import { + GlobalLoaderCacheConfig, + InvalidateInput, + LoaderCache, + LoaderCacheEntry, + LoaderCacheEntryInfo, +} from '../../loaders/models'; import { ResolvedConfig } from './models'; -import { InvalidateInput, LoaderCacheEntryInfo } from '../../loaders/models'; /** * Default LoaderCache implementation: single in-process Map, O(N) tag-scan @@ -13,11 +17,11 @@ import { InvalidateInput, LoaderCacheEntryInfo } from '../../loaders/models'; * @internal */ export class InMemoryLoaderCache implements LoaderCache { - private readonly resolved: ResolvedConfig; + private readonly config: ResolvedConfig; private readonly store = new Map<string, LoaderCacheEntry>(); - constructor(config: GlobalLoaderCacheConfig) { - this.resolved = resolveConfig(config); + constructor(config: ResolvedConfig) { + this.config = config; } async get(key: string): Promise<LoaderCacheEntry | null> { @@ -31,7 +35,7 @@ export class InMemoryLoaderCache implements LoaderCache { } async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { - const expiresAt = ttlSeconds > 0 ? null : Date.now() + ttlSeconds * 1000; + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; this.store.set(key, { value, tags: [...tags], @@ -41,7 +45,7 @@ export class InMemoryLoaderCache implements LoaderCache { } async invalidate(filter: InvalidateInput): Promise<number> { - const required = resolveTagsToInvalidate(filter); + const required = resolveTagsToInvalidate(filter, this.config.defaultSiteName); let deleted = 0; for (const [key, entry] of this.store) { if (required.every((tag) => entry.tags.includes(tag))) { @@ -77,21 +81,16 @@ export class InMemoryLoaderCache implements LoaderCache { return out; } - resolveTtl(loaderId: string): number { - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; - return this.resolved.revalidate; + resolveTtl(): number { + return this.config.revalidate; } - isEnabled(loaderId: string): boolean { - if (!this.resolved.enabled) return false; - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.enabled === false) return false; - return true; + enabled(): boolean { + return this.config.enabled; } - getConfig(): ResolvedConfig { - return this.resolved; + getConfig(): Readonly<GlobalLoaderCacheConfig> { + return this.config; } private isExpired(entry: LoaderCacheEntry): boolean { diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts index 39e746ac89..0973726d9b 100644 --- a/packages/angular/src/server/cache/index.ts +++ b/packages/angular/src/server/cache/index.ts @@ -1,17 +1,14 @@ -export type { - LoaderCache, - LoaderCacheConfig, - LoaderCacheLoaderConfig, - LoaderCacheEntry, - LoaderCacheEntryInfo, - InvalidateFilter, - CacheKeyDimensions, -} from './models'; +export type { CacheKeyDimensions, ResolvedConfig } from './models'; export { createLoaderCache } from './loader-cache'; -export { resolveLoaderData, type ResolveLoaderDataResult } from './resolve-loader-data'; export { createCacheAdminMiddleware, type CacheAdminMiddlewareOptions, } from './cache-admin-middleware'; -export { buildCacheKey, buildDefaultTags, filterToRequiredTags, serializeKey } from './cache-key'; -export { dimensionsFromContext } from './models'; +export { + buildCacheKey, + buildDefaultTags, + resolveTagsToInvalidate, + serializeKey, + CACHE_KEY_PREFIX, +} from './cache-key'; +export { dimensionsFromContext } from './utils'; diff --git a/packages/angular/src/server/cache/loader-cache.spec.ts b/packages/angular/src/server/cache/loader-cache.spec.ts new file mode 100644 index 0000000000..eede73c8e2 --- /dev/null +++ b/packages/angular/src/server/cache/loader-cache.spec.ts @@ -0,0 +1,216 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import memoryDriver from 'unstorage/drivers/memory'; +import fsDriver from 'unstorage/drivers/fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { LoaderCache, InvalidateInput } from '../../loaders/models'; +import { createLoaderCache } from './loader-cache'; +import { buildCacheKey, buildDefaultTags } from './cache-key'; +import type { LoaderContext } from '../../loaders/models'; + +const sampleContext: LoaderContext = { + url: '/products', + params: { site: 'shop', locale: 'en' }, + query: {}, +}; + +function sampleKey(loaderId = 'page') { + return buildCacheKey(loaderId, sampleContext).key; +} + +function sampleTags(loaderId = 'page') { + const { dimensions } = buildCacheKey(loaderId, sampleContext); + return buildDefaultTags(dimensions); +} + +async function runSharedLoaderCacheContract( + label: string, + createCache: () => LoaderCache | Promise<LoaderCache>, + cleanup?: () => Promise<void> +) { + describe(`${label} shared cache contract`, () => { + let cache: LoaderCache; + + beforeEach(async () => { + cache = await createCache(); + }); + + afterEach(async () => { + await cleanup?.(); + vi.useRealTimers(); + }); + + describe('when storing and reading loader output', () => { + it('returns null on a cache miss and the stored value on a hit', async () => { + const key = sampleKey(); + expect(await cache.get(key)).toBeNull(); + + await cache.set(key, { title: 'Products' }, 300, sampleTags()); + const hit = await cache.get(key); + + expect(hit?.value).toEqual({ title: 'Products' }); + expect(hit?.tags).toEqual(sampleTags()); + }); + }); + + describe('when an entry TTL expires', () => { + it('treats the entry as missing and removes it from storage', async () => { + vi.useFakeTimers(); + const key = sampleKey('expiring'); + await cache.set(key, { stale: true }, 30, sampleTags('expiring')); + + vi.advanceTimersByTime(31_000); + expect(await cache.get(key)).toBeNull(); + }); + }); + + describe('when ttl is zero or negative', () => { + it('keeps the entry until it is explicitly invalidated', async () => { + vi.useFakeTimers(); + const key = sampleKey('persistent'); + await cache.set(key, { permanent: true }, 0, sampleTags('persistent')); + + vi.advanceTimersByTime(3600_000); + expect(await cache.get(key)).not.toBeNull(); + }); + }); + + describe('when invalidating by route tag', () => { + it('deletes only entries whose tags match every required tag', async () => { + const keyA = sampleKey('page'); + const keyB = buildCacheKey('footer', { + ...sampleContext, + url: '/other', + }).key; + + await cache.set(keyA, { page: true }, 300, sampleTags('page')); + await cache.set( + keyB, + { footer: true }, + 300, + buildDefaultTags(buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions) + ); + + const deleted = await cache.invalidate({ + route: '/products', + site: 'shop', + } satisfies InvalidateInput); + + expect(deleted).toBe(1); + expect(await cache.get(keyA)).toBeNull(); + expect(await cache.get(keyB)).not.toBeNull(); + }); + }); + + describe('when deleting entries', () => { + it('removes a single key and reports whether it existed', async () => { + const key = sampleKey('delete-me'); + await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); + + expect(await cache.delete(key)).toBe(true); + expect(await cache.get(key)).toBeNull(); + expect(await cache.delete(key)).toBe(false); + }); + }); + + describe('when flushing entries', () => { + it('removes every key from the in-memory backend', async () => { + if (label !== 'InMemoryLoaderCache') return; + + const key = sampleKey('flush-me'); + await cache.set(key, { temp: true }, 300, sampleTags('flush-me')); + await cache.flush(); + expect(await cache.get(key)).toBeNull(); + }); + }); + + describe('when listing entries for admin tooling', () => { + it('returns metadata without values and skips expired entries', async () => { + vi.useFakeTimers(); + const liveKey = sampleKey('live'); + const expiredKey = sampleKey('expired-list'); + + await cache.set(liveKey, { live: true }, 300, sampleTags('live')); + await cache.set(expiredKey, { expired: true }, 10, sampleTags('expired-list')); + vi.advanceTimersByTime(11_000); + + const entries = await cache.entries(); + expect(entries.some((entry) => entry.key === liveKey)).toBe(true); + expect(entries.some((entry) => entry.key === expiredKey)).toBe(false); + expect(entries.find((entry) => entry.key === liveKey)?.tags).toEqual(sampleTags('live')); + }); + }); + + describe('when reading cache configuration', () => { + it('reports enabled state and default ttl from the resolved config', () => { + expect(cache.enabled()).toBe(true); + expect(cache.resolveTtl()).toBe(300); + expect(cache.getConfig()).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); + }); + }); + }); +} + +runSharedLoaderCacheContract('InMemoryLoaderCache', () => + createLoaderCache({ revalidate: 300, defaultSiteName: 'default' }) +); + +runSharedLoaderCacheContract('UnstorageLoaderCache (memory driver)', () => + createLoaderCache({ driver: memoryDriver(), revalidate: 300 }) +); + +describe('UnstorageLoaderCache (fs driver)', () => { + let cacheDir: string; + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'sc-loader-cache-')); + }); + + afterEach(async () => { + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('persists entries across separate cache instances on disk', async () => { + const key = sampleKey('persisted'); + const tags = sampleTags('persisted'); + + const writer = createLoaderCache({ + driver: fsDriver({ base: cacheDir }), + revalidate: 300, + }); + await writer.set(key, { persisted: true }, 300, tags); + + const reader = createLoaderCache({ + driver: fsDriver({ base: cacheDir }), + revalidate: 300, + }); + const hit = await reader.get(key); + + expect(hit?.value).toEqual({ persisted: true }); + }); +}); + +describe('createLoaderCache factory', () => { + it('uses the in-memory backend when no unstorage driver is supplied', async () => { + const cache = createLoaderCache(); + const key = sampleKey('factory-default'); + await cache.set(key, { ok: true }, 300, sampleTags('factory-default')); + expect(await cache.get(key)).not.toBeNull(); + }); + + it('still stores and retrieves entries when a namespace is configured', async () => { + const cache = createLoaderCache({ + driver: memoryDriver(), + namespace: 'preview-app', + revalidate: 300, + }); + const key = sampleKey('namespaced'); + await cache.set(key, { namespaced: true }, 300, sampleTags('namespaced')); + + expect(await cache.get(key)).toEqual( + expect.objectContaining({ value: { namespaced: true } }) + ); + }); +}); diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index 55d9287c72..2125caee68 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,4 +1,3 @@ -import { createStorage } from 'unstorage'; import { LoaderCache, GlobalLoaderCacheConfig } from '../../loaders/models'; import { InMemoryLoaderCache } from './default-in-memory-cache'; import { UnstorageLoaderCache } from './unstorage-loader-cache'; @@ -7,10 +6,13 @@ import { resolveConfig } from './utils'; /** * Public factory for the loader cache. Dispatches to the right backend: * - * - `config.storage` → {@link UnstorageLoaderCache} using that Storage - * - `config.driver` → {@link UnstorageLoaderCache} wrapping the driver - * in `createStorage({ driver })` - * - otherwise → {@link InMemoryLoaderCache} (plain Map) + * - `config.driver` provided → {@link UnstorageLoaderCache} wrapping the + * driver in `createStorage({ driver })` + * - otherwise → {@link InMemoryLoaderCache} (plain Map) + * + * Drivers are imported and constructed in the app's `server.ts` and passed + * here as an instance. The cache module does not know about driver-specific + * options (filesystem base path, Redis URL, etc.) — the app owns that. * * Callers depend on the {@link LoaderCache} interface; concrete classes are * not exported, so we can swap implementations without touching public types. @@ -20,7 +22,7 @@ import { resolveConfig } from './utils'; export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { const resolved = resolveConfig(config); if (config.driver) { - return new UnstorageLoaderCache(createStorage(), resolved); + return new UnstorageLoaderCache(config.driver, resolved); } - return new InMemoryLoaderCache(config); + return new InMemoryLoaderCache(resolved); } diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index b754b65841..299bbdaad8 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -10,16 +10,27 @@ export interface CacheKeyDimensions { variantId: string; loaderId: string; route: string; - customTags?: string[]; } /** * Resolved (fully defaulted) config used by every {@link LoaderCache} * implementation. Exported as `@internal` so sibling impls can share the same * shape and helper. + * + * Mirrors {@link GlobalLoaderCacheConfig} minus the construction-time `driver` + * field — drivers are turned into a Storage instance in the factory before the + * config reaches a backend. * @internal */ export interface ResolvedConfig { + /** Default TTL in seconds; `0` or negative means "never expire". */ revalidate: number; + /** Master switch — when false, every call falls through to the raw loader. */ enabled: boolean; + /** Optional namespace prefix on cache keys (multi-app storage sharing). */ + namespace: string; + /** Site name used by `invalidate({ route })` when no `site` is supplied. */ + defaultSiteName: string; + /** Per-loader overrides keyed by loaderId. */ + loaders: Record<string, import('../../loaders/models').LoaderCacheConfig>; } diff --git a/packages/angular/src/server/cache/resolve-loader-data.ts b/packages/angular/src/server/cache/resolve-loader-data.ts deleted file mode 100644 index d299c31ea0..0000000000 --- a/packages/angular/src/server/cache/resolve-loader-data.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - LoaderApiRequest, - LoaderContext, - LoaderRedirectResult, - isLoaderRedirectResult, - LoaderCache, - LoaderCacheConfig, -} from '../../loaders/models'; -import { extractRequestContext } from '../../loaders/utils'; -import { LoaderRegistry } from '../models'; -import { buildCacheKey, buildDefaultTags } from './cache-key'; - -/** - * Result returned to call sites. Mirrors the raw loader return shape so the - * SSR resolver branch and the Express middleware can wrap as they need. - * @public - */ -export type ResolveLoaderDataResult = - | { kind: 'data'; data: unknown } - | { kind: 'redirect'; redirect: LoaderRedirectResult } - | { kind: 'error'; status: number; message: string }; - -/** - * Shared server-only entry point used by both: - * - loader-resolver.ts (SSR branch) - * - loader-data-service-middleware.ts (/_data endpoint) - * - * Mirrors browser LoaderDataService.getData semantically: check cache -> on - * miss, run loader -> store. Errors are surfaced as `{ kind: 'error' }` to keep - * the contract simple; callers map them to their wire/Router shapes. - * @public - */ -export async function resolveLoaderData( - request: LoaderApiRequest, - registry: LoaderRegistry, - cache: LoaderCache | undefined, - cacheOptions?: LoaderCacheConfig -): Promise<ResolveLoaderDataResult> { - const { loaderId, url, params, query } = request; - const requestContext = extractRequestContext(request); - const loader = registry[loaderId]; - if (!loader) { - return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` }; - } - - const ctx: LoaderContext = { url, params, query, requestContext }; - - const cacheable = cacheOptions?.enabled && cache && cache.isEnabled(loaderId); - - if (cacheable) { - const { key } = buildCacheKey(loaderId, ctx); - const hit = await cache.get(key); - if (hit) { - return { kind: 'data', data: hit.value }; - } - } - - let value: unknown; - try { - value = await loader(ctx); - } catch (err) { - // Preserve the existing wire-level error shape from executeLoader; the SSR - // resolver re-throws to keep the Router error contract. - const message = err instanceof Error ? err.message : 'Loader failed'; - return { kind: 'error', status: 500, message, ...wrapError(err) }; - } - - if (isLoaderRedirectResult(value)) { - return { kind: 'redirect', redirect: value }; - } - - if (cacheable) { - const { key, dimensions } = buildCacheKey(loaderId, ctx); - const tags = buildDefaultTags(dimensions); - await cache.set(key, value, cacheOptions?.revalidate ?? cache.resolveTtl(loaderId), tags); - } - - return { kind: 'data', data: value }; -} - -// Lets the SSR resolver re-throw with the original Error subclass when it -// needs to (LoaderHttpError, NotFoundNavigationError). -function wrapError(err: unknown): { cause?: unknown } { - return err instanceof Error ? { cause: err } : {}; -} diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index 767e0016e8..c8af8f266f 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -1,7 +1,13 @@ import { Storage, createStorage, Driver } from 'unstorage'; -import { InvalidateInput, LoaderCache, LoaderCacheEntry, LoaderCacheEntryInfo } from './models'; -import { resolveTagsToInvalidate } from './cache-key'; -import type { ResolvedConfig } from './loader-cache'; +import { + GlobalLoaderCacheConfig, + InvalidateInput, + LoaderCache, + LoaderCacheEntry, + LoaderCacheEntryInfo, +} from '../../loaders/models'; +import { CACHE_KEY_PREFIX, resolveTagsToInvalidate } from './cache-key'; +import { ResolvedConfig } from './models'; /** * Unstorage-backed {@link LoaderCache}. Pluggable across `unstorage` drivers — @@ -14,22 +20,24 @@ import type { ResolvedConfig } from './loader-cache'; * * Invalidation walks every key under the cache prefix and reads each entry's * tags — O(N) over the cache size. Acceptable up to thousands of entries; a - * driver-native tag index (Redis `SADD`, etc.) is a Phase 2b optimization. + * driver-native tag index (Redis `SADD`, etc.) is a Phase 3 optimization. * @internal */ export class UnstorageLoaderCache implements LoaderCache { private readonly storage: Storage; - private readonly resolved: ResolvedConfig; + private readonly config: ResolvedConfig; /** Prefix passed to `storage.getKeys()` / `storage.clear()` for scoped scans. */ private readonly keyPrefix: string; - constructor(driver: Driver, resolved: ResolvedConfig) { - this.storage = createStorage({ driver: driver }); - this.resolved = resolved; + constructor(driver: Driver, config: ResolvedConfig) { + this.storage = createStorage({ driver }); + this.config = config; // Mirrors the serializeKey() prefix in cache-key.ts so getKeys() returns // only this cache's entries — never anything else the user stores in the - // same Storage instance. - this.keyPrefix = resolved.namespace ? `loader:${resolved.namespace}:` : 'loader:'; + // same Storage instance. Namespace is appended when configured. + this.keyPrefix = config.namespace + ? `${CACHE_KEY_PREFIX}:${config.namespace}` + : CACHE_KEY_PREFIX; } async get(key: string): Promise<LoaderCacheEntry | null> { @@ -54,7 +62,7 @@ export class UnstorageLoaderCache implements LoaderCache { } async invalidate(filter: InvalidateInput): Promise<number> { - const tags = resolveTagsToInvalidate(filter, this.resolved.defaultSiteName); + const tags = resolveTagsToInvalidate(filter, this.config.defaultSiteName); const keys = await this.storage.getKeys(this.keyPrefix); let deleted = 0; for (const key of keys) { @@ -99,21 +107,16 @@ export class UnstorageLoaderCache implements LoaderCache { return out; } - resolveTtl(loaderId: string): number { - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.ttl !== undefined) return perLoader.ttl; - return this.resolved.revalidate; + resolveTtl(): number { + return this.config.revalidate; } - isEnabled(loaderId: string): boolean { - if (!this.resolved.enabled) return false; - const perLoader = this.resolved.loaders[loaderId]; - if (perLoader && perLoader.enabled === false) return false; - return true; + enabled(): boolean { + return this.config.enabled; } - getConfig(): ResolvedConfig { - return this.resolved; + getConfig(): Readonly<GlobalLoaderCacheConfig> { + return this.config; } private isExpired(entry: LoaderCacheEntry): boolean { diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts new file mode 100644 index 0000000000..52fa2dbc13 --- /dev/null +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -0,0 +1,84 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import { approxByteSize, dimensionsFromContext, resolveConfig } from './utils'; +import { DEFAULT_CACHE_TTL } from './models'; + +describe('dimensionsFromContext', () => { + describe('when building cache dimensions from a loader context', () => { + it('reads site and locale from route params and strips query strings from the url', () => { + const dimensions = dimensionsFromContext('page', { + url: '/articles/1?ref=email', + params: { site: 'blog', locale: 'de' }, + query: {}, + }); + + expect(dimensions).toEqual({ + site: 'blog', + locale: 'de', + variantId: 'default', + loaderId: 'page', + route: '/articles/1', + }); + }); + }); + + describe('when params are missing', () => { + it('falls back to default site, locale, and root route', () => { + const dimensions = dimensionsFromContext('page', { + url: '', + params: {}, + query: {}, + }); + + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.route).toBe('/'); + }); + }); +}); + +describe('resolveConfig', () => { + describe('when the app passes a partial cache config', () => { + it('applies defaults for ttl, enabled flag, namespace, and default site name', () => { + expect(resolveConfig({})).toEqual({ + revalidate: DEFAULT_CACHE_TTL, + enabled: true, + namespace: '', + defaultSiteName: 'default', + loaders: {}, + }); + }); + }); + + describe('when the app overrides cache settings', () => { + it('keeps the supplied values intact', () => { + expect( + resolveConfig({ + revalidate: 60, + enabled: false, + namespace: 'preview', + defaultSiteName: 'shop', + loaders: { page: { revalidate: 120 } }, + }) + ).toEqual({ + revalidate: 60, + enabled: false, + namespace: 'preview', + defaultSiteName: 'shop', + loaders: { page: { revalidate: 120 } }, + }); + }); + }); +}); + +describe('approxByteSize', () => { + it('returns the JSON string length for serializable values', () => { + expect(approxByteSize({ title: 'Home' })).toBe(JSON.stringify({ title: 'Home' }).length); + }); + + it('returns zero when the value cannot be serialized', () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(approxByteSize(circular)).toBe(0); + }); +}); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 90e60482c9..3fa9d51627 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -1,5 +1,5 @@ -import { LoaderCacheConfig, ResolvedConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; -import { LoaderContext } from '../../loaders/models'; +import { ResolvedConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; +import { GlobalLoaderCacheConfig, LoaderContext } from '../../loaders/models'; /** * @deprecated only used for demo purposes. remove before release. @@ -38,13 +38,16 @@ function stripQuery(url: string): string { } /** - * Build a {@link ResolvedConfig} from a {@link LoaderCacheConfig}. Shared by - * every backend so config semantics stay identical regardless of driver. + * Build a {@link ResolvedConfig} from a {@link GlobalLoaderCacheConfig}. + * Shared by every backend so config semantics stay identical regardless of driver. * @internal */ -export function resolveConfig(config: LoaderCacheConfig): ResolvedConfig { +export function resolveConfig(config: GlobalLoaderCacheConfig): ResolvedConfig { return { revalidate: config.revalidate ?? DEFAULT_CACHE_TTL, enabled: config.enabled ?? true, + namespace: config.namespace ?? '', + defaultSiteName: config.defaultSiteName ?? 'default', + loaders: config.loaders ?? {}, }; } diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index dadcf1fdd2..6ee2dc77b3 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -12,6 +12,8 @@ export { } from './models'; export { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; +export { ServerLoaderDataProvider } from './loader-data.provider'; +export { provideServerLoaderDataProvider } from './provide-server-loader-data-provider'; // Loader cache (server-only). Browser code must not reach createLoaderCache — // see plan §1 (Browser safety). The exports here are types + server factories; diff --git a/packages/angular/src/server/loader-data-service-middleware.spec.ts b/packages/angular/src/server/loader-data-service-middleware.spec.ts index f9a92f4758..6d37b9d645 100644 --- a/packages/angular/src/server/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/loader-data-service-middleware.spec.ts @@ -6,7 +6,8 @@ import { NotFoundNavigationError, LoaderHttpError } from '../loaders/models'; import { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; import { LOADER_DATA_ENDPOINT } from './constants'; import { EXTRACT_REQUEST_CONTEXT_TOKEN } from './models'; -import type { LoaderRegistry } from './models'; +import type { LoaderRegistry } from '../loaders/loader-registry.token'; +import { createLoaderCache } from './cache/loader-cache'; /** * Minimal Express `res` stub for middleware tests. @@ -38,11 +39,15 @@ describe('createLoaderDataServiceMiddleware', () => { }); /** - * @param {{ loaders: import('./models').LoaderRegistry; endpoint?: string }} opts - Middleware factory options + * @param {{ loaders: import('./models').LoaderRegistry; endpoint?: string; cache?: import('../loaders/models').LoaderCache }} opts - Middleware factory options * @param {import('./models').LoaderRegistry} opts.loaders - Registered route loaders * @param {string} [opts.endpoint] - Data endpoint path override */ - function createMiddleware(opts: { loaders: LoaderRegistry; endpoint?: string }) { + function createMiddleware(opts: { + loaders: LoaderRegistry; + endpoint?: string; + cache?: import('../loaders/models').LoaderCache; + }) { const extractReq = TestBed.inject(EXTRACT_REQUEST_CONTEXT_TOKEN); return createLoaderDataServiceMiddleware({ ...opts, @@ -288,6 +293,38 @@ describe('createLoaderDataServiceMiddleware', () => { expect(res.json).not.toHaveBeenCalled(); }); + it('should serve cached loader data on repeat requests without re-running the loader', async () => { + const mockLoader = vi.fn().mockResolvedValue({ title: 'Cached page' }) as LoaderFn; + const cache = createLoaderCache({ revalidate: 300 }); + const middleware = createMiddleware({ + loaders: { page: mockLoader }, + endpoint, + cache, + }); + const req = { + method: 'POST', + path: endpoint, + body: { loaderId: 'page', url: '/cached-page', params: { site: 'demo' }, query: {} }, + query: {}, + headers: {}, + }; + const res1 = createMockRes(); + const res2 = createMockRes(); + + await middleware(req as any, res1 as any, createMockNext()); + await middleware(req as any, res2 as any, createMockNext()); + + expect(mockLoader).toHaveBeenCalledTimes(1); + expect(res1.json).toHaveBeenCalledWith({ + kind: 'data', + data: { title: 'Cached page' }, + }); + expect(res2.json).toHaveBeenCalledWith({ + kind: 'data', + data: { title: 'Cached page' }, + }); + }); + it('should return 400 when POST body missing loaderId', async () => { const middleware = createMiddleware({ loaders: { page: vi.fn() as LoaderFn }, diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/loader-data-service-middleware.ts index 50c7578c2e..f8b2625e4a 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/loader-data-service-middleware.ts @@ -3,7 +3,7 @@ import { LoaderApiResponse, NotFoundNavigationError, LoaderHttpError, - LoaderCache, + LoaderDataResult, } from '../loaders/models'; import { extractRequestContext } from '../loaders/utils'; import { @@ -12,25 +12,16 @@ import { ExpressNextFunction, ExpressRequest, ExpressResponse, - LoaderRegistry, } from './models'; import { LOADER_DATA_ENDPOINT } from './constants'; -import { resolveLoaderData } from './cache/resolve-loader-data'; +import { ServerLoaderDataProvider } from './loader-data.provider'; /** - * Execute a loader and return the API response - * @param {LoaderApiRequest} request - The loader data request - * @param {LoaderRegistry} loaders - The loader registry - * @param {RequestContext} [requestContext] - The request context - * @returns {Promise<LoaderApiResponse>} Promise resolving to the API response + * Map loader resolution result to wire-level API response. + * @param {LoaderDataResult} result - Loader result from the shared registry + * @returns {LoaderApiResponse} Wire envelope for the client */ -async function executeLoader( - request: LoaderApiRequest, - loaders: LoaderRegistry, - cache: LoaderCache | undefined -): Promise<LoaderApiResponse> { - const result = await resolveLoaderData(request, loaders, cache); - +function toApiResponse(result: LoaderDataResult): LoaderApiResponse { if (result.kind === 'redirect') { return { kind: 'redirect', @@ -42,9 +33,7 @@ async function executeLoader( } if (result.kind === 'error') { - // Map known loader errors back to wire envelopes; resolveLoaderData - // attaches the original Error via `cause` so we can pattern-match. - const cause = (result as { cause?: unknown }).cause; + const cause = result.cause; if (cause instanceof NotFoundNavigationError) { return { kind: 'notFound', status: 404 }; } @@ -90,7 +79,6 @@ function parseLoaderRequest( url: String(req.query?.url ?? ''), params: {}, query, - angularRequestContext: extractRequestContext(req), }; } return { status: 405, message: 'Method not allowed' }; @@ -109,12 +97,12 @@ function parseLoaderRequest( * ```typescript * import { createExpressDataMiddleware, LOADER_DATA_ENDPOINT } from '@sitecore-content-sdk/angular'; * - * // Use default endpoint (same as client when FETCH_DATA_ENDPOINT is not provided) - * app.use(createExpressDataMiddleware({ loaders: SERVER_LOADERS })); + * // Pass the same LOADERS object used with provideLoaderRegistry(LOADERS) + * app.use(createExpressDataMiddleware({ loaders: LOADERS })); * * // Or pass the same endpoint you provide to the Angular app (FETCH_DATA_ENDPOINT) * const dataEndpoint = process.env.DATA_ENDPOINT ?? LOADER_DATA_ENDPOINT; - * app.use(createExpressDataMiddleware({ loaders: SERVER_LOADERS, endpoint: dataEndpoint })); + * app.use(createExpressDataMiddleware({ loaders: LOADERS, endpoint: dataEndpoint })); * ``` * @public */ @@ -122,6 +110,8 @@ export function createLoaderDataServiceMiddleware( options: ExpressDataHandlerOptions ): ExpressMiddleware { const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options; + const serverLoaderData = new ServerLoaderDataProvider(loaders, cache); + return async ( req: ExpressRequest, res: ExpressResponse, @@ -134,7 +124,12 @@ export function createLoaderDataServiceMiddleware( try { const parsed = parseLoaderRequest(req); if ('loaderId' in parsed) { - const result = await executeLoader(parsed, loaders, cache); + // Per refactor plan A2: extract once at the boundary; ride on the payload. + // POST body's `angularRequestContext` is ignored — server-derived data + // (hostname, headers) must come from the actual request, not from a + // payload the browser could spoof. + parsed.angularRequestContext = extractRequestContext(req); + const result = toApiResponse(await serverLoaderData.resolve(parsed)); sendResponse(res, result); } else { res diff --git a/packages/angular/src/server/loader-data.provider.spec.ts b/packages/angular/src/server/loader-data.provider.spec.ts new file mode 100644 index 0000000000..19e52f112b --- /dev/null +++ b/packages/angular/src/server/loader-data.provider.spec.ts @@ -0,0 +1,211 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerLoaderDataProvider } from './loader-data.provider'; +import type { LoaderCache, LoaderFn } from '../loaders/models'; +import { createLoaderCache } from './cache/loader-cache'; + +describe('ServerLoaderDataProvider', () => { + const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); + + beforeEach(() => { + vi.mocked(pageLoader).mockClear(); + vi.mocked(pageLoader).mockResolvedValue({ title: 'Page' }); + }); + + it('should return error when loader id is not in registry', async () => { + const provider = new ServerLoaderDataProvider({}); + const result = await provider.resolve({ + loaderId: 'missing', + url: '/path', + params: {}, + query: {}, + }); + expect(result).toEqual({ + kind: 'error', + status: 500, + message: 'No loader registered for id "missing"', + }); + }); + + it('should invoke loader and return data on cache miss', async () => { + const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/about', + params: { slug: 'about' }, + query: { q: '1' }, + }); + + expect(pageLoader).toHaveBeenCalledWith({ + url: '/about', + params: { slug: 'about' }, + query: { q: '1' }, + requestContext: undefined, + }); + expect(result).toEqual({ kind: 'data', data: { title: 'Page' } }); + }); + + it('should return cached data without invoking loader', async () => { + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ value: { cached: true } }), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + resolveTtl: vi.fn().mockReturnValue(300), + enabled: vi.fn().mockReturnValue(true), + getConfig: vi.fn(), + }; + + const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/cached', + params: {}, + query: {}, + }); + + expect(result).toEqual({ kind: 'data', data: { cached: true } }); + expect(pageLoader).not.toHaveBeenCalled(); + }); + + it('should return redirect when loader returns redirect result', async () => { + vi.mocked(pageLoader).mockResolvedValueOnce({ + loaderRedirectTarget: '/other', + status: 302, + }); + const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/redirect', + params: {}, + query: {}, + }); + + expect(result).toEqual({ + kind: 'redirect', + redirect: { loaderRedirectTarget: '/other', status: 302 }, + }); + }); + + it('should return error with cause when loader throws', async () => { + const err = new Error('Loader failed'); + vi.mocked(pageLoader).mockRejectedValueOnce(err); + const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/fail', + params: {}, + query: {}, + }); + + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.message).toBe('Loader failed'); + expect(result.cause).toBe(err); + } + }); + + it('should store loader result in cache when cacheable', async () => { + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + resolveTtl: vi.fn().mockReturnValue(300), + enabled: vi.fn().mockReturnValue(true), + getConfig: vi.fn(), + }; + + const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + await provider.resolve({ + loaderId: 'page', + url: '/store', + params: {}, + query: {}, + }); + + expect(cache.set).toHaveBeenCalled(); + }); + + it('should skip the cache when it is globally disabled and the route did not opt in', async () => { + const cache: LoaderCache = { + get: vi.fn(), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + resolveTtl: vi.fn().mockReturnValue(300), + enabled: vi.fn().mockReturnValue(false), + getConfig: vi.fn(), + }; + + const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + await provider.resolve({ + loaderId: 'page', + url: '/live', + params: {}, + query: {}, + }); + await provider.resolve({ + loaderId: 'page', + url: '/live', + params: {}, + query: {}, + }); + + expect(pageLoader).toHaveBeenCalledTimes(2); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should use the cache for a route that opts in even when global caching is disabled', async () => { + const cache = createLoaderCache({ enabled: false, revalidate: 300 }); + const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/featured', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, tags: ['featured'], revalidate: 60 }, + }; + + await provider.resolve(request); + await provider.resolve(request); + + expect(pageLoader).toHaveBeenCalledTimes(1); + }); + + it('should not cache redirect responses', async () => { + vi.mocked(pageLoader).mockResolvedValueOnce({ + loaderRedirectTarget: '/login', + status: 302, + }); + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + resolveTtl: vi.fn().mockReturnValue(300), + enabled: vi.fn().mockReturnValue(true), + getConfig: vi.fn(), + }; + + const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/protected', + params: {}, + query: {}, + }); + + expect(result.kind).toBe('redirect'); + expect(cache.set).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/server/loader-data.provider.ts b/packages/angular/src/server/loader-data.provider.ts new file mode 100644 index 0000000000..46632da869 --- /dev/null +++ b/packages/angular/src/server/loader-data.provider.ts @@ -0,0 +1,73 @@ +import { + LoaderApiRequest, + LoaderContext, + isLoaderRedirectResult, + LoaderCache, + LoaderDataResult, +} from '../loaders/models'; +import { LoaderRegistry } from '../loaders/loader-registry.token'; +import { buildCacheKey, buildDefaultTags } from './cache/cache-key'; + +/** + * Server-side loader data provider. Runs loaders from the shared cross-boundary + * {@link LoaderRegistry} with optional global {@link LoaderCache} backing. + * Used by Express middleware and SSR (via {@link SERVER_LOADER_DATA_PROVIDER}). + * @public + */ +export class ServerLoaderDataProvider { + constructor( + private readonly registry: LoaderRegistry, + private readonly cache?: LoaderCache + ) {} + + /** + * Resolve loader data: check cache, run loader on miss, store result. + * @param {LoaderApiRequest} request - Loader request payload + * @returns {Promise<LoaderDataResult>} Resolved loader result + */ + async resolve(request: LoaderApiRequest): Promise<LoaderDataResult> { + const { loaderId, url, params, query, angularRequestContext, cacheOptions } = request; + const loader = this.registry[loaderId]; + if (!loader) { + return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` }; + } + + const ctx: LoaderContext = { url, params, query, requestContext: angularRequestContext }; + + const cacheable = this.cache && (cacheOptions?.enabled || this.cache.enabled()); + + if (cacheable) { + const { key } = buildCacheKey(loaderId, ctx); + const hit = await this.cache!.get(key); + if (hit) { + return { kind: 'data', data: hit.value }; + } + } + + let value: unknown; + try { + value = await loader(ctx); + } catch (err) { + const message = err instanceof Error ? err.message : 'Loader failed'; + return { + kind: 'error', + status: 500, + message, + ...(err instanceof Error ? { cause: err } : {}), + }; + } + + if (isLoaderRedirectResult(value)) { + return { kind: 'redirect', redirect: value }; + } + + if (cacheable) { + const { key, dimensions } = buildCacheKey(loaderId, ctx); + const tags = [...buildDefaultTags(dimensions), ...(cacheOptions?.tags ?? [])]; + const ttl = cacheOptions?.revalidate ?? this.cache!.resolveTtl(); + await this.cache!.set(key, value, ttl, tags); + } + + return { kind: 'data', data: value }; + } +} diff --git a/packages/angular/src/server/models.ts b/packages/angular/src/server/models.ts index 47c2775c28..fa1b84dd25 100644 --- a/packages/angular/src/server/models.ts +++ b/packages/angular/src/server/models.ts @@ -1,7 +1,7 @@ import { InjectionToken } from '@angular/core'; import type { RequestContext } from '../loaders/models'; -import type { LoaderFn } from '../loaders/models'; -import type { LoaderCache } from './cache/models'; +import type { LoaderRegistry } from '../loaders/loader-registry.token'; +import type { LoaderCache } from '../loaders/models'; /** * Injection token for the request context extractor (used by tests to provide a mock via TestBed). @@ -69,10 +69,10 @@ export type ExpressMiddleware = ( ) => void | Promise<void>; /** - * Loader registry type - maps loader IDs to loader functions * @public + * @deprecated Import {@link LoaderRegistry} from `@sitecore-content-sdk/angular` loader registry exports instead. */ -export type LoaderRegistry = Record<string, LoaderFn>; +export type { LoaderRegistry } from '../loaders/loader-registry.token'; /** * Options for the Express data handler @@ -80,7 +80,7 @@ export type LoaderRegistry = Record<string, LoaderFn>; */ export interface ExpressDataHandlerOptions extends DataHandlerConfig { /** - * The loader registry containing all registered loaders + * The shared loader registry (same object as {@link provideLoaderRegistry}). */ loaders: LoaderRegistry; /** diff --git a/packages/angular/src/server/provide-server-loader-data-provider.ts b/packages/angular/src/server/provide-server-loader-data-provider.ts new file mode 100644 index 0000000000..16d95814b5 --- /dev/null +++ b/packages/angular/src/server/provide-server-loader-data-provider.ts @@ -0,0 +1,37 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + REQUEST_CONTEXT, +} from '@angular/core'; +import { LOADER_REGISTRY } from '../loaders/loader-registry.token'; +import { SERVER_LOADER_DATA_PROVIDER } from '../loaders/server-loader-data-provider.token'; +import { LoaderCache, LoaderApiRequest } from '../loaders/models'; +import { ServerLoaderDataProvider } from './loader-data.provider'; + +/** + * Wires SSR {@link SERVER_LOADER_DATA_PROVIDER} to {@link ServerLoaderDataProvider} + * using the shared {@link LOADER_REGISTRY}. Include in server application providers + * alongside {@link provideLoaderRegistry}. + * @returns Environment providers for SSR loader data resolution + * @public + */ +export function provideServerLoaderDataProvider(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: SERVER_LOADER_DATA_PROVIDER, + useFactory: () => { + const registry = inject(LOADER_REGISTRY); + return { + resolve(request: LoaderApiRequest) { + const ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as + | { cache?: LoaderCache } + | undefined; + const cache = ssrContext?.cache; + return new ServerLoaderDataProvider(registry, cache).resolve(request); + }, + }; + }, + }, + ]); +} diff --git a/packages/create-content-sdk-app/src/templates/angular/package.json b/packages/create-content-sdk-app/src/templates/angular/package.json index 402103abba..1178b9d1f7 100644 --- a/packages/create-content-sdk-app/src/templates/angular/package.json +++ b/packages/create-content-sdk-app/src/templates/angular/package.json @@ -49,6 +49,7 @@ "express": "^5.1.0", "rxjs": "~7.8.0", "unstorage": "^1.17.5", + "@ngx-translate/core": "^17.0.0", "tailwind-bootstrap-grid": "^6.0.0", "tslib": "^2.3.0" }, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts index 41031f1165..6297181b82 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts @@ -2,11 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { provideServerLoaderDataProvider } from '@sitecore-content-sdk/angular'; const serverConfig: ApplicationConfig = { - providers: [ - provideServerRendering(withRoutes(serverRoutes)) - ] + providers: [provideServerRendering(withRoutes(serverRoutes)), provideServerLoaderDataProvider()], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 8b6d39b0dd..6333ac1268 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -15,6 +15,7 @@ import { createLoaderDataServiceMiddleware, } from '@sitecore-content-sdk/angular'; import { LOADERS } from './content-sdk/loaders'; +import config from '../sitecore.config'; const browserDistFolder = join(import.meta.dirname, '../browser'); @@ -39,8 +40,8 @@ const driver = : undefined; const loaderCache = createLoaderCache({ - revalidate: 300, - defaultSiteName: 'localhost', + revalidate: config.angular.isrCache.revalidate, + defaultSiteName: config.defaultSiteName, ...(driver ? { driver } : {}), }); From 272d1392728275fd633fc6f66f9a517dcb2e07ae Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Wed, 27 May 2026 18:16:57 -0400 Subject: [PATCH 09/14] phase 3. OSR and tag based revalidation. Align with nextjs --- packages/angular/src/loaders/models.ts | 82 ++--- .../cache/cache-admin-middleware.spec.ts | 298 ++++++------------ .../server/cache/cache-admin-middleware.ts | 15 +- .../src/server/cache/cache-key.spec.ts | 149 +++------ .../angular/src/server/cache/cache-key.ts | 87 +++-- .../angular/src/server/cache/cache-tags.ts | 159 ++++++++++ .../server/cache/default-in-memory-cache.ts | 121 +++++-- packages/angular/src/server/cache/index.ts | 19 +- .../src/server/cache/loader-cache.spec.ts | 103 +++--- .../angular/src/server/cache/loader-cache.ts | 3 +- packages/angular/src/server/cache/models.ts | 36 +-- .../server/cache/unstorage-loader-cache.ts | 156 +++++---- .../angular/src/server/cache/utils.spec.ts | 99 +++--- packages/angular/src/server/cache/utils.ts | 113 ++++++- packages/angular/src/server/index.ts | 3 +- .../src/server/loader-data.provider.spec.ts | 6 +- .../src/server/loader-data.provider.ts | 96 +++++- .../angular/src/server/middleware/index.ts | 16 + .../loader-data-service-middleware.spec.ts | 18 +- .../loader-data-service-middleware.ts | 10 +- ...sitecore-edge-webhook-revalidation.spec.ts | 52 +++ .../sitecore-edge-webhook-revalidation.ts | 95 ++++++ .../sitecore-revalidate-middleware.spec.ts | 112 +++++++ .../sitecore-revalidate-middleware.ts | 128 ++++++++ .../src/app/admin/cache-demo.component.ts | 115 +++---- .../src/templates/angular/src/server.ts | 19 +- .../src/cache/sitecore-cache-tags.test.ts | 10 +- .../nextjs/src/cache/sitecore-cache-tags.ts | 9 +- .../cache/sitecore-page-cache-tags.test.ts | 16 +- 29 files changed, 1382 insertions(+), 763 deletions(-) create mode 100644 packages/angular/src/server/cache/cache-tags.ts create mode 100644 packages/angular/src/server/middleware/index.ts rename packages/angular/src/server/{ => middleware}/loader-data-service-middleware.spec.ts (92%) rename packages/angular/src/server/{ => middleware}/loader-data-service-middleware.ts (95%) create mode 100644 packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts create mode 100644 packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts create mode 100644 packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts create mode 100644 packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index 2d9a2ad54d..590199aa3c 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -1,6 +1,4 @@ import type { Params } from '@angular/router'; -import type { Driver } from 'unstorage'; - export const DEFAULT_NOT_FOUND_ROUTE = '/404'; export const DEFAULT_ERROR_ROUTE = '/500'; @@ -169,16 +167,15 @@ export interface LoaderCacheConfig { revalidate?: number; /** Master switch — when false, every call falls through to the raw loader. */ enabled?: boolean; + /** Default site name for tag helpers and admin tooling. Defaults to `'default'`. */ + defaultSiteName?: string; /** - * Custom tags applied to every entry this loader writes. Merged with the - * default identity tags (`site:`, `locale:`, `variant:`, `loader:`, `route:`). - * Used for grouped invalidation — `invalidate({ tags: ['featured'] })` wipes - * every entry that carries this tag, regardless of route. - * - * Tags are arbitrary strings; conventional shapes like `'category:news'` are - * fine and just match verbatim. + * Custom tags applied to every entry this loader writes. Merged with built-in + * OSR tags (self-key, `sc:site`, `sc:locale`, and `sc:item` for page loaders). */ tags?: string[]; + sites?: string[]; + defaultLocale?: string; } /** @@ -191,8 +188,18 @@ export interface LoaderCacheEntryInfo { tags: string[]; storedAt: number; expiresAt: number | null; + stale: boolean; } +/** + * Three-outcome read result for stale-while-revalidate (Phase 3). + * @public + */ +export type LoaderCacheReadResult = + | { kind: 'hit'; value: unknown; cacheKey: string } + | { kind: 'stale'; value: unknown; cacheKey: string } + | { kind: 'miss'; cacheKey: string }; + /** * Persisted cache entry shape. Stored under the composite cache key built by * buildCacheKey(); see cache-key.ts. @@ -203,58 +210,17 @@ export interface LoaderCacheEntry { tags: string[]; storedAt: number; expiresAt: number | null; // null = never expire + /** When true (or TTL expired), entry is served stale while refreshing. */ + stale: boolean; } /** - * Filter accepted by cache.invalidate(). At least one of `route` or `tags` - * must be supplied. All provided dimensions narrow the match (AND-intersection). - * - * - `route` matches the `route:<path>` identity tag. - * - `tags` matches custom tags written via `LoaderCacheConfig.tags`. - * - `site` defaults to `defaultSiteName` when omitted; pass `'*'` to span all sites. - * - `language`/`variantId`/`loaderId` narrow when supplied; otherwise unconstrained. + * Tag-based invalidation input + * Marks matching entries stale; does not delete them. * @public */ export interface InvalidateInput { - route?: string; tags?: string[]; - site?: string | '*'; - language?: string; - variantId?: string; - loaderId?: string; -} - -/** - * Global config for the loader cache. Consumed by `createLoaderCache()` in - * the app's `server.ts`. - * - * Drivers are imported and instantiated in the app (e.g. - * `fsDriver({ base: './.cache/loaders' })`) — the package does not own driver - * selection. When `driver` is omitted, the cache falls back to its built-in - * in-memory implementation. - * @public - */ -export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { - /** - * Unstorage `Driver` instance. Pass an imported driver — the cache wraps it - * with `createStorage({ driver })` internally. Omit for the in-memory default. - */ - driver?: Driver; - /** - * Site name used by `invalidate({ route })` when no `site` is supplied. - * Should match `scConfig.defaultSiteName`. Defaults to `'default'`. - */ - defaultSiteName?: string; - /** - * Prefix applied to every cache key. Useful for multi-app shared storage. - * Defaults to empty (no prefix beyond the built-in `scLoader:` namespace). - */ - namespace?: string; - /** - * Per-loader config overrides keyed by loaderId. Per-route overrides on - * `loaderResolver()` take precedence over this map. - */ - loaders?: Record<string, LoaderCacheConfig>; } /** @@ -265,13 +231,13 @@ export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { * @public */ export interface LoaderCache { - get(key: string): Promise<LoaderCacheEntry | null>; + get(key: string): Promise<LoaderCacheReadResult>; /** * Stores an entry. `ttlSeconds > 0` makes the entry expire after that many - * seconds; `0` or negative means "never expire". + * seconds; `0` or negative means "never expire". Always writes `stale: false`. */ set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void>; - /** Per-path invalidation. Returns number of entries deleted. */ + /** Marks entries stale by tag. Returns number of entries marked. */ invalidate(filter: InvalidateInput): Promise<number>; /** Direct delete by exact key. */ delete(key: string): Promise<boolean>; @@ -282,5 +248,5 @@ export interface LoaderCache { resolveTtl(): number; enabled(): boolean; /** Reads back the resolved config (useful for admin UI). */ - getConfig(): Readonly<GlobalLoaderCacheConfig>; + getConfig(): Readonly<LoaderCacheConfig>; } diff --git a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts index 839ae66c30..c4b20f6cef 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createCacheAdminMiddleware } from './cache-admin-middleware'; import { createLoaderCache } from './loader-cache'; -import { buildCacheKey, buildDefaultTags } from './cache-key'; +import { buildCacheKey } from './cache-key'; +import { buildLoaderCacheTags } from './cache-tags'; import type { ExpressRequest, ExpressResponse } from '../models'; function createMockRes() { @@ -22,6 +23,7 @@ function createMockNext() { describe('createCacheAdminMiddleware', () => { const endpoint = '/api/_cache'; let cache: ReturnType<typeof createLoaderCache>; + let cacheKey: string; beforeEach(async () => { cache = createLoaderCache({ revalidate: 300, defaultSiteName: 'demo' }); @@ -30,216 +32,114 @@ describe('createCacheAdminMiddleware', () => { params: { site: 'demo', locale: 'en' }, query: {}, }; - const { key, dimensions } = buildCacheKey('page', ctx); - await cache.set(key, { title: 'About' }, 300, buildDefaultTags(dimensions)); + const built = buildCacheKey('page', ctx); + cacheKey = built.key; + await cache.set( + cacheKey, + { title: 'About' }, + 300, + buildLoaderCacheTags('page', built.dimensions, cacheKey) + ); }); - describe('when the request path is outside the admin endpoint', () => { - it('delegates to the next middleware', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const next = createMockNext(); - const res = createMockRes(); + it('delegates when path is outside admin endpoint', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const next = createMockNext(); + const res = createMockRes(); - await middleware( - { method: 'GET', path: '/other', url: '/other', body: {}, query: {} } as ExpressRequest, - res, - next - ); + await middleware( + { method: 'GET', path: '/other', url: '/other', body: {}, query: {} } as ExpressRequest, + res, + next + ); - expect(next).toHaveBeenCalledWith(); - expect(res.json).not.toHaveBeenCalled(); - }); - }); - - describe('when auth rejects the caller', () => { - it('responds with forbidden and does not touch the cache', async () => { - const middleware = createCacheAdminMiddleware({ - cache, - endpoint, - auth: () => false, - }); - const res = createMockRes(); - - await middleware( - { - method: 'GET', - path: `${endpoint}/entries`, - url: `${endpoint}/entries`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'forbidden' }); - }); + expect(next).toHaveBeenCalledWith(); + expect(res.json).not.toHaveBeenCalled(); }); - describe('when listing cache entries', () => { - it('returns metadata for live entries without exposing cached values', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'GET', - path: `${endpoint}/entries`, - url: `${endpoint}/entries`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(200); - const payload = res.json.mock.calls[0][0] as { entries: Array<{ key: string }> }; - expect(payload.entries.length).toBeGreaterThan(0); - expect(payload.entries[0]).not.toHaveProperty('value'); + it('returns 403 when auth rejects', async () => { + const middleware = createCacheAdminMiddleware({ + cache, + endpoint, + auth: () => false, }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(403); }); - describe('when reading cache configuration', () => { - it('returns the resolved cache config', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'GET', - path: `${endpoint}/config`, - url: `${endpoint}/config`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ revalidate: 300, defaultSiteName: 'demo' }) - ); - }); + it('lists entries without values', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0] as { entries: Array<{ key: string }> }; + expect(payload.entries.length).toBeGreaterThan(0); + expect(payload.entries[0]).not.toHaveProperty('value'); }); - describe('when invalidating cache entries', () => { - it('requires at least a route or custom tags in the request body', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'POST', - path: `${endpoint}/invalidate`, - url: `${endpoint}/invalidate`, - body: { site: 'demo' }, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'at least one of `route` or `tags` is required', - }); - }); - - it('deletes matching entries and reports how many were removed', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'POST', - path: `${endpoint}/invalidate`, - url: `${endpoint}/invalidate`, - body: { route: '/about' }, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ deleted: 1 }); - expect(await cache.entries()).toHaveLength(0); - }); + it('requires non-empty tags for invalidate', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'non-empty `tags` array is required' }); }); - describe('when flushing the entire cache', () => { - it('removes every entry and returns ok', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'POST', - path: `${endpoint}/flush`, - url: `${endpoint}/flush`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ ok: true }); - expect(await cache.entries()).toHaveLength(0); - }); - }); - - describe('when the admin action is unknown', () => { - it('responds with not found', async () => { - const middleware = createCacheAdminMiddleware({ cache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'GET', - path: `${endpoint}/unknown`, - url: `${endpoint}/unknown`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ - error: 'unknown cache admin action: unknown', - }); - }); - }); - - describe('when the cache throws while handling a request', () => { - it('returns a 500 with the error message', async () => { - const brokenCache = { - ...cache, - entries: vi.fn().mockRejectedValue(new Error('storage offline')), - }; - const middleware = createCacheAdminMiddleware({ cache: brokenCache, endpoint }); - const res = createMockRes(); - - await middleware( - { - method: 'GET', - path: `${endpoint}/entries`, - url: `${endpoint}/entries`, - body: {}, - query: {}, - } as ExpressRequest, - res, - createMockNext() - ); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'storage offline' }); - }); + it('marks matching entries stale by tag', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: { tags: [cacheKey] }, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ marked: 1 }); + expect((await cache.get(cacheKey)).kind).toBe('stale'); }); }); diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/cache-admin-middleware.ts index dffd810ca7..2de532d147 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.ts @@ -27,13 +27,9 @@ const DEFAULT_ENDPOINT = '/api/_cache'; /** * Lightweight admin surface for the loader cache: * GET <endpoint>/entries → list entries (metadata only, no values) - * POST <endpoint>/invalidate → invalidate by InvalidateInput (JSON body) + * POST <endpoint>/invalidate → mark stale by tags (JSON body) * POST <endpoint>/flush → flush every entry * GET <endpoint>/config → resolved config (for the demo UI) - * - * The cache reference is captured in closure at construction time; this - * middleware does not read `req.loaderCache` or any other property off the - * request. * @public */ export function createCacheAdminMiddleware( @@ -69,14 +65,13 @@ export function createCacheAdminMiddleware( if (action === 'invalidate' && req.method === 'POST') { const body = (req.body ?? {}) as Partial<InvalidateInput>; - const hasRoute = typeof body.route === 'string' && body.route.length > 0; const hasTags = Array.isArray(body.tags) && body.tags.length > 0; - if (!hasRoute && !hasTags) { - res.status(400).json({ error: 'at least one of `route` or `tags` is required' }); + if (!hasTags) { + res.status(400).json({ error: 'non-empty `tags` array is required' }); return; } - const deleted = await cache.invalidate(body as InvalidateInput); - res.status(200).json({ deleted }); + const marked = await cache.invalidate(body as InvalidateInput); + res.status(200).json({ marked }); return; } diff --git a/packages/angular/src/server/cache/cache-key.spec.ts b/packages/angular/src/server/cache/cache-key.spec.ts index 3ff9255b52..13ef47c2fd 100644 --- a/packages/angular/src/server/cache/cache-key.spec.ts +++ b/packages/angular/src/server/cache/cache-key.spec.ts @@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest'; import type { LoaderContext } from '../../loaders/models'; import { buildCacheKey, - buildDefaultTags, + buildPageCacheKey, + buildDictionaryCacheKey, CACHE_KEY_PREFIX, - resolveTagsToInvalidate, - serializeKey, + serializeLoaderCacheKey, } from './cache-key'; import type { CacheKeyDimensions } from './models'; @@ -20,121 +20,66 @@ function makeContext(overrides: Partial<LoaderContext> = {}): LoaderContext { } describe('buildCacheKey', () => { - describe('when a loader runs for a localized page route', () => { - it('builds a composite key from site, locale, loader id, and path', () => { - const { key, dimensions } = buildCacheKey('page', makeContext({ url: '/about?preview=1' })); + it('builds sc:loader:page key from site, locale, variant, and pathKey', () => { + const { key, dimensions } = buildCacheKey('page', makeContext({ url: '/about?preview=1' })); - expect(dimensions).toEqual({ - site: 'mysite', - locale: 'en', - variantId: 'default', - loaderId: 'page', - route: '/about', - }); - expect(key).toBe( - `${CACHE_KEY_PREFIX}:mysite:en:default:page:${encodeURIComponent('/about')}` - ); + expect(dimensions).toEqual({ + site: 'mysite', + locale: 'en', + variantId: 'default', + loaderId: 'page', + pathKey: 'about', }); + expect(key).toBe('sc:loader:page:mysite:en:default:about'); }); - describe('when route params omit site or locale', () => { - it('defaults site to "default" and locale to "en"', () => { - const { dimensions } = buildCacheKey( - 'page', - makeContext({ params: {}, url: '/home' }) - ); + it('uses _ pathKey for home route', () => { + const { dimensions } = buildCacheKey('page', makeContext({ url: '/' })); + expect(dimensions.pathKey).toBe('_'); + }); - expect(dimensions.site).toBe('default'); - expect(dimensions.locale).toBe('en'); - expect(dimensions.route).toBe('/home'); - }); + it('strips locale prefix from url when it matches params.locale', () => { + const { dimensions } = buildCacheKey( + 'page', + makeContext({ url: '/en/about', params: { site: 'mysite', locale: 'en' } }) + ); + expect(dimensions.pathKey).toBe('about'); + }); + + it('builds dictionary key without variant or path', () => { + const { key } = buildCacheKey('dictionary', makeContext()); + expect(key).toBe('sc:loader:dictionary:mysite:en'); + }); + + it('defaults site and locale when params omit them', () => { + const { dimensions } = buildCacheKey('page', makeContext({ params: {}, url: '/home' })); + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.pathKey).toBe('home'); }); }); -describe('serializeKey', () => { - it('joins identity dimensions with the scLoader prefix', () => { - const dimensions: CacheKeyDimensions = { +describe('serializeLoaderCacheKey', () => { + it('dispatches page and dictionary shapes', () => { + const pageDims: CacheKeyDimensions = { site: 'demo', locale: 'de', variantId: 'default', loaderId: 'page', - route: '/products/shoes', + pathKey: 'products/shoes', }; - - expect(serializeKey(dimensions)).toBe( - `${CACHE_KEY_PREFIX}:demo:de:default:page:${encodeURIComponent('/products/shoes')}` - ); - }); -}); - -describe('buildDefaultTags', () => { - it('mirrors each cache dimension as a tag for grouped invalidation', () => { - const dimensions: CacheKeyDimensions = { + const dictDims: CacheKeyDimensions = { site: 'demo', - locale: 'fr', + locale: 'de', variantId: 'default', - loaderId: 'page', - route: '/news', + loaderId: 'dictionary', + pathKey: '_', }; - expect(buildDefaultTags(dimensions)).toEqual([ - 'site:demo', - 'locale:fr', - 'variant:default', - 'loader:page', - `route:${encodeURIComponent('/news')}`, - ]); - }); -}); - -describe('resolveTagsToInvalidate', () => { - const defaultSite = 'corporate'; - - describe('when invalidating a single route on the default site', () => { - it('requires both the default site tag and the route tag', () => { - expect(resolveTagsToInvalidate({ route: '/about' }, defaultSite)).toEqual([ - `site:${encodeURIComponent('corporate')}`, - `route:${encodeURIComponent('/about')}`, - ]); - }); - }); - - describe('when invalidating across every site', () => { - it('omits the site tag when site is "*"', () => { - expect(resolveTagsToInvalidate({ route: '/about', site: '*' }, defaultSite)).toEqual([ - `route:${encodeURIComponent('/about')}`, - ]); - }); - }); - - describe('when narrowing by language, loader, or variant', () => { - it('adds a tag for each supplied dimension', () => { - expect( - resolveTagsToInvalidate( - { - route: '/products', - site: 'shop', - language: 'de', - loaderId: 'page', - variantId: 'personalized-a', - }, - defaultSite - ) - ).toEqual([ - `site:${encodeURIComponent('shop')}`, - `locale:${encodeURIComponent('de')}`, - `variant:${encodeURIComponent('personalized-a')}`, - `loader:${encodeURIComponent('page')}`, - `route:${encodeURIComponent('/products')}`, - ]); - }); - }); - - describe('when invalidating by custom tags', () => { - it('passes custom tags through without adding a prefix', () => { - expect( - resolveTagsToInvalidate({ tags: ['featured', 'category:news'] }, defaultSite) - ).toEqual(['site:corporate', 'featured', 'category:news']); - }); + expect(buildPageCacheKey(pageDims)).toBe( + `${CACHE_KEY_PREFIX}:page:demo:de:default:products/shoes` + ); + expect(buildDictionaryCacheKey(dictDims)).toBe(`${CACHE_KEY_PREFIX}:dictionary:demo:de`); + expect(serializeLoaderCacheKey(pageDims)).toBe(buildPageCacheKey(pageDims)); }); }); diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index 7baf3b36fa..2a1f1461d2 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -1,70 +1,59 @@ import type { LoaderContext } from '../../loaders/models'; import { CacheKeyDimensions } from './models'; import { dimensionsFromContext } from './utils'; -import { InvalidateInput } from '../../loaders/models'; +import { sanitizeSitecoreCacheSegment } from './utils'; +import { SITECORE_CONTENT_CACHE_TAG_PREFIX } from './cache-tags'; -export const CACHE_KEY_PREFIX = 'scLoader'; +/** Prefix for OSR-aligned loader cache keys (`sc:loader:…`). @public */ +export const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`; /** - * Compose the canonical cache key. - * Format: loader:<namespace?>:<site>:<language>:<variantId>:<loaderId>:<route>:<paramsHash> + * Compose the canonical cache key for a loader invocation. + * @public */ export function buildCacheKey( loaderId: string, ctx: LoaderContext ): { key: string; dimensions: CacheKeyDimensions } { const dimensions = dimensionsFromContext(loaderId, ctx); - const key = serializeKey(dimensions); + const key = serializeLoaderCacheKey(dimensions); return { key, dimensions }; } -export function serializeKey(dimensions: CacheKeyDimensions): string { - return [ - CACHE_KEY_PREFIX, - encodeURIComponent(dimensions.site), - encodeURIComponent(dimensions.locale), - encodeURIComponent(dimensions.variantId), - encodeURIComponent(dimensions.loaderId), - encodeURIComponent(dimensions.route), - ].join(':'); -} - /** - * Tag list mirrored alongside each entry — used by invalidate() to find matching keys. + * Serializes cache key dimensions into the public `sc:loader:…` format. + * @public */ -export function buildDefaultTags(dimensions: CacheKeyDimensions): string[] { - return [ - `site:${encodeURIComponent(dimensions.site)}`, - `locale:${encodeURIComponent(dimensions.locale)}`, - `variant:${encodeURIComponent(dimensions.variantId)}`, - `loader:${encodeURIComponent(dimensions.loaderId)}`, - `route:${encodeURIComponent(dimensions.route)}`, - ]; +export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string { + if (dimensions.loaderId === 'page') { + return buildPageCacheKey(dimensions); + } + if (dimensions.loaderId === 'dictionary') { + return buildDictionaryCacheKey(dimensions); + } + return buildGenericLoaderCacheKey(dimensions); } -/** - * Resolve an InvalidateInput into the set of tags that an entry must carry to match. - * Omitted dimensions widen to "all" (no tag constraint on that axis); - * `site` defaults to `defaultSiteName` unless explicitly '*'. - * - * At least one of `filter.route` or `filter.tags` should be set; otherwise the - * returned list contains only the site constraint (which would match every - * entry on the default site). Callers (admin middleware, CLI) enforce that - * precondition. - */ -export function resolveTagsToInvalidate( - filter: InvalidateInput, - defaultSiteName: string -): string[] { - const tags: string[] = []; +/** @public */ +export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId); + return `${CACHE_KEY_PREFIX}:page:${site}:${locale}:${variantId}:${dimensions.pathKey}`; +} + +/** @public */ +export function buildDictionaryCacheKey(dimensions: CacheKeyDimensions): string { + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + return `${CACHE_KEY_PREFIX}:dictionary:${site}:${locale}`; +} - const site = filter.site === '*' ? null : filter.site ?? defaultSiteName; - if (site) tags.push(`site:${encodeURIComponent(site)}`); - if (filter.language) tags.push(`locale:${encodeURIComponent(filter.language)}`); - if (filter.variantId) tags.push(`variant:${encodeURIComponent(filter.variantId)}`); - if (filter.loaderId) tags.push(`loader:${encodeURIComponent(filter.loaderId)}`); - if (filter.route) tags.push(`route:${encodeURIComponent(filter.route)}`); - // Custom tags are matched verbatim (no prefix transformation). - if (filter.tags?.length) tags.push(...filter.tags); - return tags; +/** @public */ +export function buildGenericLoaderCacheKey(dimensions: CacheKeyDimensions): string { + const loaderId = sanitizeSitecoreCacheSegment(dimensions.loaderId); + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId); + return `${CACHE_KEY_PREFIX}:${loaderId}:${site}:${locale}:${variantId}:${dimensions.pathKey}`; } diff --git a/packages/angular/src/server/cache/cache-tags.ts b/packages/angular/src/server/cache/cache-tags.ts new file mode 100644 index 0000000000..a5c14ce64e --- /dev/null +++ b/packages/angular/src/server/cache/cache-tags.ts @@ -0,0 +1,159 @@ +import type { RouteData } from '@sitecore-content-sdk/content/layout'; +import type { Page } from '@sitecore-content-sdk/content/client'; +import { + normalizeSitecoreItemIdForCacheKey, + sanitizeSitecoreCacheSegment, + dedupeCacheStrings, +} from './utils'; +import type { CacheKeyDimensions } from './models'; + +/** Sitecore `sc:` namespace prefix for cache tags. */ +export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; + +/** + * Parameters for {@link buildSitecoreItemCacheTag}. + * @internal + */ +export type BuildSitecoreItemCacheTagParams = { + itemId: string; + locale: string; + version?: number; +}; + +/** + * Tag for a layout/route item. Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. + * @internal + */ +export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParams): string { + const id = normalizeSitecoreItemIdForCacheKey(params.itemId); + const locale = sanitizeSitecoreCacheSegment(params.locale); + const ver = + params.version !== undefined && Number.isFinite(params.version) + ? `v${Math.trunc(params.version)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +/** + * Tag for dictionary data scoped to site + locale. + * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. + * @internal + */ +export function buildSitecoreDictionaryCacheTag(params: { site: string; locale: string }): string { + const site = sanitizeSitecoreCacheSegment(params.site); + const locale = sanitizeSitecoreCacheSegment(params.locale); + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; +} + +/** + * Builds an item cache tag from layout route data when `itemId` is present. + * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. + * @internal + */ +export function buildSitecoreItemCacheTagFromRouteData( + route: RouteData | null | undefined, + fallbackLocale: string +): string | null { + if (!route?.itemId) { + return null; + } + const locale = route.itemLanguage + ? sanitizeSitecoreCacheSegment(route.itemLanguage) + : sanitizeSitecoreCacheSegment(fallbackLocale); + const id = normalizeSitecoreItemIdForCacheKey(route.itemId); + const ver = + route.itemVersion !== undefined && Number.isFinite(route.itemVersion) + ? `v${Math.trunc(route.itemVersion)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +/** + * Builds loader-cache dictionary tags for webhook fan-out (`sc:loader:dictionary:…`). + * @internal + */ +export function buildLoaderDictionaryCacheTagsFromSites(params: { + sites: readonly { name: string; language?: string }[]; + baseLocale: string; +}): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const site of params.sites) { + const locale = site.language?.trim() ? site.language : params.baseLocale; + const tag = buildLoaderDictionaryCacheTag({ site: site.name, locale }); + if (!seen.has(tag)) { + seen.add(tag); + out.push(tag); + } + } + return out; +} + +/** + * Cache key / self-tag for the dictionary loader. + * @internal + */ +export function buildLoaderDictionaryCacheTag(params: { site: string; locale: string }): string { + const site = sanitizeSitecoreCacheSegment(params.site); + const locale = sanitizeSitecoreCacheSegment(params.locale); + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:${site}:${locale}`; +} + +/** + * Site-wide fan-out tag. + * @internal + */ +export function buildSitecoreSiteCacheTag(site: string): string { + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:${sanitizeSitecoreCacheSegment(site)}`; +} + +/** + * Locale-wide fan-out tag. + * @internal + */ +export function buildSitecoreLocaleCacheTag(locale: string): string { + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:${sanitizeSitecoreCacheSegment(locale)}`; +} + +/** + * Builds the tag set written alongside a loader cache entry (Phase 3 OSR alignment). + * Includes self-key, site, locale, item (page loader), and Next.js-compatible dict tag. + * @internal + */ +export function buildLoaderCacheTags( + loaderId: string, + dimensions: CacheKeyDimensions, + cacheKey: string, + loaderValue?: unknown, + customTags: string[] = [] +): string[] { + const tags: string[] = [ + cacheKey, + buildSitecoreSiteCacheTag(dimensions.site), + buildSitecoreLocaleCacheTag(dimensions.locale), + ...customTags, + ]; + + if (loaderId === 'page') { + const itemTag = buildPageItemTag(loaderValue, dimensions.locale); + if (itemTag) { + tags.push(itemTag); + } + } + + if (loaderId === 'dictionary') { + tags.push( + buildSitecoreDictionaryCacheTag({ site: dimensions.site, locale: dimensions.locale }) + ); + } + + return dedupeCacheStrings(tags); +} + +function buildPageItemTag(value: unknown, fallbackLocale: string): string | null { + if (!value || typeof value !== 'object') { + return null; + } + const page = value as Page; + return buildSitecoreItemCacheTagFromRouteData(page.layout?.sitecore?.route, fallbackLocale); +} diff --git a/packages/angular/src/server/cache/default-in-memory-cache.ts b/packages/angular/src/server/cache/default-in-memory-cache.ts index 7dfc1c90af..12e31d6ca3 100644 --- a/packages/angular/src/server/cache/default-in-memory-cache.ts +++ b/packages/angular/src/server/cache/default-in-memory-cache.ts @@ -1,81 +1,99 @@ -import { resolveTagsToInvalidate } from './cache-key'; import { - GlobalLoaderCacheConfig, InvalidateInput, LoaderCache, + LoaderCacheConfig, LoaderCacheEntry, LoaderCacheEntryInfo, + LoaderCacheReadResult, } from '../../loaders/models'; -import { ResolvedConfig } from './models'; +import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; /** - * Default LoaderCache implementation: single in-process Map, O(N) tag-scan - * invalidation. Suitable for single-process deployments and demos. - * - * Not exported. Driver variants (unstorage memory/fs/redis) live in sibling - * classes that implement the same {@link LoaderCache} interface. + * Default LoaderCache implementation: in-process Map + tag → keys index. * @internal */ export class InMemoryLoaderCache implements LoaderCache { - private readonly config: ResolvedConfig; + private readonly config: Required<LoaderCacheConfig>; private readonly store = new Map<string, LoaderCacheEntry>(); + private readonly tagIndex = new InMemoryTagIndex(); - constructor(config: ResolvedConfig) { - this.config = config; + constructor(config: LoaderCacheConfig = {}) { + this.config = applyLoaderCacheConfigDefaults(config); } - async get(key: string): Promise<LoaderCacheEntry | null> { + async get(key: string): Promise<LoaderCacheReadResult> { const entry = this.store.get(key); - if (!entry) return null; - if (this.isExpired(entry)) { - this.store.delete(key); - return null; - } - return entry; + return evaluateCacheRead(key, entry ?? null); } async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + const existing = this.store.get(key); + if (existing) { + this.tagIndex.unlink(key, existing.tags); + } + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; this.store.set(key, { value, tags: [...tags], storedAt: Date.now(), expiresAt, + stale: false, }); + this.tagIndex.link(key, tags); } async invalidate(filter: InvalidateInput): Promise<number> { - const required = resolveTagsToInvalidate(filter, this.config.defaultSiteName); - let deleted = 0; - for (const [key, entry] of this.store) { - if (required.every((tag) => entry.tags.includes(tag))) { - this.store.delete(key); - deleted++; + const tags = filter.tags ?? []; + if (tags.length === 0) { + return 0; + } + const keys = this.tagIndex.resolveKeys(tags); + let marked = 0; + for (const key of keys) { + if (await this.markStale(key)) { + marked++; } } - return deleted; + return marked; + } + + async markStale(key: string): Promise<boolean> { + const entry = this.store.get(key); + if (!entry) { + return false; + } + if (entry.stale) { + return true; + } + this.store.set(key, { ...entry, stale: true }); + return true; } async delete(key: string): Promise<boolean> { - return this.store.delete(key); + const entry = this.store.get(key); + if (!entry) { + return false; + } + this.tagIndex.unlink(key, entry.tags); + this.store.delete(key); + return true; } async flush(): Promise<void> { this.store.clear(); + this.tagIndex.clear(); } async entries(): Promise<LoaderCacheEntryInfo[]> { const out: LoaderCacheEntryInfo[] = []; for (const [key, entry] of this.store) { - if (this.isExpired(entry)) { - this.store.delete(key); - continue; - } out.push({ key, tags: [...entry.tags], storedAt: entry.storedAt, expiresAt: entry.expiresAt, + stale: entry.stale, }); } return out; @@ -89,11 +107,48 @@ export class InMemoryLoaderCache implements LoaderCache { return this.config.enabled; } - getConfig(): Readonly<GlobalLoaderCacheConfig> { + getConfig(): Readonly<LoaderCacheConfig> { return this.config; } +} + +/** + * In-process tag index: tag → set of cache keys. + * @internal + */ +export class InMemoryTagIndex { + private readonly tagToKeys = new Map<string, Set<string>>(); + + link(cacheKey: string, tags: string[]): void { + for (const tag of tags) { + if (!this.tagToKeys.has(tag)) { + this.tagToKeys.set(tag, new Set()); + } + this.tagToKeys.get(tag)!.add(cacheKey); + } + } + + unlink(cacheKey: string, tags: string[]): void { + for (const tag of tags) { + const keys = this.tagToKeys.get(tag); + keys?.delete(cacheKey); + if (keys?.size === 0) { + this.tagToKeys.delete(tag); + } + } + } + + resolveKeys(tags: string[]): Set<string> { + const out = new Set<string>(); + for (const tag of tags) { + for (const key of this.tagToKeys.get(tag) ?? []) { + out.add(key); + } + } + return out; + } - private isExpired(entry: LoaderCacheEntry): boolean { - return entry.expiresAt !== null && entry.expiresAt <= Date.now(); + clear(): void { + this.tagToKeys.clear(); } } diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts index 0973726d9b..a2ce198ef0 100644 --- a/packages/angular/src/server/cache/index.ts +++ b/packages/angular/src/server/cache/index.ts @@ -1,4 +1,4 @@ -export type { CacheKeyDimensions, ResolvedConfig } from './models'; +export type { CacheKeyDimensions, GlobalLoaderCacheConfig } from './models'; export { createLoaderCache } from './loader-cache'; export { createCacheAdminMiddleware, @@ -6,9 +6,18 @@ export { } from './cache-admin-middleware'; export { buildCacheKey, - buildDefaultTags, - resolveTagsToInvalidate, - serializeKey, + buildPageCacheKey, + buildDictionaryCacheKey, + buildGenericLoaderCacheKey, + serializeLoaderCacheKey, CACHE_KEY_PREFIX, } from './cache-key'; -export { dimensionsFromContext } from './utils'; +export { buildLoaderCacheTags } from './cache-tags'; +export { + buildSitecoreItemCacheTag, + buildSitecoreDictionaryCacheTag, + buildLoaderDictionaryCacheTag, + buildLoaderDictionaryCacheTagsFromSites, + SITECORE_CONTENT_CACHE_TAG_PREFIX, +} from './cache-tags'; +export { dimensionsFromContext, urlToPathKey } from './utils'; diff --git a/packages/angular/src/server/cache/loader-cache.spec.ts b/packages/angular/src/server/cache/loader-cache.spec.ts index eede73c8e2..c1da650349 100644 --- a/packages/angular/src/server/cache/loader-cache.spec.ts +++ b/packages/angular/src/server/cache/loader-cache.spec.ts @@ -1,13 +1,14 @@ /* eslint-disable jsdoc/require-jsdoc */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import memoryDriver from 'unstorage/drivers/memory'; import fsDriver from 'unstorage/drivers/fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import type { LoaderCache, InvalidateInput } from '../../loaders/models'; +import type { LoaderCache } from '../../loaders/models'; import { createLoaderCache } from './loader-cache'; -import { buildCacheKey, buildDefaultTags } from './cache-key'; +import { buildCacheKey } from './cache-key'; +import { buildLoaderCacheTags } from './cache-tags'; import type { LoaderContext } from '../../loaders/models'; const sampleContext: LoaderContext = { @@ -20,9 +21,9 @@ function sampleKey(loaderId = 'page') { return buildCacheKey(loaderId, sampleContext).key; } -function sampleTags(loaderId = 'page') { - const { dimensions } = buildCacheKey(loaderId, sampleContext); - return buildDefaultTags(dimensions); +function sampleTags(loaderId = 'page', value?: unknown) { + const { key, dimensions } = buildCacheKey(loaderId, sampleContext); + return buildLoaderCacheTags(loaderId, dimensions, key, value); } async function runSharedLoaderCacheContract( @@ -43,42 +44,43 @@ async function runSharedLoaderCacheContract( }); describe('when storing and reading loader output', () => { - it('returns null on a cache miss and the stored value on a hit', async () => { + it('returns miss on empty key and hit after set', async () => { const key = sampleKey(); - expect(await cache.get(key)).toBeNull(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); await cache.set(key, { title: 'Products' }, 300, sampleTags()); const hit = await cache.get(key); - expect(hit?.value).toEqual({ title: 'Products' }); - expect(hit?.tags).toEqual(sampleTags()); + expect(hit).toEqual({ kind: 'hit', value: { title: 'Products' }, cacheKey: key }); }); }); describe('when an entry TTL expires', () => { - it('treats the entry as missing and removes it from storage', async () => { + it('returns stale (does not delete) so SWR can serve last-known-good', async () => { vi.useFakeTimers(); const key = sampleKey('expiring'); await cache.set(key, { stale: true }, 30, sampleTags('expiring')); vi.advanceTimersByTime(31_000); - expect(await cache.get(key)).toBeNull(); + const read = await cache.get(key); + expect(read).toEqual({ kind: 'stale', value: { stale: true }, cacheKey: key }); }); }); describe('when ttl is zero or negative', () => { - it('keeps the entry until it is explicitly invalidated', async () => { + it('keeps the entry until explicitly invalidated', async () => { vi.useFakeTimers(); const key = sampleKey('persistent'); await cache.set(key, { permanent: true }, 0, sampleTags('persistent')); vi.advanceTimersByTime(3600_000); - expect(await cache.get(key)).not.toBeNull(); + const read = await cache.get(key); + expect(read.kind).toBe('hit'); }); }); - describe('when invalidating by route tag', () => { - it('deletes only entries whose tags match every required tag', async () => { + describe('when invalidating by tag', () => { + it('marks matching entries stale without deleting them', async () => { const keyA = sampleKey('page'); const keyB = buildCacheKey('footer', { ...sampleContext, @@ -86,21 +88,35 @@ async function runSharedLoaderCacheContract( }).key; await cache.set(keyA, { page: true }, 300, sampleTags('page')); - await cache.set( - keyB, - { footer: true }, - 300, - buildDefaultTags(buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions) + const tagsB = buildLoaderCacheTags( + 'footer', + buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions, + keyB ); + await cache.set(keyB, { footer: true }, 300, tagsB); + + const marked = await cache.invalidate({ tags: ['sc:site:shop'] }); + + expect(marked).toBe(2); + expect(await cache.get(keyA)).toEqual({ + kind: 'stale', + value: { page: true }, + cacheKey: keyA, + }); + expect(await cache.get(keyB)).toEqual({ + kind: 'stale', + value: { footer: true }, + cacheKey: keyB, + }); + }); - const deleted = await cache.invalidate({ - route: '/products', - site: 'shop', - } satisfies InvalidateInput); + it('marks a single entry stale by self-key tag', async () => { + const key = sampleKey('page'); + await cache.set(key, { page: true }, 300, sampleTags('page')); - expect(deleted).toBe(1); - expect(await cache.get(keyA)).toBeNull(); - expect(await cache.get(keyB)).not.toBeNull(); + const marked = await cache.invalidate({ tags: [key] }); + expect(marked).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); }); }); @@ -110,7 +126,7 @@ async function runSharedLoaderCacheContract( await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); expect(await cache.delete(key)).toBe(true); - expect(await cache.get(key)).toBeNull(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); expect(await cache.delete(key)).toBe(false); }); }); @@ -122,24 +138,20 @@ async function runSharedLoaderCacheContract( const key = sampleKey('flush-me'); await cache.set(key, { temp: true }, 300, sampleTags('flush-me')); await cache.flush(); - expect(await cache.get(key)).toBeNull(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); }); }); describe('when listing entries for admin tooling', () => { - it('returns metadata without values and skips expired entries', async () => { - vi.useFakeTimers(); + it('returns metadata without values and includes stale flag', async () => { const liveKey = sampleKey('live'); - const expiredKey = sampleKey('expired-list'); - await cache.set(liveKey, { live: true }, 300, sampleTags('live')); - await cache.set(expiredKey, { expired: true }, 10, sampleTags('expired-list')); - vi.advanceTimersByTime(11_000); + await cache.invalidate({ tags: [liveKey] }); const entries = await cache.entries(); - expect(entries.some((entry) => entry.key === liveKey)).toBe(true); - expect(entries.some((entry) => entry.key === expiredKey)).toBe(false); - expect(entries.find((entry) => entry.key === liveKey)?.tags).toEqual(sampleTags('live')); + const live = entries.find((entry) => entry.key === liveKey); + expect(live?.tags).toEqual(sampleTags('live')); + expect(live?.stale).toBe(true); }); }); @@ -188,7 +200,7 @@ describe('UnstorageLoaderCache (fs driver)', () => { }); const hit = await reader.get(key); - expect(hit?.value).toEqual({ persisted: true }); + expect(hit).toEqual({ kind: 'hit', value: { persisted: true }, cacheKey: key }); }); }); @@ -197,20 +209,19 @@ describe('createLoaderCache factory', () => { const cache = createLoaderCache(); const key = sampleKey('factory-default'); await cache.set(key, { ok: true }, 300, sampleTags('factory-default')); - expect(await cache.get(key)).not.toBeNull(); + expect((await cache.get(key)).kind).toBe('hit'); }); - it('still stores and retrieves entries when a namespace is configured', async () => { + it('uses the unstorage backend when a driver is supplied', async () => { const cache = createLoaderCache({ driver: memoryDriver(), - namespace: 'preview-app', revalidate: 300, }); - const key = sampleKey('namespaced'); - await cache.set(key, { namespaced: true }, 300, sampleTags('namespaced')); + const key = sampleKey('unstorage'); + await cache.set(key, { persisted: true }, 300, sampleTags('unstorage')); expect(await cache.get(key)).toEqual( - expect.objectContaining({ value: { namespaced: true } }) + expect.objectContaining({ kind: 'hit', value: { persisted: true } }) ); }); }); diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index 2125caee68..bb056b1998 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,4 +1,5 @@ -import { LoaderCache, GlobalLoaderCacheConfig } from '../../loaders/models'; +import { LoaderCache } from '../../loaders/models'; +import { GlobalLoaderCacheConfig } from './models'; import { InMemoryLoaderCache } from './default-in-memory-cache'; import { UnstorageLoaderCache } from './unstorage-loader-cache'; import { resolveConfig } from './utils'; diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index 299bbdaad8..a355347982 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -1,3 +1,6 @@ +import { Driver } from 'unstorage'; +import { LoaderCacheConfig } from '../../loaders/models'; + export const DEFAULT_CACHE_TTL = 300; /** @@ -9,28 +12,23 @@ export interface CacheKeyDimensions { locale: string; variantId: string; loaderId: string; - route: string; + pathKey: string; } /** - * Resolved (fully defaulted) config used by every {@link LoaderCache} - * implementation. Exported as `@internal` so sibling impls can share the same - * shape and helper. + * Global config for the loader cache. Consumed by `createLoaderCache()` in + * the app's `server.ts`. * - * Mirrors {@link GlobalLoaderCacheConfig} minus the construction-time `driver` - * field — drivers are turned into a Storage instance in the factory before the - * config reaches a backend. - * @internal + * Drivers are imported and instantiated in the app (e.g. + * `fsDriver({ base: './.cache/loaders' })`) — the package does not own driver + * selection. When `driver` is omitted, the cache falls back to its built-in + * in-memory implementation. + * @public */ -export interface ResolvedConfig { - /** Default TTL in seconds; `0` or negative means "never expire". */ - revalidate: number; - /** Master switch — when false, every call falls through to the raw loader. */ - enabled: boolean; - /** Optional namespace prefix on cache keys (multi-app storage sharing). */ - namespace: string; - /** Site name used by `invalidate({ route })` when no `site` is supplied. */ - defaultSiteName: string; - /** Per-loader overrides keyed by loaderId. */ - loaders: Record<string, import('../../loaders/models').LoaderCacheConfig>; +export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { + /** + * Unstorage `Driver` instance. Pass an imported driver — the cache wraps it + * with `createStorage({ driver })` internally. Omit for the in-memory default. + */ + driver?: Driver; } diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index c8af8f266f..4c6e84460e 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -1,107 +1,107 @@ import { Storage, createStorage, Driver } from 'unstorage'; import { - GlobalLoaderCacheConfig, InvalidateInput, LoaderCache, + LoaderCacheConfig, LoaderCacheEntry, LoaderCacheEntryInfo, + LoaderCacheReadResult, } from '../../loaders/models'; -import { CACHE_KEY_PREFIX, resolveTagsToInvalidate } from './cache-key'; -import { ResolvedConfig } from './models'; +import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; +import { CACHE_KEY_PREFIX } from './cache-key'; +import { GlobalLoaderCacheConfig } from './models'; /** - * Unstorage-backed {@link LoaderCache}. Pluggable across `unstorage` drivers — - * `memory` for dev, `fs` / `fsLite` for single-process persistence, `redis` / - * `vercelKv` / `cloudflareKv` for multi-worker deployments. + * Unstorage-backed {@link LoaderCache}. * - * Entries are stored under the same composite key built by - * {@link buildCacheKey} so prerendered, runtime, and admin-CLI writes collide - * by design (the cross-process consistency promised in plan §4.3). - * - * Invalidation walks every key under the cache prefix and reads each entry's - * tags — O(N) over the cache size. Acceptable up to thousands of entries; a - * driver-native tag index (Redis `SADD`, etc.) is a Phase 3 optimization. + * Two key spaces in one driver: + * - `{cacheKey}` → loader entry (value + metadata; tags copied on the entry) + * - `tag:{tag}` → `string[]` of cache keys pointing at that entry * @internal */ +/** Prefix for tag-index keys in unstorage (entries use `sc:loader:…` keys directly). */ +const TAG_INDEX_PREFIX = 'tag:'; + export class UnstorageLoaderCache implements LoaderCache { private readonly storage: Storage; - private readonly config: ResolvedConfig; - /** Prefix passed to `storage.getKeys()` / `storage.clear()` for scoped scans. */ - private readonly keyPrefix: string; + private readonly config: Required<LoaderCacheConfig>; - constructor(driver: Driver, config: ResolvedConfig) { + constructor(driver: Driver, config: LoaderCacheConfig = {}) { this.storage = createStorage({ driver }); - this.config = config; - // Mirrors the serializeKey() prefix in cache-key.ts so getKeys() returns - // only this cache's entries — never anything else the user stores in the - // same Storage instance. Namespace is appended when configured. - this.keyPrefix = config.namespace - ? `${CACHE_KEY_PREFIX}:${config.namespace}` - : CACHE_KEY_PREFIX; + this.config = applyLoaderCacheConfigDefaults(config); } - async get(key: string): Promise<LoaderCacheEntry | null> { - const entry = await this.storage.getItem<LoaderCacheEntry>(key); - if (!entry) return null; - if (this.isExpired(entry)) { - await this.storage.removeItem(key); - return null; - } - return entry; + async get(cacheKey: string): Promise<LoaderCacheReadResult> { + const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); + return evaluateCacheRead(cacheKey, entry ?? null); } - async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + async set(cacheKey: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { + const existing = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); + if (existing) { + await this.unlinkTags(cacheKey, existing.tags); + } + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; const entry: LoaderCacheEntry = { value, tags: [...tags], storedAt: Date.now(), expiresAt, + stale: false, }; - await this.storage.setItem(key, entry); + await this.storage.setItem(this.cacheStorageKey(cacheKey), entry); + await this.linkTags(cacheKey, tags); } async invalidate(filter: InvalidateInput): Promise<number> { - const tags = resolveTagsToInvalidate(filter, this.config.defaultSiteName); - const keys = await this.storage.getKeys(this.keyPrefix); - let deleted = 0; - for (const key of keys) { - const entry = await this.storage.getItem<LoaderCacheEntry>(key); - if (!entry) continue; - if (tags.every((tag) => entry.tags.includes(tag))) { - await this.storage.removeItem(key); - deleted++; + const tags = filter.tags ?? []; + if (tags.length === 0) { + return 0; + } + const keys = await this.resolveCacheKeysFromTags(tags); + let marked = 0; + for (const cacheKey of keys) { + const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); + if (!entry) { + continue; + } + if (!entry.stale) { + await this.storage.setItem(this.cacheStorageKey(cacheKey), { ...entry, stale: true }); } + marked++; } - return deleted; + return marked; } - async delete(key: string): Promise<boolean> { - const had = await this.storage.hasItem(key); - if (!had) return false; - await this.storage.removeItem(key); + async delete(cacheKey: string): Promise<boolean> { + const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); + if (!entry) { + return false; + } + await this.unlinkTags(cacheKey, entry.tags); + await this.storage.removeItem(this.cacheStorageKey(cacheKey)); return true; } async flush(): Promise<void> { - await this.storage.clear(this.keyPrefix); + await this.storage.clear(); } async entries(): Promise<LoaderCacheEntryInfo[]> { - const keys = await this.storage.getKeys(this.keyPrefix); + const keys = await this.storage.getKeys(CACHE_KEY_PREFIX); const out: LoaderCacheEntryInfo[] = []; - for (const key of keys) { - const entry = await this.storage.getItem<LoaderCacheEntry>(key); - if (!entry) continue; - if (this.isExpired(entry)) { - await this.storage.removeItem(key); + for (const cacheKey of keys) { + const entry = await this.storage.getItem<LoaderCacheEntry>(cacheKey); + if (!entry) { continue; } out.push({ - key, + key: cacheKey, tags: [...entry.tags], storedAt: entry.storedAt, expiresAt: entry.expiresAt, + stale: entry.stale, }); } return out; @@ -119,7 +119,47 @@ export class UnstorageLoaderCache implements LoaderCache { return this.config; } - private isExpired(entry: LoaderCacheEntry): boolean { - return entry.expiresAt !== null && entry.expiresAt <= Date.now(); + /** Cache entry: OSR-aligned `sc:loader:…` key → loader payload. */ + private cacheStorageKey(cacheKey: string): string { + return cacheKey; + } + + /** Tag index: `tag:{tag}` → cache keys. */ + private tagStorageKey(tag: string): string { + return `${TAG_INDEX_PREFIX}${tag}`; + } + + private async linkTags(cacheKey: string, tags: string[]): Promise<void> { + for (const tag of tags) { + const storageKey = this.tagStorageKey(tag); + const current = (await this.storage.getItem<string[]>(storageKey)) ?? []; + if (!current.includes(cacheKey)) { + await this.storage.setItem(storageKey, [...current, cacheKey]); + } + } + } + + private async unlinkTags(cacheKey: string, tags: string[]): Promise<void> { + for (const tag of tags) { + const storageKey = this.tagStorageKey(tag); + const current = (await this.storage.getItem<string[]>(storageKey)) ?? []; + const next = current.filter((k) => k !== cacheKey); + if (next.length === 0) { + await this.storage.removeItem(storageKey); + } else { + await this.storage.setItem(storageKey, next); + } + } + } + + private async resolveCacheKeysFromTags(tags: string[]): Promise<Set<string>> { + const out = new Set<string>(); + for (const tag of tags) { + const keys = (await this.storage.getItem<string[]>(this.tagStorageKey(tag))) ?? []; + for (const key of keys) { + out.add(key); + } + } + return out; } } diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts index 52fa2dbc13..9ae6372841 100644 --- a/packages/angular/src/server/cache/utils.spec.ts +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -1,72 +1,65 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect } from 'vitest'; -import { approxByteSize, dimensionsFromContext, resolveConfig } from './utils'; +import { approxByteSize, dimensionsFromContext, resolveConfig, applyLoaderCacheConfigDefaults, urlToPathKey } from './utils'; import { DEFAULT_CACHE_TTL } from './models'; +describe('urlToPathKey', () => { + it('sanitizes path segments and uses _ for home', () => { + expect(urlToPathKey('/')).toBe('_'); + expect(urlToPathKey('/About Us')).toBe('about_us'); + expect(urlToPathKey('/products/shoes')).toBe('products/shoes'); + }); + + it('strips locale prefix when provided', () => { + expect(urlToPathKey('/en/about', 'en')).toBe('about'); + }); +}); + describe('dimensionsFromContext', () => { - describe('when building cache dimensions from a loader context', () => { - it('reads site and locale from route params and strips query strings from the url', () => { - const dimensions = dimensionsFromContext('page', { - url: '/articles/1?ref=email', - params: { site: 'blog', locale: 'de' }, - query: {}, - }); + it('reads site and locale from route params and derives pathKey', () => { + const dimensions = dimensionsFromContext('page', { + url: '/articles/1?ref=email', + params: { site: 'blog', locale: 'de' }, + query: {}, + }); - expect(dimensions).toEqual({ - site: 'blog', - locale: 'de', - variantId: 'default', - loaderId: 'page', - route: '/articles/1', - }); + expect(dimensions).toEqual({ + site: 'blog', + locale: 'de', + variantId: 'default', + loaderId: 'page', + pathKey: 'articles/1', }); }); - describe('when params are missing', () => { - it('falls back to default site, locale, and root route', () => { - const dimensions = dimensionsFromContext('page', { - url: '', - params: {}, - query: {}, - }); - - expect(dimensions.site).toBe('default'); - expect(dimensions.locale).toBe('en'); - expect(dimensions.route).toBe('/'); + it('falls back to default site, locale, and home pathKey', () => { + const dimensions = dimensionsFromContext('page', { + url: '', + params: {}, + query: {}, }); + + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.pathKey).toBe('_'); }); }); describe('resolveConfig', () => { - describe('when the app passes a partial cache config', () => { - it('applies defaults for ttl, enabled flag, namespace, and default site name', () => { - expect(resolveConfig({})).toEqual({ - revalidate: DEFAULT_CACHE_TTL, - enabled: true, - namespace: '', - defaultSiteName: 'default', - loaders: {}, - }); - }); + it('strips driver from global cache config', () => { + expect(resolveConfig({ driver: {} as never, revalidate: 60 })).toEqual({ revalidate: 60 }); }); +}); - describe('when the app overrides cache settings', () => { - it('keeps the supplied values intact', () => { - expect( - resolveConfig({ - revalidate: 60, - enabled: false, - namespace: 'preview', - defaultSiteName: 'shop', - loaders: { page: { revalidate: 120 } }, - }) - ).toEqual({ - revalidate: 60, - enabled: false, - namespace: 'preview', - defaultSiteName: 'shop', - loaders: { page: { revalidate: 120 } }, - }); +describe('applyLoaderCacheConfigDefaults', () => { + it('applies defaults for every config field', () => { + expect(applyLoaderCacheConfigDefaults({})).toEqual({ + revalidate: DEFAULT_CACHE_TTL, + enabled: true, + defaultSiteName: 'default', + tags: [], + sites: [], + defaultLocale: 'en', }); }); }); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 3fa9d51627..1760d04b1c 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -1,5 +1,10 @@ -import { ResolvedConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; -import { GlobalLoaderCacheConfig, LoaderContext } from '../../loaders/models'; +import { + LoaderCacheConfig, + LoaderCacheReadResult, + LoaderCacheEntry, + LoaderContext, +} from '../../loaders/models'; +import { GlobalLoaderCacheConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; /** * @deprecated only used for demo purposes. remove before release. @@ -12,42 +17,122 @@ export function approxByteSize(value: unknown): number { } } +function stripQuery(url: string): string { + const i = url.indexOf('?'); + return i === -1 ? url : url.slice(0, i); +} + /** - * Builder hook for tests and the admin endpoint: turn a LoaderContext into the - * dimensions used by the key + tag builders. + * Converts a loader URL to the pathKey segment used in OSR-aligned cache keys. + * Strips an optional leading locale segment when it matches `params.locale`. + * @internal + */ +export function urlToPathKey(url: string, locale?: string): string { + const pathname = stripQuery(url || '/').replace(/^\/+|\/+$/g, ''); + let segments = pathname ? pathname.split('/').filter(Boolean) : []; + if (locale && segments[0]?.toLowerCase() === locale.toLowerCase()) { + segments = segments.slice(1); + } + if (segments.length === 0) { + return '_'; + } + return segments.map((segment) => sanitizeSitecoreCacheSegment(segment)).join('/'); +} + +/** + * Builder hook for tests and the admin endpoint. * @internal */ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { const params = (ctx.params ?? {}) as Record<string, unknown>; const site = (params?.['site'] as string) || 'default'; const locale = (params?.['locale'] as string) || 'en'; - const route = stripQuery(ctx.url || '/'); + const pathKey = urlToPathKey(ctx.url || '/', locale); return { site, locale, variantId: 'default', loaderId, - route, + pathKey, }; } -function stripQuery(url: string): string { - const i = url.indexOf('?'); - return i === -1 ? url : url.slice(0, i); +/** + * Strips `driver` from {@link GlobalLoaderCacheConfig}. + * @internal + */ +export function resolveConfig(config: GlobalLoaderCacheConfig): LoaderCacheConfig { + const { driver: _, ...rest } = config; + return rest; } /** - * Build a {@link ResolvedConfig} from a {@link GlobalLoaderCacheConfig}. - * Shared by every backend so config semantics stay identical regardless of driver. + * Applies defaults for every {@link LoaderCacheConfig} field. * @internal */ -export function resolveConfig(config: GlobalLoaderCacheConfig): ResolvedConfig { +export function applyLoaderCacheConfigDefaults( + config: LoaderCacheConfig = {} +): Required<LoaderCacheConfig> { return { revalidate: config.revalidate ?? DEFAULT_CACHE_TTL, enabled: config.enabled ?? true, - namespace: config.namespace ?? '', defaultSiteName: config.defaultSiteName ?? 'default', - loaders: config.loaders ?? {}, + tags: config.tags ?? [], + sites: config.sites ?? [], + defaultLocale: config.defaultLocale ?? 'en', }; } + +/** + * Maps a stored entry to the three-outcome read result used by the resolver (Phase 3 SWR). + * @internal + */ +export function evaluateCacheRead( + cacheKey: string, + entry: LoaderCacheEntry | null | undefined, + now = Date.now() +): LoaderCacheReadResult { + if (!entry) { + return { kind: 'miss', cacheKey }; + } + if (entry.stale || (entry.expiresAt !== null && entry.expiresAt <= now)) { + return { kind: 'stale', value: entry.value, cacheKey }; + } + return { kind: 'hit', value: entry.value, cacheKey }; +} + +/** + * Sanitizes a segment for Sitecore cache keys and tags. + * @internal + */ +export function sanitizeSitecoreCacheSegment(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[/:\s]+/g, '_'); +} + +/** + * Normalizes a Sitecore item GUID for cache keys/tags (lowercase, no braces). + * @internal + */ +export function normalizeSitecoreItemIdForCacheKey(itemId: string): string { + return itemId.trim().toLowerCase().replace(/[{}]/g, ''); +} + +/** + * Deduplicates strings while preserving first-seen order. + * @internal + */ +export function dedupeCacheStrings(values: string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const value of values) { + if (!seen.has(value)) { + seen.add(value); + out.push(value); + } + } + return out; +} diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index 6ee2dc77b3..1123ff4379 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -11,10 +11,11 @@ export { DataHandlerConfig, } from './models'; -export { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; export { ServerLoaderDataProvider } from './loader-data.provider'; export { provideServerLoaderDataProvider } from './provide-server-loader-data-provider'; +export * from './middleware'; + // Loader cache (server-only). Browser code must not reach createLoaderCache — // see plan §1 (Browser safety). The exports here are types + server factories; // they tree-shake out of the browser bundle when not referenced. diff --git a/packages/angular/src/server/loader-data.provider.spec.ts b/packages/angular/src/server/loader-data.provider.spec.ts index 19e52f112b..3cd2323002 100644 --- a/packages/angular/src/server/loader-data.provider.spec.ts +++ b/packages/angular/src/server/loader-data.provider.spec.ts @@ -47,7 +47,7 @@ describe('ServerLoaderDataProvider', () => { it('should return cached data without invoking loader', async () => { const cache: LoaderCache = { - get: vi.fn().mockResolvedValue({ value: { cached: true } }), + get: vi.fn().mockResolvedValue({ kind: 'hit', value: { cached: true }, cacheKey: 'k' }), set: vi.fn(), invalidate: vi.fn(), delete: vi.fn(), @@ -109,7 +109,7 @@ describe('ServerLoaderDataProvider', () => { it('should store loader result in cache when cacheable', async () => { const cache: LoaderCache = { - get: vi.fn().mockResolvedValue(null), + get: vi.fn().mockResolvedValue({ kind: 'miss', cacheKey: 'k' }), set: vi.fn(), invalidate: vi.fn(), delete: vi.fn(), @@ -186,7 +186,7 @@ describe('ServerLoaderDataProvider', () => { status: 302, }); const cache: LoaderCache = { - get: vi.fn().mockResolvedValue(null), + get: vi.fn().mockResolvedValue({ kind: 'miss', cacheKey: 'k' }), set: vi.fn(), invalidate: vi.fn(), delete: vi.fn(), diff --git a/packages/angular/src/server/loader-data.provider.ts b/packages/angular/src/server/loader-data.provider.ts index 46632da869..cb26702bd7 100644 --- a/packages/angular/src/server/loader-data.provider.ts +++ b/packages/angular/src/server/loader-data.provider.ts @@ -6,19 +6,18 @@ import { LoaderDataResult, } from '../loaders/models'; import { LoaderRegistry } from '../loaders/loader-registry.token'; -import { buildCacheKey, buildDefaultTags } from './cache/cache-key'; +import { buildCacheKey } from './cache/cache-key'; +import { buildLoaderCacheTags } from './cache/cache-tags'; /** - * Server-side loader data provider. Runs loaders from the shared cross-boundary - * {@link LoaderRegistry} with optional global {@link LoaderCache} backing. - * Used by Express middleware and SSR (via {@link SERVER_LOADER_DATA_PROVIDER}). + * Server-side loader data provider with stale-while-revalidate cache reads (Phase 3). * @public */ export class ServerLoaderDataProvider { - constructor( - private readonly registry: LoaderRegistry, - private readonly cache?: LoaderCache - ) {} + /** Process-wide coalescing for stale-while-revalidate background refreshes. */ + private static readonly pendingCacheOps = new Set<string>(); + + constructor(private readonly registry: LoaderRegistry, private readonly cache?: LoaderCache) {} /** * Resolve loader data: check cache, run loader on miss, store result. @@ -34,16 +33,67 @@ export class ServerLoaderDataProvider { const ctx: LoaderContext = { url, params, query, requestContext: angularRequestContext }; - const cacheable = this.cache && (cacheOptions?.enabled || this.cache.enabled()); + const cacheable = this.cache && (cacheOptions?.enabled ?? this.cache.enabled()); if (cacheable) { const { key } = buildCacheKey(loaderId, ctx); - const hit = await this.cache!.get(key); - if (hit) { - return { kind: 'data', data: hit.value }; + const read = await this.cache!.get(key); + + if (read.kind === 'hit') { + return { kind: 'data', data: read.value }; + } + + if (read.kind === 'stale') { + this.scheduleBackgroundRefresh(request, ctx, key, cacheOptions); + return { kind: 'data', data: read.value }; } } + return this.runLoader({ request, ctx, cacheable: !!cacheable }); + } + + private scheduleBackgroundRefresh( + request: LoaderApiRequest, + ctx: LoaderContext, + cacheKey: string, + cacheOptions: LoaderApiRequest['cacheOptions'] + ): void { + if (ServerLoaderDataProvider.pendingCacheOps.has(cacheKey)) { + return; + } + ServerLoaderDataProvider.pendingCacheOps.add(cacheKey); + void this.runLoader({ + request, + ctx, + cacheable: true, + cacheOptions, + knownCacheKey: cacheKey, + }).then( + () => { + ServerLoaderDataProvider.pendingCacheOps.delete(cacheKey); + }, + () => { + ServerLoaderDataProvider.pendingCacheOps.delete(cacheKey); + } + ); + } + + private async runLoader({ + request, + ctx, + cacheable, + cacheOptions, + knownCacheKey, + }: { + request: LoaderApiRequest; + ctx: LoaderContext; + cacheable: boolean; + cacheOptions?: LoaderApiRequest['cacheOptions']; + knownCacheKey?: string; + }): Promise<LoaderDataResult> { + const { loaderId } = request; + const loader = this.registry[loaderId]!; + let value: unknown; try { value = await loader(ctx); @@ -61,11 +111,25 @@ export class ServerLoaderDataProvider { return { kind: 'redirect', redirect: value }; } - if (cacheable) { + if (cacheable && this.cache) { const { key, dimensions } = buildCacheKey(loaderId, ctx); - const tags = [...buildDefaultTags(dimensions), ...(cacheOptions?.tags ?? [])]; - const ttl = cacheOptions?.revalidate ?? this.cache!.resolveTtl(); - await this.cache!.set(key, value, ttl, tags); + const cacheKey = knownCacheKey ?? key; + const tags = buildLoaderCacheTags( + loaderId, + dimensions, + cacheKey, + value, + cacheOptions?.tags ?? [] + ); + const ttl = cacheOptions?.revalidate ?? this.cache.resolveTtl(); + try { + await this.cache.set(cacheKey, value, ttl, tags); + } catch (err) { + console.warn( + '[sitecore-loader-cache] background refresh failed to write cache entry:', + err instanceof Error ? err.message : err + ); + } } return { kind: 'data', data: value }; diff --git a/packages/angular/src/server/middleware/index.ts b/packages/angular/src/server/middleware/index.ts new file mode 100644 index 0000000000..97c8cc01f9 --- /dev/null +++ b/packages/angular/src/server/middleware/index.ts @@ -0,0 +1,16 @@ +export { + createLoaderDataServiceMiddleware, + createExpressDataMiddleware, +} from './loader-data-service-middleware'; +export { + createSitecoreRevalidateMiddleware, + type SitecoreRevalidateMiddlewareOptions, + resolveConfiguredRevalidateSecret, +} from './sitecore-revalidate-middleware'; +export { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + extractSitecoreEdgeContentId, + type SitecoreEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateUpdate, + type CollectSitecoreTagsFromEdgeBodyOptions, +} from './sitecore-edge-webhook-revalidation'; diff --git a/packages/angular/src/server/loader-data-service-middleware.spec.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts similarity index 92% rename from packages/angular/src/server/loader-data-service-middleware.spec.ts rename to packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts index 6d37b9d645..169b6e8e97 100644 --- a/packages/angular/src/server/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts @@ -1,13 +1,13 @@ /* eslint-disable jsdoc/require-jsdoc */ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { LoaderFn } from '../loaders/models'; -import { NotFoundNavigationError, LoaderHttpError } from '../loaders/models'; +import type { LoaderFn } from '../../loaders/models'; +import { NotFoundNavigationError, LoaderHttpError } from '../../loaders/models'; import { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; -import { LOADER_DATA_ENDPOINT } from './constants'; -import { EXTRACT_REQUEST_CONTEXT_TOKEN } from './models'; -import type { LoaderRegistry } from '../loaders/loader-registry.token'; -import { createLoaderCache } from './cache/loader-cache'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { EXTRACT_REQUEST_CONTEXT_TOKEN } from '../models'; +import type { LoaderRegistry } from '../../loaders/loader-registry.token'; +import { createLoaderCache } from '../cache/loader-cache'; /** * Minimal Express `res` stub for middleware tests. @@ -39,14 +39,12 @@ describe('createLoaderDataServiceMiddleware', () => { }); /** - * @param {{ loaders: import('./models').LoaderRegistry; endpoint?: string; cache?: import('../loaders/models').LoaderCache }} opts - Middleware factory options - * @param {import('./models').LoaderRegistry} opts.loaders - Registered route loaders - * @param {string} [opts.endpoint] - Data endpoint path override + * @param {{ loaders: import('../models').LoaderRegistry; endpoint?: string; cache?: import('../../loaders/models').LoaderCache }} opts - Middleware factory options */ function createMiddleware(opts: { loaders: LoaderRegistry; endpoint?: string; - cache?: import('../loaders/models').LoaderCache; + cache?: import('../../loaders/models').LoaderCache; }) { const extractReq = TestBed.inject(EXTRACT_REQUEST_CONTEXT_TOKEN); return createLoaderDataServiceMiddleware({ diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.ts similarity index 95% rename from packages/angular/src/server/loader-data-service-middleware.ts rename to packages/angular/src/server/middleware/loader-data-service-middleware.ts index f8b2625e4a..b13ac7e576 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.ts @@ -4,17 +4,17 @@ import { NotFoundNavigationError, LoaderHttpError, LoaderDataResult, -} from '../loaders/models'; -import { extractRequestContext } from '../loaders/utils'; +} from '../../loaders/models'; +import { extractRequestContext } from '../../loaders/utils'; import { ExpressDataHandlerOptions, ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse, -} from './models'; -import { LOADER_DATA_ENDPOINT } from './constants'; -import { ServerLoaderDataProvider } from './loader-data.provider'; +} from '../models'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { ServerLoaderDataProvider } from '../loader-data.provider'; /** * Map loader resolution result to wire-level API response. diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts new file mode 100644 index 0000000000..a6bac77af0 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts @@ -0,0 +1,52 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + extractSitecoreEdgeContentId, +} from './sitecore-edge-webhook-revalidation'; + +describe('sitecore-edge-webhook-revalidation', () => { + describe('extractSitecoreEdgeContentId', () => { + it('strips -media and -layout suffixes', () => { + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-media')).toBe( + '71B0BA0716214254AEE4429B1A970C8B' + ); + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-LAYOUT')).toBe( + '71B0BA0716214254AEE4429B1A970C8B' + ); + }); + }); + + describe('collectSitecoreTagsFromEdgeRevalidateRequestBody', () => { + it('maps updates to sc:item tags using entity_culture', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + updates: [ + { + identifier: '71B0BA0716214254AEE4429B1A970C8B-media', + entity_culture: 'en', + }, + ], + }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + + it('passes through full sc: tags in tags array', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { tags: ['sc:loader:dictionary:default:en'] }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:loader:dictionary:default:en']); + }); + + it('maps bare ids in tags array to item tags with defaultLocale', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { tags: ['71B0BA0716214254AEE4429B1A970C8B'] }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + }); +}); diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts new file mode 100644 index 0000000000..5a728a326b --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts @@ -0,0 +1,95 @@ +import { buildSitecoreItemCacheTag, SITECORE_CONTENT_CACHE_TAG_PREFIX } from '../cache/cache-tags'; +import { dedupeCacheStrings } from '../cache/utils'; + +/** + * One content change entry as commonly seen in Experience Edge / Content Operations payloads. + * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. + * @public + */ +export type SitecoreEdgeRevalidateUpdate = { + identifier?: string; + entity_definition?: string; + operation?: string; + entity_culture?: string; +}; + +/** + * Request body shape for webhook-driven revalidation. + * @public + */ +export type SitecoreEdgeRevalidateRequestBody = { + invocation_id?: string; + updates?: SitecoreEdgeRevalidateUpdate[]; + continues?: boolean; + tags?: string[]; +}; + +/** + * Strips Experience Edge style suffixes from an `identifier`. + * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. + * @param {string} identifier - Raw identifier from a webhook update row. + * @public + */ +export function extractSitecoreEdgeContentId(identifier: string): string { + if (!identifier || typeof identifier !== 'string') { + return ''; + } + const trimmed = identifier.trim(); + return trimmed.replace(/-(?:media|layout)$/i, ''); +} + +const FULL_TAG_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:`; + +function isFullSitecoreContentCacheTag(value: string): boolean { + return value.startsWith(FULL_TAG_PREFIX); +} + +/** + * Options for {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. + * @public + */ +export type CollectSitecoreTagsFromEdgeBodyOptions = { + defaultLocale: string; +}; + +/** + * Maps an Experience Edge webhook JSON body to Sitecore cache tag strings. + * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. + * @public + */ +export function collectSitecoreTagsFromEdgeRevalidateRequestBody( + body: SitecoreEdgeRevalidateRequestBody | null | undefined, + options: CollectSitecoreTagsFromEdgeBodyOptions +): string[] { + const { defaultLocale } = options; + const out: string[] = []; + + for (const raw of body?.tags ?? []) { + if (typeof raw !== 'string') { + continue; + } + const s = raw.trim(); + if (!s) { + continue; + } + if (isFullSitecoreContentCacheTag(s)) { + out.push(s); + } else { + const id = extractSitecoreEdgeContentId(s); + if (id) { + out.push(buildSitecoreItemCacheTag({ itemId: id, locale: defaultLocale })); + } + } + } + + for (const u of body?.updates ?? []) { + const id = extractSitecoreEdgeContentId(u?.identifier ?? ''); + if (!id) { + continue; + } + const locale = u?.entity_culture?.trim() || defaultLocale; + out.push(buildSitecoreItemCacheTag({ itemId: id, locale })); + } + + return dedupeCacheStrings(out).filter(Boolean); +} diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts new file mode 100644 index 0000000000..e18376fd46 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts @@ -0,0 +1,112 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createSitecoreRevalidateMiddleware } from './sitecore-revalidate-middleware'; +import { createLoaderCache } from '../cache/loader-cache'; +import { buildCacheKey } from '../cache/cache-key'; +import { buildLoaderCacheTags } from '../cache/cache-tags'; +import type { ExpressRequest, ExpressResponse } from '../models'; + +function createMockRes() { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { + status: ReturnType<typeof vi.fn>; + json: ReturnType<typeof vi.fn>; + }; +} + +describe('createSitecoreRevalidateMiddleware', () => { + let cache: ReturnType<typeof createLoaderCache>; + let cacheKey: string; + const next = vi.fn(); + + beforeEach(async () => { + delete process.env.SITECORE_REVALIDATE_SECRET; + cache = createLoaderCache({ revalidate: 300 }); + const built = buildCacheKey('page', { + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + cacheKey = built.key; + await cache.set( + cacheKey, + { title: 'About' }, + 300, + buildLoaderCacheTags('page', built.dimensions, cacheKey, { + layout: { sitecore: { route: { itemId: '71B0BA0716214254AEE4429B1A970C8B' } } }, + locale: 'en', + mode: {}, + }) + ); + }); + + afterEach(() => { + delete process.env.SITECORE_REVALIDATE_SECRET; + }); + + it('marks entries stale on item publish webhook', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { + updates: [{ identifier: '71B0BA0716214254AEE4429B1A970C8B', entity_culture: 'en' }], + }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect((await cache.get(cacheKey)).kind).toBe('stale'); + }); + + it('returns 401 when secret is configured but header mismatches', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { tags: ['sc:site:demo'] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('falls through non-POST requests', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: {}, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts new file mode 100644 index 0000000000..441ca404f2 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -0,0 +1,128 @@ +import type { SiteInfo } from '@sitecore-content-sdk/content/site'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { LoaderCache } from '../../loaders/models'; +import { buildLoaderDictionaryCacheTagsFromSites } from '../cache/cache-tags'; +import { dedupeCacheStrings } from '../cache/utils'; +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateRequestBody, +} from './sitecore-edge-webhook-revalidation'; + +const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; +const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; +const DEFAULT_ENDPOINT = '/api/revalidate'; + +function readProcessEnv(name: string): string | undefined { + const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process; + return proc?.env?.[name]; +} + +/** + * Returns a non-empty trimmed secret, or `undefined` when unset or whitespace-only. + * Authority: `packages/nextjs/src/route-handler/sitecore-revalidate-route-handler.ts`. + * @internal + */ +export function resolveConfiguredRevalidateSecret( + secretOption: string | undefined, + envValue: string | undefined +): string | undefined { + const raw = secretOption !== undefined ? secretOption : envValue; + const trimmed = raw?.trim(); + return trimmed || undefined; +} + +/** + * Options for {@link createSitecoreRevalidateMiddleware}. + * @public + */ +export interface SitecoreRevalidateMiddlewareOptions { + cache: LoaderCache; + /** Default: `process.env.SITECORE_REVALIDATE_SECRET` */ + secret?: string; + /** Locale fallback when an update has no entity_culture; default `'en'`. */ + defaultLocale?: string; + /** + * Optional sites list; when set, every call also marks stale one + * `sc:loader:dictionary:<site>:<locale>` entry per site. + */ + sites?: SiteInfo[]; + /** Endpoint path; default `/api/revalidate`. */ + endpoint?: string; +} + +/** + * Express middleware aligned with Next.js `createSitecoreRevalidateRouteHandler`. + * Marks matching loader cache entries stale via tag index (SWR semantics). + * @public + */ +export function createSitecoreRevalidateMiddleware( + options: SitecoreRevalidateMiddlewareOptions +): ExpressMiddleware { + const { cache, secret, defaultLocale = 'en', sites, endpoint = DEFAULT_ENDPOINT } = options; + + const dictionaryTags = + sites !== undefined + ? buildLoaderDictionaryCacheTagsFromSites({ sites, baseLocale: defaultLocale }) + : []; + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + if (req.method !== 'POST' || req.path !== endpoint) { + next(); + return; + } + + const startTimestamp = Date.now(); + + try { + const configuredSecret = resolveConfiguredRevalidateSecret( + secret, + readProcessEnv(DEFAULT_SECRET_ENV_VAR) + ); + + if (configuredSecret) { + const headers = req.headers as Record<string, string | string[] | undefined>; + const providedSecret = headers[DEFAULT_SECRET_HEADER]; + const headerValue = Array.isArray(providedSecret) ? providedSecret[0] : providedSecret; + if (headerValue !== configuredSecret) { + res.status(401).json({ error: 'Unauthorized.' }); + return; + } + } + + const body = req.body; + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + res.status(400).json({ error: 'Request body must be a JSON object.' }); + return; + } + + const webhookBody = body as SitecoreEdgeRevalidateRequestBody; + + const tags = dedupeCacheStrings([ + ...collectSitecoreTagsFromEdgeRevalidateRequestBody(webhookBody, { defaultLocale }), + ...dictionaryTags, + ]); + + if (tags.length === 0) { + res.status(400).json({ + error: + 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.', + }); + return; + } + + const marked = await cache.invalidate({ tags }); + + res.status(200).json({ + revalidated: true, + tagsCount: tags.length, + marked, + invocation_id: webhookBody.invocation_id ?? null, + continues: webhookBody.continues ?? false, + durationMs: Date.now() - startTimestamp, + }); + } catch (error) { + console.error('Sitecore revalidate middleware failed:', error); + res.status(500).json({ error: 'Internal Server Error.' }); + } + }; +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts index 5406528860..fd7ccd1e56 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts @@ -9,6 +9,7 @@ interface AdminEntry { tags: string[]; storedAt: number; expiresAt: number | null; + stale: boolean; approxBytes: number; } @@ -29,11 +30,7 @@ interface ConfigResponse { const ADMIN_BASE = '/api/_cache'; /** - * Demo page that lists every loader-cache entry held by the running server and - * lets you invalidate them per-path or flush the whole cache. - * - * Hits the admin endpoints exposed by createCacheAdminMiddleware() in - * server.ts. Plan: llm-wiki/wiki/plans/doc-loader-cache-plan.md + * Demo page that lists loader-cache entries and supports tag-based invalidation (Phase 3 OSR). */ @Component({ selector: 'app-cache-demo', @@ -54,8 +51,8 @@ const ADMIN_BASE = '/api/_cache'; </p> } <p class="hint"> - Lists entries written by SSR + /_data middleware. Navigate to any page in another tab, - then click <em>Refresh</em> to see them appear. Invalidate to evict. + Entries use <code>sc:loader:…</code> keys and Sitecore OSR tags. Invalidate marks entries + <em>stale</em> (SWR); the next request serves last-known-good while refreshing. </p> </header> @@ -71,31 +68,25 @@ const ADMIN_BASE = '/api/_cache'; <form class="invalidate" (submit)="invalidate($event)"> <fieldset> - <legend>Invalidate by path</legend> + <legend>Invalidate by tag</legend> <label> - Route - <input type="text" name="route" [(ngModel)]="invalidateRoute" placeholder="/about" required /> + Tags (comma-separated) + <input + type="text" + name="tags" + [(ngModel)]="invalidateTags" + placeholder="sc:item:…, sc:site:demo" + required + /> </label> - <label> - Site (optional) - <input type="text" name="site" [(ngModel)]="invalidateSite" placeholder="default" /> - </label> - <label> - Language (optional) - <input type="text" name="language" [(ngModel)]="invalidateLanguage" placeholder="en" /> - </label> - <label> - Loader (optional) - <input type="text" name="loaderId" [(ngModel)]="invalidateLoaderId" placeholder="page" /> - </label> - <button type="submit" [disabled]="loading() || !invalidateRoute.trim()">Invalidate</button> + <button type="submit" [disabled]="loading() || !invalidateTags.trim()">Mark stale</button> </fieldset> </form> @if (!loading() && entries().length > 0) { <div class="meta"> Total entries: <strong>{{ entries().length }}</strong> - · total size: <strong>{{ totalBytes() | number }} bytes</strong> + · stale: <strong>{{ staleCount() }}</strong> </div> } @@ -106,7 +97,7 @@ const ADMIN_BASE = '/api/_cache'; } @else { <ul class="entries"> @for (entry of entries(); track entry.key) { - <li> + <li [class.stale]="entry.stale"> <div class="key">{{ entry.key }}</div> <div class="tags"> @for (t of entry.tags; track t) { @@ -120,11 +111,13 @@ const ADMIN_BASE = '/api/_cache'; } @else { <span>expires: never</span> } - <span>size: {{ entry.approxBytes | number }} B</span> + @if (entry.stale) { + <span class="stale-badge">stale</span> + } </div> <div class="actions"> - <button type="button" (click)="deleteByTagMatch(entry)" [disabled]="loading()"> - Invalidate this route + <button type="button" (click)="invalidateEntry(entry)" [disabled]="loading()"> + Mark this entry stale </button> </div> </li> @@ -142,15 +135,17 @@ const ADMIN_BASE = '/api/_cache'; .toolbar .status { color: #2a7; font-size: .9rem; } .invalidate fieldset { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; } .invalidate label { display: flex; flex-direction: column; font-size: .85rem; color: #444; } - .invalidate input { padding: .25rem .5rem; min-width: 8rem; } + .invalidate input { padding: .25rem .5rem; min-width: 16rem; } .meta { color: #666; margin: .75rem 0; font-size: .9rem; } .loading, .empty { color: #888; padding: 1rem 0; } .entries { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .75rem; } .entries li { border: 1px solid #ddd; padding: .75rem; border-radius: 6px; background: #fafafa; } + .entries li.stale { border-color: #c90; background: #fffbeb; } .key { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; word-break: break-all; } .tags { margin: .35rem 0; display: flex; flex-wrap: wrap; gap: .25rem; } .tag { font-size: .7rem; background: #eef; padding: .1rem .4rem; border-radius: 3px; font-family: ui-monospace, Consolas, monospace; } - .meta-row { display: flex; gap: 1rem; font-size: .8rem; color: #555; margin-top: .25rem; } + .meta-row { display: flex; gap: 1rem; font-size: .8rem; color: #555; margin-top: .25rem; align-items: center; } + .stale-badge { color: #a60; font-weight: 600; } .actions { margin-top: .5rem; } button { padding: .35rem .75rem; cursor: pointer; } button:disabled { opacity: .5; cursor: not-allowed; } @@ -164,14 +159,9 @@ export class CacheDemoComponent { readonly config = signal<ConfigResponse | null>(null); readonly loading = signal(false); readonly lastMessage = signal<string>(''); - readonly totalBytes = computed(() => - this.entries().reduce((sum, e) => sum + e.approxBytes, 0) - ); + readonly staleCount = computed(() => this.entries().filter((e) => e.stale).length); - invalidateRoute = ''; - invalidateSite = ''; - invalidateLanguage = ''; - invalidateLoaderId = ''; + invalidateTags = ''; constructor() { this.refresh(); @@ -184,7 +174,12 @@ export class CacheDemoComponent { firstValueFrom(this.http.get<EntriesResponse>(`${ADMIN_BASE}/entries`)), firstValueFrom(this.http.get<ConfigResponse>(`${ADMIN_BASE}/config`)), ]); - this.entries.set(entries.entries); + this.entries.set( + entries.entries.map((e) => ({ + ...e, + approxBytes: e.key.length * 2, + })) + ); this.config.set(config); this.lastMessage.set(''); } catch (err) { @@ -208,20 +203,18 @@ export class CacheDemoComponent { async invalidate(event: Event): Promise<void> { event.preventDefault(); - const route = this.invalidateRoute.trim(); - if (!route) return; - - const body: Record<string, string> = { route }; - if (this.invalidateSite.trim()) body['site'] = this.invalidateSite.trim(); - if (this.invalidateLanguage.trim()) body['language'] = this.invalidateLanguage.trim(); - if (this.invalidateLoaderId.trim()) body['loaderId'] = this.invalidateLoaderId.trim(); + const tags = this.invalidateTags + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + if (tags.length === 0) return; this.loading.set(true); try { const resp = await firstValueFrom( - this.http.post<{ deleted: number }>(`${ADMIN_BASE}/invalidate`, body) + this.http.post<{ marked: number }>(`${ADMIN_BASE}/invalidate`, { tags }) ); - this.lastMessage.set(`Invalidated ${resp.deleted} entr${resp.deleted === 1 ? 'y' : 'ies'}.`); + this.lastMessage.set(`Marked ${resp.marked} entr${resp.marked === 1 ? 'y' : 'ies'} stale.`); await this.refresh(); } catch (err) { this.lastMessage.set(`Invalidate failed: ${(err as Error).message}`); @@ -230,38 +223,16 @@ export class CacheDemoComponent { } } - /** - * Convenience: invalidate using the route + loader tags read off the row. - */ - async deleteByTagMatch(entry: AdminEntry): Promise<void> { - const route = readTag(entry.tags, 'route:'); - const site = readTag(entry.tags, 'site:'); - const language = readTag(entry.tags, 'language:'); - const loaderId = readTag(entry.tags, 'loader:'); - if (!route) { - this.lastMessage.set('Entry has no route tag.'); - return; - } - - const body: Record<string, string> = { route }; - if (site) body['site'] = site; - if (language) body['language'] = language; - if (loaderId) body['loaderId'] = loaderId; - + async invalidateEntry(entry: AdminEntry): Promise<void> { this.loading.set(true); try { const resp = await firstValueFrom( - this.http.post<{ deleted: number }>(`${ADMIN_BASE}/invalidate`, body) + this.http.post<{ marked: number }>(`${ADMIN_BASE}/invalidate`, { tags: [entry.key] }) ); - this.lastMessage.set(`Invalidated ${resp.deleted} entr${resp.deleted === 1 ? 'y' : 'ies'}.`); + this.lastMessage.set(`Marked ${resp.marked} entr${resp.marked === 1 ? 'y' : 'ies'} stale.`); await this.refresh(); } finally { this.loading.set(false); } } } - -function readTag(tags: string[], prefix: string): string | undefined { - const t = tags.find((x) => x.startsWith(prefix)); - return t ? decodeURIComponent(t.slice(prefix.length)) : undefined; -} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 6333ac1268..e3ec375146 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -13,6 +13,7 @@ import { createCacheAdminMiddleware, createLoaderCache, createLoaderDataServiceMiddleware, + createSitecoreRevalidateMiddleware, } from '@sitecore-content-sdk/angular'; import { LOADERS } from './content-sdk/loaders'; import config from '../sitecore.config'; @@ -41,12 +42,28 @@ const driver = const loaderCache = createLoaderCache({ revalidate: config.angular.isrCache.revalidate, - defaultSiteName: config.defaultSiteName, + enabled: config.angular.isrCache.enabled, + defaultSiteName: config.defaultSite, ...(driver ? { driver } : {}), }); app.use(express.json()); +/** Production webhook: POST /api/revalidate (Sitecore Edge OSR). */ +app.use( + createSitecoreRevalidateMiddleware({ + cache: loaderCache, + defaultLocale: config.defaultLanguage, + sites: [ + { + name: config.defaultSite, + hostName: '*', + language: config.defaultLanguage, + }, + ], + }) +); + /** Admin endpoints for cache inspection and invalidation (see `/api/_cache`). */ app.use(createCacheAdminMiddleware({ cache: loaderCache, endpoint: '/api/_cache' })); diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts index a887bed80d..9692e953e9 100644 --- a/packages/nextjs/src/cache/sitecore-cache-tags.test.ts +++ b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts @@ -38,7 +38,11 @@ describe('sitecore-cache-tags', () => { it('joins path segments', () => { expect( - buildSitecoreRouteCacheTag({ site: 'Website', locale: 'en-US', pathSegments: ['About', 'Team'] }) + buildSitecoreRouteCacheTag({ + site: 'Website', + locale: 'en-US', + pathSegments: ['About', 'Team'], + }) ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:website:en-us:about/team`); }); }); @@ -121,7 +125,9 @@ describe('sitecore-cache-tags', () => { } as RouteData, 'en-US' ) - ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111-1111-1111-1111-111111111111:fr-fr:v2`); + ).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111-1111-1111-1111-111111111111:fr-fr:v2` + ); }); it('falls back to fallbackLocale', () => { diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.ts b/packages/nextjs/src/cache/sitecore-cache-tags.ts index 681619882b..95f65e9079 100644 --- a/packages/nextjs/src/cache/sitecore-cache-tags.ts +++ b/packages/nextjs/src/cache/sitecore-cache-tags.ts @@ -14,7 +14,10 @@ export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; * @internal */ export function sanitizeSitecoreCacheTagSegment(value: string): string { - return value.trim().toLowerCase().replace(/[/:\s]+/g, '_'); + return value + .trim() + .toLowerCase() + .replace(/[/:\s]+/g, '_'); } /** @@ -95,7 +98,9 @@ export type BuildSitecoreDictionaryCacheTagParams = { * @param {BuildSitecoreDictionaryCacheTagParams} params - Site and locale for the dictionary fetch. * @public */ -export function buildSitecoreDictionaryCacheTag(params: BuildSitecoreDictionaryCacheTagParams): string { +export function buildSitecoreDictionaryCacheTag( + params: BuildSitecoreDictionaryCacheTagParams +): string { const site = sanitizeSitecoreCacheTagSegment(params.site); const locale = sanitizeSitecoreCacheTagSegment(params.locale); return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; diff --git a/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts index 41e9e6d17b..a6c5c8f012 100644 --- a/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts +++ b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts @@ -47,9 +47,15 @@ describe('collectSitecorePageCacheTags', () => { ...base, personalizedPathname: '/about', }); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`))).to.equal(true); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:`))).to.equal(true); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal(false); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`))).to.equal( + true + ); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:`))).to.equal( + true + ); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal( + false + ); }); it('does not add a personalization variant tag even when pathname carries variant markers', () => { @@ -57,6 +63,8 @@ describe('collectSitecorePageCacheTags', () => { ...base, personalizedPathname: '/about/_variantId_hero-a', }); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal(false); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal( + false + ); }); }); From 362409f7497b6489b90356d4d5d05c299d557f1d Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Wed, 27 May 2026 18:51:15 -0400 Subject: [PATCH 10/14] tsdoc and more tests --- .../src/lib/sitecore-context.service.spec.ts | 14 +- .../angular/src/loaders/loader-resolver.ts | 2 + packages/angular/src/loaders/models.ts | 56 +++-- packages/angular/src/loaders/utils.ts | 2 +- .../cache/cache-admin-middleware.spec.ts | 83 +++++++ .../angular/src/server/cache/cache-key.ts | 36 ++- .../src/server/cache/cache-tags.spec.ts | 159 +++++++++++++ .../angular/src/server/cache/cache-tags.ts | 124 ++++++++-- .../src/server/cache/cache.spec-helpers.ts | 147 ++++++++++++ .../cache/default-in-memory-cache.spec.ts | 81 +++++++ .../server/cache/default-in-memory-cache.ts | 120 ++++++---- .../src/server/cache/loader-cache.spec.ts | 214 ++---------------- .../angular/src/server/cache/loader-cache.ts | 26 ++- packages/angular/src/server/cache/models.ts | 8 +- .../cache/unstorage-loader-cache.spec.ts | 142 ++++++++++++ .../server/cache/unstorage-loader-cache.ts | 51 ++++- .../angular/src/server/cache/utils.spec.ts | 56 ++++- packages/angular/src/server/cache/utils.ts | 58 ++++- .../src/server/loader-data.provider.spec.ts | 91 ++++++++ .../src/server/loader-data.provider.ts | 21 +- .../sitecore-edge-webhook-revalidation.ts | 7 + .../sitecore-revalidate-middleware.ts | 16 +- .../src/testing/mock-sitecore-context.ts | 3 +- yarn.lock | 183 +++++++++++++++ 24 files changed, 1362 insertions(+), 338 deletions(-) create mode 100644 packages/angular/src/server/cache/cache-tags.spec.ts create mode 100644 packages/angular/src/server/cache/cache.spec-helpers.ts create mode 100644 packages/angular/src/server/cache/default-in-memory-cache.spec.ts create mode 100644 packages/angular/src/server/cache/unstorage-loader-cache.spec.ts diff --git a/packages/angular/src/lib/sitecore-context.service.spec.ts b/packages/angular/src/lib/sitecore-context.service.spec.ts index b14bce9fe4..51c0261261 100644 --- a/packages/angular/src/lib/sitecore-context.service.spec.ts +++ b/packages/angular/src/lib/sitecore-context.service.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable */ import { TestBed, ComponentFixture } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Component, PLATFORM_ID, REQUEST } from '@angular/core'; @@ -100,9 +100,7 @@ describe('SitecoreContextService', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: mockPage, dictionary: mockDictionary }) - ), + provideRouter(appLikeRoutes({ page: mockPage, dictionary: mockDictionary })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) }, SitecoreContextService, ], @@ -141,9 +139,7 @@ describe('SitecoreContextService', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: editingPage }) - ), + provideRouter(appLikeRoutes({ page: editingPage })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) }, SitecoreContextService, ], @@ -325,9 +321,7 @@ describe('SitecoreContextService effectiveLocale', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: makePage({ locale: 'fr' }) }) - ), + provideRouter(appLikeRoutes({ page: makePage({ locale: 'fr' }) })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES], 'en'), diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 0a234f0be1..40e7c935b8 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -73,6 +73,8 @@ function buildLoaderParams(route: ActivatedRouteSnapshot, defaultLanguage?: stri * @param {string} loaderId - loader ID to resolve, used for transfer state key and LoaderDataService call * @param {Router} router - The Angular router instance * @param {string} [defaultLanguage] - Default language for locale fallback in params + * @param {LoaderCacheConfig} [cacheOptions] - Cache options for the loader + * @returns {Promise<unknown | RedirectCommand>} The resolved data or redirect command */ async function resolveOnBrowser( route: ActivatedRouteSnapshot, diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index 590199aa3c..b3a4eaf9e9 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -174,7 +174,12 @@ export interface LoaderCacheConfig { * OSR tags (self-key, `sc:site`, `sc:locale`, and `sc:item` for page loaders). */ tags?: string[]; + /** + * Site names used by revalidation middleware to fan out dictionary loader tags + * (`sc:loader:dictionary:<site>:<locale>`) on every webhook call. + */ sites?: string[]; + /** Fallback locale for tag helpers when a site entry has no `language`. Defaults to `'en'`. */ defaultLocale?: string; } @@ -193,11 +198,18 @@ export interface LoaderCacheEntryInfo { /** * Three-outcome read result for stale-while-revalidate (Phase 3). + * + * - `hit` — entry is fresh; serve cached value without running the loader. + * - `stale` — entry expired or was invalidated; serve cached value and refresh in the background. + * - `miss` — no entry; run the loader synchronously. * @public */ export type LoaderCacheReadResult = + /** Fresh cache entry within TTL and not marked stale. */ | { kind: 'hit'; value: unknown; cacheKey: string } + /** Expired or invalidated entry; value is served while a background refresh runs. */ | { kind: 'stale'; value: unknown; cacheKey: string } + /** No entry stored for the requested cache key. */ | { kind: 'miss'; cacheKey: string }; /** @@ -215,38 +227,56 @@ export interface LoaderCacheEntry { } /** - * Tag-based invalidation input - * Marks matching entries stale; does not delete them. + * Tag-based invalidation input. + * Marks matching entries stale via the tag index; does not delete them (SWR semantics). * @public */ export interface InvalidateInput { + /** Non-empty list of OSR tags (for example `sc:item:…`, `sc:site:…`, or a cache key self-tag). */ tags?: string[]; } /** - * Server-only cache instance. Constructed once in server.ts via - * createLoaderCache() and passed by reference to the middleware factories - * (`createLoaderDataServiceMiddleware`, `createCacheAdminMiddleware`) and to - * Angular SSR through `angularApp.handle(req, { cache })`. + * Server-only cache instance. Constructed once in `server.ts` via + * {@link createLoaderCache} and passed by reference to middleware factories + * ({@link createLoaderDataServiceMiddleware}, {@link createCacheAdminMiddleware}, + * {@link createSitecoreRevalidateMiddleware}) and to Angular SSR through + * `angularApp.handle(req, { cache })`. + * + * Implementations maintain a sidecar tag index so {@link LoaderCache.invalidate} + * can mark entries stale without scanning every key. * @public */ export interface LoaderCache { + /** + * Reads a cache entry and classifies it as hit, stale, or miss. + * @param key - OSR-aligned cache key (for example `sc:loader:page:demo:en:default:about`). + */ get(key: string): Promise<LoaderCacheReadResult>; /** - * Stores an entry. `ttlSeconds > 0` makes the entry expire after that many - * seconds; `0` or negative means "never expire". Always writes `stale: false`. + * Stores an entry and links it to the supplied tag set. + * @param key - Cache key to write. + * @param value - Loader payload to persist. + * @param ttlSeconds - TTL in seconds; `0` or negative means never expire. + * @param tags - Tag index pointers written alongside the entry (self-key, site, locale, item, etc.). */ set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void>; - /** Marks entries stale by tag. Returns number of entries marked. */ + /** + * Marks every entry linked to any of the supplied tags as stale. + * @param filter - Tag list to resolve through the tag index. + * @returns Number of entries marked stale (includes entries already stale). + */ invalidate(filter: InvalidateInput): Promise<number>; - /** Direct delete by exact key. */ + /** Removes a single entry and unlinks it from the tag index. */ delete(key: string): Promise<boolean>; - /** Nuke every entry. */ + /** Removes every entry and clears the tag index. */ flush(): Promise<void>; - /** Returns lightweight metadata for every live entry — used by admin tooling. */ + /** Returns lightweight metadata for admin tooling (values are omitted). */ entries(): Promise<LoaderCacheEntryInfo[]>; + /** Global default TTL in seconds from {@link LoaderCacheConfig.revalidate}. */ resolveTtl(): number; + /** Whether caching is enabled globally. Per-route overrides may still opt in. */ enabled(): boolean; - /** Reads back the resolved config (useful for admin UI). */ + /** Resolved configuration (useful for admin UI and diagnostics). */ getConfig(): Readonly<LoaderCacheConfig>; } diff --git a/packages/angular/src/loaders/utils.ts b/packages/angular/src/loaders/utils.ts index 18997b04d5..23255dbc63 100644 --- a/packages/angular/src/loaders/utils.ts +++ b/packages/angular/src/loaders/utils.ts @@ -130,7 +130,7 @@ export function extractRequestContext(req: Request | ExpressLikeRequest): Reques } // Express-like request object - const hostHeader = req.headers?.['host']; + const hostHeader = req.headers?.host; const hostname = pickHostnameFromHostHeader( Array.isArray(hostHeader) ? hostHeader[0] : hostHeader ); diff --git a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts index c4b20f6cef..577b7a9a37 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts +++ b/packages/angular/src/server/cache/cache-admin-middleware.spec.ts @@ -142,4 +142,87 @@ describe('createCacheAdminMiddleware', () => { expect(res.json).toHaveBeenCalledWith({ marked: 1 }); expect((await cache.get(cacheKey)).kind).toBe('stale'); }); + + it('returns resolved cache config', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/config`, + url: `${endpoint}/config`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ revalidate: 300 })); + }); + + it('flushes all cache entries', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/flush`, + url: `${endpoint}/flush`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + expect((await cache.get(cacheKey)).kind).toBe('miss'); + }); + + it('returns 404 for unknown admin actions', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/unknown`, + url: `${endpoint}/unknown`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 500 when cache operations fail', async () => { + const failingCache = createLoaderCache({ revalidate: 300 }); + vi.spyOn(failingCache, 'entries').mockRejectedValue(new Error('storage down')); + + const middleware = createCacheAdminMiddleware({ cache: failingCache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'storage down' }); + }); }); diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index 2a1f1461d2..357f2c7267 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -8,7 +8,15 @@ import { SITECORE_CONTENT_CACHE_TAG_PREFIX } from './cache-tags'; export const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`; /** - * Compose the canonical cache key for a loader invocation. + * Compose the canonical cache key and dimension tuple for a loader invocation. + * @param {string} loaderId - Registered loader id (`page`, `dictionary`, etc.). + * @param {LoaderContext} ctx - Loader context (URL, route params, query). + * @returns {{ key: string, dimensions: CacheKeyDimensions }} Cache key and parsed dimensions. + * @example + * ```ts + * buildCacheKey('page', { url: '/about', params: { site: 'demo', locale: 'en' }, query: {} }); + * // → { key: 'sc:loader:page:demo:en:default:about', dimensions: { … } } + * ``` * @public */ export function buildCacheKey( @@ -22,6 +30,10 @@ export function buildCacheKey( /** * Serializes cache key dimensions into the public `sc:loader:…` format. + * Dispatches to {@link buildPageCacheKey}, {@link buildDictionaryCacheKey}, or + * {@link buildGenericLoaderCacheKey} based on `dimensions.loaderId`. + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} OSR-aligned cache key. * @public */ export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string { @@ -34,7 +46,12 @@ export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string return buildGenericLoaderCacheKey(dimensions); } -/** @public */ +/** + * Page loader key: `sc:loader:page:<site>:<locale>:<variantId>:<pathKey>`. + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Page loader cache key. + * @public + */ export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { const site = sanitizeSitecoreCacheSegment(dimensions.site); const locale = sanitizeSitecoreCacheSegment(dimensions.locale); @@ -42,14 +59,25 @@ export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { return `${CACHE_KEY_PREFIX}:page:${site}:${locale}:${variantId}:${dimensions.pathKey}`; } -/** @public */ +/** + * Dictionary loader key: `sc:loader:dictionary:<site>:<locale>` (one entry per site/locale). + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Dictionary loader cache key. + * @public + */ export function buildDictionaryCacheKey(dimensions: CacheKeyDimensions): string { const site = sanitizeSitecoreCacheSegment(dimensions.site); const locale = sanitizeSitecoreCacheSegment(dimensions.locale); return `${CACHE_KEY_PREFIX}:dictionary:${site}:${locale}`; } -/** @public */ +/** + * Generic loader key: `sc:loader:<loaderId>:<site>:<locale>:<variantId>:<pathKey>`. + * Used for loaders other than `page` and `dictionary` (for example `404`, `500`). + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Generic loader cache key. + * @public + */ export function buildGenericLoaderCacheKey(dimensions: CacheKeyDimensions): string { const loaderId = sanitizeSitecoreCacheSegment(dimensions.loaderId); const site = sanitizeSitecoreCacheSegment(dimensions.site); diff --git a/packages/angular/src/server/cache/cache-tags.spec.ts b/packages/angular/src/server/cache/cache-tags.spec.ts new file mode 100644 index 0000000000..995362b4df --- /dev/null +++ b/packages/angular/src/server/cache/cache-tags.spec.ts @@ -0,0 +1,159 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import type { RouteData } from '@sitecore-content-sdk/content/layout'; +import { + SITECORE_CONTENT_CACHE_TAG_PREFIX, + buildSitecoreItemCacheTag, + buildSitecoreDictionaryCacheTag, + buildSitecoreItemCacheTagFromRouteData, + buildLoaderDictionaryCacheTagsFromSites, + buildLoaderDictionaryCacheTag, + buildSitecoreSiteCacheTag, + buildSitecoreLocaleCacheTag, + buildLoaderCacheTags, +} from './cache-tags'; +import type { CacheKeyDimensions } from './models'; + +const pageDimensions: CacheKeyDimensions = { + site: 'Demo Site', + locale: 'en-US', + variantId: 'default', + loaderId: 'page', + pathKey: 'about', +}; + +describe('buildSitecoreItemCacheTag', () => { + it('normalizes item id and builds latest version tag by default', () => { + expect( + buildSitecoreItemCacheTag({ itemId: '{ABC-123}', locale: 'en-US', version: undefined }) + ).toBe(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:abc-123:en-us:latest`); + }); + + it('includes numeric version when provided', () => { + expect(buildSitecoreItemCacheTag({ itemId: 'abc', locale: 'en', version: 3.7 })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:abc:en:v3` + ); + }); +}); + +describe('buildSitecoreDictionaryCacheTag', () => { + it('sanitizes site and locale segments', () => { + expect(buildSitecoreDictionaryCacheTag({ site: 'Demo Site', locale: 'en US' })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:demo_site:en_us` + ); + }); +}); + +describe('buildSitecoreItemCacheTagFromRouteData', () => { + it('returns null when route has no itemId', () => { + expect(buildSitecoreItemCacheTagFromRouteData(null, 'en')).toBeNull(); + expect(buildSitecoreItemCacheTagFromRouteData({} as RouteData, 'en')).toBeNull(); + }); + + it('uses route language and version when present', () => { + const route = { + itemId: '{GUID}', + itemLanguage: 'de', + itemVersion: 5, + } as RouteData; + + expect(buildSitecoreItemCacheTagFromRouteData(route, 'en')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:guid:de:v5` + ); + }); + + it('falls back to provided locale when route language is absent', () => { + const route = { itemId: 'item-1' } as RouteData; + expect(buildSitecoreItemCacheTagFromRouteData(route, 'fr-CA')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:item-1:fr-ca:latest` + ); + }); +}); + +describe('buildLoaderDictionaryCacheTagsFromSites', () => { + it('dedupes tags and falls back to base locale', () => { + const tags = buildLoaderDictionaryCacheTagsFromSites({ + sites: [ + { name: 'shop', language: 'en' }, + { name: 'shop', language: 'en' }, + { name: 'blog', language: ' ' }, + ], + baseLocale: 'de', + }); + + expect(tags).toEqual([ + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:shop:en`, + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:blog:de`, + ]); + }); +}); + +describe('buildLoaderDictionaryCacheTag', () => { + it('builds loader dictionary self-tag', () => { + expect(buildLoaderDictionaryCacheTag({ site: 'demo', locale: 'en' })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:demo:en` + ); + }); +}); + +describe('buildSitecoreSiteCacheTag / buildSitecoreLocaleCacheTag', () => { + it('sanitizes site and locale fan-out tags', () => { + expect(buildSitecoreSiteCacheTag('My Site')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:my_site` + ); + expect(buildSitecoreLocaleCacheTag('en US')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:en_us` + ); + }); +}); + +describe('buildLoaderCacheTags', () => { + const cacheKey = 'sc:loader:page:demo:en:default:about'; + + it('includes site, locale, self-key, and custom tags', () => { + const tags = buildLoaderCacheTags('footer', pageDimensions, cacheKey, undefined, [ + 'custom:tag', + cacheKey, + ]); + + expect(tags).toContain(cacheKey); + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:demo_site`); + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:en-us`); + expect(tags).toContain('custom:tag'); + expect(tags.length).toBe(new Set(tags).size); + }); + + it('adds item tag for page loader when layout route has itemId', () => { + const pageValue = { + layout: { + sitecore: { + route: { + itemId: '{ITEM-1}', + itemLanguage: 'en', + }, + }, + }, + }; + + const tags = buildLoaderCacheTags('page', pageDimensions, cacheKey, pageValue); + + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:item-1:en:latest`); + }); + + it('skips item tag for page loader when value is not a page shape', () => { + const tags = buildLoaderCacheTags('page', pageDimensions, cacheKey, 'not-a-page'); + expect(tags.some((tag) => tag.includes(':item:'))).toBe(false); + }); + + it('adds dictionary tag for dictionary loader', () => { + const dictDimensions: CacheKeyDimensions = { + ...pageDimensions, + loaderId: 'dictionary', + }; + const dictKey = 'sc:loader:dictionary:demo:en'; + + const tags = buildLoaderCacheTags('dictionary', dictDimensions, dictKey); + + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:demo_site:en-us`); + }); +}); diff --git a/packages/angular/src/server/cache/cache-tags.ts b/packages/angular/src/server/cache/cache-tags.ts index a5c14ce64e..ccbd8c77e0 100644 --- a/packages/angular/src/server/cache/cache-tags.ts +++ b/packages/angular/src/server/cache/cache-tags.ts @@ -7,22 +7,32 @@ import { } from './utils'; import type { CacheKeyDimensions } from './models'; -/** Sitecore `sc:` namespace prefix for cache tags. */ +/** + * Sitecore OSR namespace prefix shared with Next.js (`sc:`). + * All loader cache keys and invalidation tags use this prefix. + * @public + */ export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; /** * Parameters for {@link buildSitecoreItemCacheTag}. - * @internal + * @public */ export type BuildSitecoreItemCacheTagParams = { + /** Sitecore item GUID or content id. */ itemId: string; + /** Locale/culture for the item tag. */ locale: string; + /** Optional published version; omitted values produce a `latest` suffix. */ version?: number; }; /** - * Tag for a layout/route item. Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. - * @internal + * Builds an item-scoped revalidation tag: `sc:item:<id>:<locale>:<version>`. + * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. + * @param {BuildSitecoreItemCacheTagParams} params - Item id, locale, and optional version. + * @returns {string} Sitecore item cache tag. + * @public */ export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParams): string { const id = normalizeSitecoreItemIdForCacheKey(params.itemId); @@ -35,20 +45,60 @@ export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParam } /** - * Tag for dictionary data scoped to site + locale. + * Parameters for {@link buildSitecoreDictionaryCacheTag} and related dictionary tag helpers. + * @public + */ +export type SitecoreDictionaryCacheTagParams = { + /** Site name segment. */ + site: string; + /** Locale segment. */ + locale: string; +}; + +/** + * Site entry used when fanning out dictionary loader tags from webhook middleware. + * @public + */ +export type LoaderDictionaryCacheSiteInfo = { + /** Site name. */ + name: string; + /** Optional site language; falls back to `baseLocale` when blank. */ + language?: string; +}; + +/** + * Parameters for {@link buildLoaderDictionaryCacheTagsFromSites}. + * @public + */ +export type BuildLoaderDictionaryCacheTagsFromSitesParams = { + /** Sites to emit dictionary loader tags for. */ + sites: readonly LoaderDictionaryCacheSiteInfo[]; + /** Locale used when a site entry has no `language`. */ + baseLocale: string; +}; + +/** + * Builds a Next.js-compatible dictionary tag: `sc:dict:<site>:<locale>`. + * Used for dictionary loader entries and cross-stack webhook fan-out. * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. - * @internal + * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. + * @returns {string} Dictionary cache tag. + * @public */ -export function buildSitecoreDictionaryCacheTag(params: { site: string; locale: string }): string { +export function buildSitecoreDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { const site = sanitizeSitecoreCacheSegment(params.site); const locale = sanitizeSitecoreCacheSegment(params.locale); return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; } /** - * Builds an item cache tag from layout route data when `itemId` is present. + * Builds an item tag from layout route data when `itemId` is present. + * Returns `null` when the route has no item id (non-content routes). * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. - * @internal + * @param {RouteData | null | undefined} route - Layout route metadata. + * @param {string} fallbackLocale - Locale used when `route.itemLanguage` is absent. + * @returns {string | null} Item cache tag, or `null` when no item id is available. + * @public */ export function buildSitecoreItemCacheTagFromRouteData( route: RouteData | null | undefined, @@ -69,13 +119,16 @@ export function buildSitecoreItemCacheTagFromRouteData( } /** - * Builds loader-cache dictionary tags for webhook fan-out (`sc:loader:dictionary:…`). - * @internal + * Builds loader-cache dictionary self-tags for webhook fan-out across sites. + * Produces `sc:loader:dictionary:<site>:<locale>` tags, deduped in first-seen order. + * When a site has no `language`, `baseLocale` is used. + * @param {BuildLoaderDictionaryCacheTagsFromSitesParams} params - Sites and fallback locale. + * @returns {string[]} Deduplicated loader dictionary cache tags. + * @public */ -export function buildLoaderDictionaryCacheTagsFromSites(params: { - sites: readonly { name: string; language?: string }[]; - baseLocale: string; -}): string[] { +export function buildLoaderDictionaryCacheTagsFromSites( + params: BuildLoaderDictionaryCacheTagsFromSitesParams +): string[] { const seen = new Set<string>(); const out: string[] = []; for (const site of params.sites) { @@ -90,35 +143,49 @@ export function buildLoaderDictionaryCacheTagsFromSites(params: { } /** - * Cache key / self-tag for the dictionary loader. - * @internal + * Loader-cache self-tag for the dictionary loader: `sc:loader:dictionary:<site>:<locale>`. + * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. + * @returns {string} Loader dictionary self-tag (same shape as the cache key). + * @public */ -export function buildLoaderDictionaryCacheTag(params: { site: string; locale: string }): string { +export function buildLoaderDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { const site = sanitizeSitecoreCacheSegment(params.site); const locale = sanitizeSitecoreCacheSegment(params.locale); return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:${site}:${locale}`; } /** - * Site-wide fan-out tag. - * @internal + * Site-wide fan-out tag: `sc:site:<site>`. + * Invalidating this tag marks every cached entry for the site stale. + * @param {string} site - Site name segment. + * @returns {string} Site fan-out cache tag. + * @public */ export function buildSitecoreSiteCacheTag(site: string): string { return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:${sanitizeSitecoreCacheSegment(site)}`; } /** - * Locale-wide fan-out tag. - * @internal + * Locale-wide fan-out tag: `sc:locale:<locale>`. + * @param {string} locale - Locale segment. + * @returns {string} Locale fan-out cache tag. + * @public */ export function buildSitecoreLocaleCacheTag(locale: string): string { return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:${sanitizeSitecoreCacheSegment(locale)}`; } /** - * Builds the tag set written alongside a loader cache entry (Phase 3 OSR alignment). - * Includes self-key, site, locale, item (page loader), and Next.js-compatible dict tag. - * @internal + * Builds the full tag set written alongside a loader cache entry (Phase 3 OSR alignment). + * Always includes self-tag, `sc:site:<site>`, and `sc:locale:<locale>`. Conditionally adds + * `sc:item:…` for page loaders and `sc:dict:…` for dictionary loaders. Custom tags are deduped. + * @param {string} loaderId - Loader that produced the value. + * @param {CacheKeyDimensions} dimensions - Key dimensions from {@link buildCacheKey}. + * @param {string} cacheKey - Stored cache key (also used as a self-tag). + * @param {unknown} [loaderValue] - Loader payload (page layout is inspected for item tags). + * @param {string[]} [customTags] - Optional per-route tags from `loaderResolver(id, { tags })`. + * @returns {string[]} Tag set to persist with the cache entry. + * @public */ export function buildLoaderCacheTags( loaderId: string, @@ -150,6 +217,13 @@ export function buildLoaderCacheTags( return dedupeCacheStrings(tags); } +/** + * Extracts a page item tag from a loader payload when layout route data is present. + * @param {unknown} value - Loader result (expected to be a page shape). + * @param {string} fallbackLocale - Locale used when route language is absent. + * @returns {string | null} Item cache tag, or `null` when no item id is available. + * @internal + */ function buildPageItemTag(value: unknown, fallbackLocale: string): string | null { if (!value || typeof value !== 'object') { return null; diff --git a/packages/angular/src/server/cache/cache.spec-helpers.ts b/packages/angular/src/server/cache/cache.spec-helpers.ts new file mode 100644 index 0000000000..d560a956f2 --- /dev/null +++ b/packages/angular/src/server/cache/cache.spec-helpers.ts @@ -0,0 +1,147 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { LoaderCache, LoaderContext } from '../../loaders/models'; +import { buildCacheKey } from './cache-key'; +import { buildLoaderCacheTags } from './cache-tags'; + +export const sampleContext: LoaderContext = { + url: '/products', + params: { site: 'shop', locale: 'en' }, + query: {}, +}; + +export function sampleKey(loaderId = 'page'): string { + return buildCacheKey(loaderId, sampleContext).key; +} + +export function sampleTags(loaderId = 'page', value?: unknown): string[] { + const { key, dimensions } = buildCacheKey(loaderId, sampleContext); + return buildLoaderCacheTags(loaderId, dimensions, key, value); +} + +export function runSharedLoaderCacheContract( + label: string, + createCache: () => LoaderCache | Promise<LoaderCache>, + cleanup?: () => Promise<void> +): void { + describe(`${label} shared cache contract`, () => { + let cache: LoaderCache; + + beforeEach(async () => { + cache = await createCache(); + }); + + afterEach(async () => { + await cleanup?.(); + vi.useRealTimers(); + }); + + it('returns miss on empty key and hit after set', async () => { + const key = sampleKey(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + + await cache.set(key, { title: 'Products' }, 300, sampleTags()); + expect(await cache.get(key)).toEqual({ + kind: 'hit', + value: { title: 'Products' }, + cacheKey: key, + }); + }); + + it('returns stale after TTL expires without deleting the entry', async () => { + vi.useFakeTimers(); + const key = sampleKey('expiring'); + await cache.set(key, { stale: true }, 30, sampleTags('expiring')); + + vi.advanceTimersByTime(31_000); + expect(await cache.get(key)).toEqual({ + kind: 'stale', + value: { stale: true }, + cacheKey: key, + }); + }); + + it('keeps zero-TTL entries until explicitly invalidated', async () => { + vi.useFakeTimers(); + const key = sampleKey('persistent'); + await cache.set(key, { permanent: true }, 0, sampleTags('persistent')); + + vi.advanceTimersByTime(3_600_000); + expect((await cache.get(key)).kind).toBe('hit'); + }); + + it('marks matching entries stale by site tag without deleting them', async () => { + const keyA = sampleKey('page'); + const keyB = buildCacheKey('footer', { ...sampleContext, url: '/other' }).key; + + await cache.set(keyA, { page: true }, 300, sampleTags('page')); + const tagsB = buildLoaderCacheTags( + 'footer', + buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions, + keyB + ); + await cache.set(keyB, { footer: true }, 300, tagsB); + + expect(await cache.invalidate({ tags: ['sc:site:shop'] })).toBe(2); + expect((await cache.get(keyA)).kind).toBe('stale'); + expect((await cache.get(keyB)).kind).toBe('stale'); + }); + + it('marks a single entry stale by self-key tag', async () => { + const key = sampleKey('page'); + await cache.set(key, { page: true }, 300, sampleTags('page')); + + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('deletes a key and unlinks it from the tag index', async () => { + const key = sampleKey('delete-me'); + await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); + + expect(await cache.delete(key)).toBe(true); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + expect(await cache.delete(key)).toBe(false); + }); + + it('flushes every entry', async () => { + const key = sampleKey('flush-me'); + await cache.set(key, { temp: true }, 300, sampleTags('flush-me')); + await cache.flush(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + }); + + it('returns zero from invalidate when tags are empty', async () => { + const key = sampleKey('no-tags'); + await cache.set(key, { keep: true }, 300, sampleTags('no-tags')); + expect(await cache.invalidate({ tags: [] })).toBe(0); + expect((await cache.get(key)).kind).toBe('hit'); + }); + + it('relinks tag index when overwriting an entry', async () => { + const key = sampleKey('overwrite'); + await cache.set(key, { v: 1 }, 300, ['sc:site:old']); + await cache.set(key, { v: 2 }, 300, ['sc:site:new']); + + expect(await cache.invalidate({ tags: ['sc:site:old'] })).toBe(0); + expect(await cache.invalidate({ tags: ['sc:site:new'] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('lists entry metadata without values', async () => { + const liveKey = sampleKey('live'); + await cache.set(liveKey, { live: true }, 300, sampleTags('live')); + await cache.invalidate({ tags: [liveKey] }); + + const live = (await cache.entries()).find((entry) => entry.key === liveKey); + expect(live?.tags).toEqual(sampleTags('live')); + expect(live?.stale).toBe(true); + }); + + it('exposes resolved config and ttl', () => { + expect(cache.enabled()).toBe(true); + expect(cache.resolveTtl()).toBe(300); + expect(cache.getConfig()).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); + }); + }); +} diff --git a/packages/angular/src/server/cache/default-in-memory-cache.spec.ts b/packages/angular/src/server/cache/default-in-memory-cache.spec.ts new file mode 100644 index 0000000000..80ea44820e --- /dev/null +++ b/packages/angular/src/server/cache/default-in-memory-cache.spec.ts @@ -0,0 +1,81 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import { InMemoryLoaderCache, InMemoryTagIndex } from './default-in-memory-cache'; +import { runSharedLoaderCacheContract, sampleKey, sampleTags } from './cache.spec-helpers'; + +describe('InMemoryTagIndex', () => { + it('links keys under multiple tags and resolves the union', () => { + const index = new InMemoryTagIndex(); + index.link('key-a', ['sc:site:demo', 'sc:locale:en']); + index.link('key-b', ['sc:site:demo']); + + expect(index.resolveKeys(['sc:site:demo']).has('key-a')).toBe(true); + expect(index.resolveKeys(['sc:site:demo']).has('key-b')).toBe(true); + expect(index.resolveKeys(['sc:locale:en']).has('key-a')).toBe(true); + expect(index.resolveKeys(['sc:locale:en']).has('key-b')).toBe(false); + }); + + it('unlinks keys and removes empty tag buckets', () => { + const index = new InMemoryTagIndex(); + index.link('key-a', ['sc:site:demo']); + index.unlink('key-a', ['sc:site:demo']); + + expect(index.resolveKeys(['sc:site:demo']).size).toBe(0); + }); + + it('clears all tag buckets', () => { + const index = new InMemoryTagIndex(); + index.link('key-a', ['sc:site:demo', 'sc:locale:en']); + index.clear(); + + expect(index.resolveKeys(['sc:site:demo']).size).toBe(0); + expect(index.resolveKeys(['sc:locale:en']).size).toBe(0); + }); +}); + +describe('InMemoryLoaderCache', () => { + runSharedLoaderCacheContract( + 'InMemoryLoaderCache', + () => new InMemoryLoaderCache({ revalidate: 300, defaultSiteName: 'default' }) + ); + + it('applies config defaults from the constructor', () => { + const cache = new InMemoryLoaderCache({ revalidate: 60, enabled: false }); + expect(cache.resolveTtl()).toBe(60); + expect(cache.enabled()).toBe(false); + expect(cache.getConfig()).toMatchObject({ + revalidate: 60, + enabled: false, + defaultSiteName: 'default', + defaultLocale: 'en', + }); + }); + + it('markStale returns false for missing keys', async () => { + const cache = new InMemoryLoaderCache({ revalidate: 300 }); + expect(await cache.markStale('missing-key')).toBe(false); + }); + + it('markStale returns true for already stale entries without rewriting them', async () => { + const cache = new InMemoryLoaderCache({ revalidate: 300 }); + const key = sampleKey('already-stale'); + await cache.set(key, { value: true }, 300, sampleTags('already-stale')); + await cache.markStale(key); + + const before = await cache.get(key); + expect(await cache.markStale(key)).toBe(true); + const after = await cache.get(key); + expect(after).toEqual(before); + }); + + it('does not leave stale tag pointers after delete', async () => { + const cache = new InMemoryLoaderCache({ revalidate: 300 }); + const key = sampleKey('deleted-tag'); + const tag = 'sc:site:deleted'; + + await cache.set(key, { value: true }, 300, [tag, key]); + await cache.delete(key); + + expect(await cache.invalidate({ tags: [tag] })).toBe(0); + }); +}); diff --git a/packages/angular/src/server/cache/default-in-memory-cache.ts b/packages/angular/src/server/cache/default-in-memory-cache.ts index 12e31d6ca3..74c017975a 100644 --- a/packages/angular/src/server/cache/default-in-memory-cache.ts +++ b/packages/angular/src/server/cache/default-in-memory-cache.ts @@ -9,7 +9,67 @@ import { import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; /** - * Default LoaderCache implementation: in-process Map + tag → keys index. + * In-process tag index mapping OSR tags to cache keys. + * Maintained alongside {@link InMemoryLoaderCache} entries for O(1) tag invalidation. + * @internal + */ +export class InMemoryTagIndex { + private readonly tagToKeys = new Map<string, Set<string>>(); + + /** + * Registers `cacheKey` under each tag in the index. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to link. + */ + link(cacheKey: string, tags: string[]): void { + for (const tag of tags) { + if (!this.tagToKeys.has(tag)) { + this.tagToKeys.set(tag, new Set()); + } + this.tagToKeys.get(tag)!.add(cacheKey); + } + } + + /** + * Removes `cacheKey` from each tag bucket; deletes empty buckets. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to unlink. + */ + unlink(cacheKey: string, tags: string[]): void { + for (const tag of tags) { + const keys = this.tagToKeys.get(tag); + keys?.delete(cacheKey); + if (keys?.size === 0) { + this.tagToKeys.delete(tag); + } + } + } + + /** + * Union of cache keys linked to any of the supplied tags. + * @param {string[]} tags - Tags to resolve. + * @returns {Set<string>} Matching cache keys. + */ + resolveKeys(tags: string[]): Set<string> { + const out = new Set<string>(); + for (const tag of tags) { + for (const key of this.tagToKeys.get(tag) ?? []) { + out.add(key); + } + } + return out; + } + + /** Clears every tag bucket. */ + clear(): void { + this.tagToKeys.clear(); + } +} + +/** + * Default {@link LoaderCache} implementation: in-process `Map` plus {@link InMemoryTagIndex}. + * Suitable for single-process dev and tests. For persistence or multi-instance deploys, + * pass an unstorage driver to {@link createLoaderCache} instead. * @internal */ export class InMemoryLoaderCache implements LoaderCache { @@ -17,15 +77,20 @@ export class InMemoryLoaderCache implements LoaderCache { private readonly store = new Map<string, LoaderCacheEntry>(); private readonly tagIndex = new InMemoryTagIndex(); + /** + * @param {LoaderCacheConfig} [config] - Partial cache configuration. + */ constructor(config: LoaderCacheConfig = {}) { this.config = applyLoaderCacheConfigDefaults(config); } + /** @inheritdoc */ async get(key: string): Promise<LoaderCacheReadResult> { const entry = this.store.get(key); return evaluateCacheRead(key, entry ?? null); } + /** @inheritdoc */ async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { const existing = this.store.get(key); if (existing) { @@ -43,6 +108,7 @@ export class InMemoryLoaderCache implements LoaderCache { this.tagIndex.link(key, tags); } + /** @inheritdoc */ async invalidate(filter: InvalidateInput): Promise<number> { const tags = filter.tags ?? []; if (tags.length === 0) { @@ -58,6 +124,11 @@ export class InMemoryLoaderCache implements LoaderCache { return marked; } + /** + * Marks a single entry stale without deleting it (SWR semantics). + * @param {string} key - Cache entry key. + * @returns {boolean} `false` when missing; `true` when the entry exists (including already stale). + */ async markStale(key: string): Promise<boolean> { const entry = this.store.get(key); if (!entry) { @@ -70,6 +141,7 @@ export class InMemoryLoaderCache implements LoaderCache { return true; } + /** @inheritdoc */ async delete(key: string): Promise<boolean> { const entry = this.store.get(key); if (!entry) { @@ -80,11 +152,13 @@ export class InMemoryLoaderCache implements LoaderCache { return true; } + /** @inheritdoc */ async flush(): Promise<void> { this.store.clear(); this.tagIndex.clear(); } + /** @inheritdoc */ async entries(): Promise<LoaderCacheEntryInfo[]> { const out: LoaderCacheEntryInfo[] = []; for (const [key, entry] of this.store) { @@ -99,56 +173,18 @@ export class InMemoryLoaderCache implements LoaderCache { return out; } + /** @inheritdoc */ resolveTtl(): number { return this.config.revalidate; } + /** @inheritdoc */ enabled(): boolean { return this.config.enabled; } + /** @inheritdoc */ getConfig(): Readonly<LoaderCacheConfig> { return this.config; } } - -/** - * In-process tag index: tag → set of cache keys. - * @internal - */ -export class InMemoryTagIndex { - private readonly tagToKeys = new Map<string, Set<string>>(); - - link(cacheKey: string, tags: string[]): void { - for (const tag of tags) { - if (!this.tagToKeys.has(tag)) { - this.tagToKeys.set(tag, new Set()); - } - this.tagToKeys.get(tag)!.add(cacheKey); - } - } - - unlink(cacheKey: string, tags: string[]): void { - for (const tag of tags) { - const keys = this.tagToKeys.get(tag); - keys?.delete(cacheKey); - if (keys?.size === 0) { - this.tagToKeys.delete(tag); - } - } - } - - resolveKeys(tags: string[]): Set<string> { - const out = new Set<string>(); - for (const tag of tags) { - for (const key of this.tagToKeys.get(tag) ?? []) { - out.add(key); - } - } - return out; - } - - clear(): void { - this.tagToKeys.clear(); - } -} diff --git a/packages/angular/src/server/cache/loader-cache.spec.ts b/packages/angular/src/server/cache/loader-cache.spec.ts index c1da650349..eb2bc90660 100644 --- a/packages/angular/src/server/cache/loader-cache.spec.ts +++ b/packages/angular/src/server/cache/loader-cache.spec.ts @@ -1,218 +1,30 @@ /* eslint-disable jsdoc/require-jsdoc */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import memoryDriver from 'unstorage/drivers/memory'; -import fsDriver from 'unstorage/drivers/fs'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import type { LoaderCache } from '../../loaders/models'; import { createLoaderCache } from './loader-cache'; -import { buildCacheKey } from './cache-key'; -import { buildLoaderCacheTags } from './cache-tags'; -import type { LoaderContext } from '../../loaders/models'; +import { InMemoryLoaderCache } from './default-in-memory-cache'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { sampleKey, sampleTags } from './cache.spec-helpers'; -const sampleContext: LoaderContext = { - url: '/products', - params: { site: 'shop', locale: 'en' }, - query: {}, -}; - -function sampleKey(loaderId = 'page') { - return buildCacheKey(loaderId, sampleContext).key; -} - -function sampleTags(loaderId = 'page', value?: unknown) { - const { key, dimensions } = buildCacheKey(loaderId, sampleContext); - return buildLoaderCacheTags(loaderId, dimensions, key, value); -} - -async function runSharedLoaderCacheContract( - label: string, - createCache: () => LoaderCache | Promise<LoaderCache>, - cleanup?: () => Promise<void> -) { - describe(`${label} shared cache contract`, () => { - let cache: LoaderCache; - - beforeEach(async () => { - cache = await createCache(); - }); - - afterEach(async () => { - await cleanup?.(); - vi.useRealTimers(); - }); - - describe('when storing and reading loader output', () => { - it('returns miss on empty key and hit after set', async () => { - const key = sampleKey(); - expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); - - await cache.set(key, { title: 'Products' }, 300, sampleTags()); - const hit = await cache.get(key); - - expect(hit).toEqual({ kind: 'hit', value: { title: 'Products' }, cacheKey: key }); - }); - }); - - describe('when an entry TTL expires', () => { - it('returns stale (does not delete) so SWR can serve last-known-good', async () => { - vi.useFakeTimers(); - const key = sampleKey('expiring'); - await cache.set(key, { stale: true }, 30, sampleTags('expiring')); - - vi.advanceTimersByTime(31_000); - const read = await cache.get(key); - expect(read).toEqual({ kind: 'stale', value: { stale: true }, cacheKey: key }); - }); - }); - - describe('when ttl is zero or negative', () => { - it('keeps the entry until explicitly invalidated', async () => { - vi.useFakeTimers(); - const key = sampleKey('persistent'); - await cache.set(key, { permanent: true }, 0, sampleTags('persistent')); - - vi.advanceTimersByTime(3600_000); - const read = await cache.get(key); - expect(read.kind).toBe('hit'); - }); - }); - - describe('when invalidating by tag', () => { - it('marks matching entries stale without deleting them', async () => { - const keyA = sampleKey('page'); - const keyB = buildCacheKey('footer', { - ...sampleContext, - url: '/other', - }).key; - - await cache.set(keyA, { page: true }, 300, sampleTags('page')); - const tagsB = buildLoaderCacheTags( - 'footer', - buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions, - keyB - ); - await cache.set(keyB, { footer: true }, 300, tagsB); - - const marked = await cache.invalidate({ tags: ['sc:site:shop'] }); - - expect(marked).toBe(2); - expect(await cache.get(keyA)).toEqual({ - kind: 'stale', - value: { page: true }, - cacheKey: keyA, - }); - expect(await cache.get(keyB)).toEqual({ - kind: 'stale', - value: { footer: true }, - cacheKey: keyB, - }); - }); - - it('marks a single entry stale by self-key tag', async () => { - const key = sampleKey('page'); - await cache.set(key, { page: true }, 300, sampleTags('page')); - - const marked = await cache.invalidate({ tags: [key] }); - expect(marked).toBe(1); - expect((await cache.get(key)).kind).toBe('stale'); - }); - }); - - describe('when deleting entries', () => { - it('removes a single key and reports whether it existed', async () => { - const key = sampleKey('delete-me'); - await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); - - expect(await cache.delete(key)).toBe(true); - expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); - expect(await cache.delete(key)).toBe(false); - }); - }); - - describe('when flushing entries', () => { - it('removes every key from the in-memory backend', async () => { - if (label !== 'InMemoryLoaderCache') return; - - const key = sampleKey('flush-me'); - await cache.set(key, { temp: true }, 300, sampleTags('flush-me')); - await cache.flush(); - expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); - }); - }); - - describe('when listing entries for admin tooling', () => { - it('returns metadata without values and includes stale flag', async () => { - const liveKey = sampleKey('live'); - await cache.set(liveKey, { live: true }, 300, sampleTags('live')); - await cache.invalidate({ tags: [liveKey] }); - - const entries = await cache.entries(); - const live = entries.find((entry) => entry.key === liveKey); - expect(live?.tags).toEqual(sampleTags('live')); - expect(live?.stale).toBe(true); - }); - }); - - describe('when reading cache configuration', () => { - it('reports enabled state and default ttl from the resolved config', () => { - expect(cache.enabled()).toBe(true); - expect(cache.resolveTtl()).toBe(300); - expect(cache.getConfig()).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); - }); - }); - }); -} - -runSharedLoaderCacheContract('InMemoryLoaderCache', () => - createLoaderCache({ revalidate: 300, defaultSiteName: 'default' }) -); - -runSharedLoaderCacheContract('UnstorageLoaderCache (memory driver)', () => - createLoaderCache({ driver: memoryDriver(), revalidate: 300 }) -); - -describe('UnstorageLoaderCache (fs driver)', () => { - let cacheDir: string; - - beforeEach(async () => { - cacheDir = await mkdtemp(join(tmpdir(), 'sc-loader-cache-')); +describe('createLoaderCache factory', () => { + it('returns an InMemoryLoaderCache when no driver is supplied', () => { + const cache = createLoaderCache(); + expect(cache).toBeInstanceOf(InMemoryLoaderCache); }); - afterEach(async () => { - await rm(cacheDir, { recursive: true, force: true }); + it('returns an UnstorageLoaderCache when a driver is supplied', () => { + const cache = createLoaderCache({ driver: memoryDriver(), revalidate: 300 }); + expect(cache).toBeInstanceOf(UnstorageLoaderCache); }); - it('persists entries across separate cache instances on disk', async () => { - const key = sampleKey('persisted'); - const tags = sampleTags('persisted'); - - const writer = createLoaderCache({ - driver: fsDriver({ base: cacheDir }), - revalidate: 300, - }); - await writer.set(key, { persisted: true }, 300, tags); - - const reader = createLoaderCache({ - driver: fsDriver({ base: cacheDir }), - revalidate: 300, - }); - const hit = await reader.get(key); - - expect(hit).toEqual({ kind: 'hit', value: { persisted: true }, cacheKey: key }); - }); -}); - -describe('createLoaderCache factory', () => { - it('uses the in-memory backend when no unstorage driver is supplied', async () => { + it('uses the in-memory backend for get/set when no driver is supplied', async () => { const cache = createLoaderCache(); const key = sampleKey('factory-default'); await cache.set(key, { ok: true }, 300, sampleTags('factory-default')); expect((await cache.get(key)).kind).toBe('hit'); }); - it('uses the unstorage backend when a driver is supplied', async () => { + it('uses the unstorage backend for get/set when a driver is supplied', async () => { const cache = createLoaderCache({ driver: memoryDriver(), revalidate: 300, diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index bb056b1998..3f25ddfc7d 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -6,18 +6,22 @@ import { resolveConfig } from './utils'; /** * Public factory for the loader cache. Dispatches to the right backend: + * - `config.driver` provided → {@link UnstorageLoaderCache} wrapping the driver in `createStorage({ driver })` + * - otherwise → {@link InMemoryLoaderCache} (plain Map) * - * - `config.driver` provided → {@link UnstorageLoaderCache} wrapping the - * driver in `createStorage({ driver })` - * - otherwise → {@link InMemoryLoaderCache} (plain Map) - * - * Drivers are imported and constructed in the app's `server.ts` and passed - * here as an instance. The cache module does not know about driver-specific - * options (filesystem base path, Redis URL, etc.) — the app owns that. - * - * Callers depend on the {@link LoaderCache} interface; concrete classes are - * not exported, so we can swap implementations without touching public types. - * See plan §4.3. + * Drivers are imported and constructed in the app's `server.ts` and passed here as an instance. + * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. + * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. + * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. + * @example + * ```ts + * const cache = createLoaderCache({ + * revalidate: config.angular.isrCache.revalidate, + * enabled: config.angular.isrCache.enabled, + * defaultSiteName: config.defaultSite, + * driver: fsDriver({ base: './.cache/loaders' }), + * }); + * ``` * @public */ export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index a355347982..8b9ae99ee9 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -1,17 +1,23 @@ import { Driver } from 'unstorage'; import { LoaderCacheConfig } from '../../loaders/models'; +/** Default global revalidate TTL (seconds) when {@link LoaderCacheConfig.revalidate} is omitted. @public */ export const DEFAULT_CACHE_TTL = 300; /** - * Identity dimensions of a cache key. Derived from LoaderContext by buildCacheKey(). + * Identity dimensions of a cache key. Derived from {@link LoaderContext} by {@link buildCacheKey}. * @public */ export interface CacheKeyDimensions { + /** Site name from route params (defaults to `'default'`). */ site: string; + /** Locale from route params (defaults to `'en'`). */ locale: string; + /** Personalization variant segment (currently always `'default'` until Phase 4). */ variantId: string; + /** Loader id (`page`, `dictionary`, etc.). */ loaderId: string; + /** Sanitized path segment from the loader URL; home route uses `'_'`. */ pathKey: string; } diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts new file mode 100644 index 0000000000..d8b493f1ed --- /dev/null +++ b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts @@ -0,0 +1,142 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import memoryDriver from 'unstorage/drivers/memory'; +import fsDriver from 'unstorage/drivers/fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { buildCacheKey } from './cache-key'; +import { + runSharedLoaderCacheContract, + sampleContext, + sampleKey, + sampleTags, +} from './cache.spec-helpers'; + +function getStorage(cache: UnstorageLoaderCache) { + return ( + cache as unknown as { + storage: { + getItem: <T>(key: string) => Promise<T | null>; + removeItem: (key: string) => Promise<void>; + }; + } + ).storage; +} + +describe('UnstorageLoaderCache', () => { + runSharedLoaderCacheContract( + 'UnstorageLoaderCache (memory driver)', + () => new UnstorageLoaderCache(memoryDriver(), { revalidate: 300, defaultSiteName: 'default' }) + ); + + it('applies config defaults from the constructor', () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 120, enabled: false }); + expect(cache.resolveTtl()).toBe(120); + expect(cache.enabled()).toBe(false); + expect(cache.getConfig()).toMatchObject({ revalidate: 120, enabled: false }); + }); + + it('returns false when deleting a missing key', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + expect(await cache.delete('sc:loader:page:missing')).toBe(false); + }); + + it('skips missing entries while invalidating stale tags', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('ghost'); + await cache.set(key, { ghost: true }, 300, sampleTags('ghost')); + await cache.delete(key); + + expect(await cache.invalidate({ tags: [key] })).toBe(0); + }); + + it('counts already stale entries during invalidate without rewriting them', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('stale-twice'); + await cache.set(key, { value: 1 }, 300, sampleTags('stale-twice')); + + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('omits ghost keys from entries listing', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('ghost-entry'); + await cache.set(key, { live: true }, 300, sampleTags('ghost-entry')); + + const storage = getStorage(cache); + await storage.removeItem(key); + + const entries = await cache.entries(); + expect(entries.find((entry) => entry.key === key)).toBeUndefined(); + }); + + it('keeps tag index entries when other keys still reference the tag', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const sharedTag = 'sc:site:shared'; + const keyA = sampleKey('shared-a'); + const keyB = buildCacheKey('footer', { ...sampleContext, url: '/footer' }).key; + + await cache.set(keyA, { a: true }, 300, [sharedTag, keyA]); + await cache.set(keyB, { b: true }, 300, [sharedTag, keyB]); + + expect(await cache.delete(keyA)).toBe(true); + expect(await cache.invalidate({ tags: [sharedTag] })).toBe(1); + expect((await cache.get(keyB)).kind).toBe('stale'); + }); + + it('stores tag index buckets under tag:{tag} keys', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('tag-index'); + const tag = 'sc:site:tag-index'; + + await cache.set(key, { ok: true }, 300, [tag, key]); + + const storage = getStorage(cache); + const indexedKeys = await storage.getItem<string[]>(`tag:${tag}`); + expect(indexedKeys).toContain(key); + }); + + it('does not duplicate cache keys in a tag bucket when set is called twice', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('dedupe-tag'); + const tag = 'sc:site:dedupe'; + + await cache.set(key, { v: 1 }, 300, [tag]); + await cache.set(key, { v: 2 }, 300, [tag]); + + const storage = getStorage(cache); + const indexedKeys = await storage.getItem<string[]>(`tag:${tag}`); + expect(indexedKeys?.filter((entryKey) => entryKey === key)).toHaveLength(1); + }); +}); + +describe('UnstorageLoaderCache (fs driver)', () => { + let cacheDir: string; + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'sc-loader-cache-')); + }); + + afterEach(async () => { + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('persists entries across separate cache instances on disk', async () => { + const key = sampleKey('persisted'); + const tags = sampleTags('persisted'); + + const writer = new UnstorageLoaderCache(fsDriver({ base: cacheDir }), { revalidate: 300 }); + await writer.set(key, { persisted: true }, 300, tags); + + const reader = new UnstorageLoaderCache(fsDriver({ base: cacheDir }), { revalidate: 300 }); + expect(await reader.get(key)).toEqual({ + kind: 'hit', + value: { persisted: true }, + cacheKey: key, + }); + }); +}); diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index 4c6e84460e..93e6b6c712 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -11,31 +11,36 @@ import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; import { CACHE_KEY_PREFIX } from './cache-key'; import { GlobalLoaderCacheConfig } from './models'; -/** - * Unstorage-backed {@link LoaderCache}. - * - * Two key spaces in one driver: - * - `{cacheKey}` → loader entry (value + metadata; tags copied on the entry) - * - `tag:{tag}` → `string[]` of cache keys pointing at that entry - * @internal - */ /** Prefix for tag-index keys in unstorage (entries use `sc:loader:…` keys directly). */ const TAG_INDEX_PREFIX = 'tag:'; +/** + * Unstorage-backed {@link LoaderCache} for persistent or shared storage. + * Two key spaces share one driver: `{cacheKey}` entries and `tag:{tag}` index arrays. + * Semantics match {@link InMemoryLoaderCache}: `invalidate` marks stale; `get` uses + * {@link evaluateCacheRead} for hit/stale/miss. + * @internal + */ export class UnstorageLoaderCache implements LoaderCache { private readonly storage: Storage; private readonly config: Required<LoaderCacheConfig>; + /** + * @param {Driver} driver - Unstorage driver instance from the app (`server.ts`). + * @param {LoaderCacheConfig} [config] - Resolved cache configuration. + */ constructor(driver: Driver, config: LoaderCacheConfig = {}) { this.storage = createStorage({ driver }); this.config = applyLoaderCacheConfigDefaults(config); } + /** @inheritdoc */ async get(cacheKey: string): Promise<LoaderCacheReadResult> { const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); return evaluateCacheRead(cacheKey, entry ?? null); } + /** @inheritdoc */ async set(cacheKey: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { const existing = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); if (existing) { @@ -54,6 +59,7 @@ export class UnstorageLoaderCache implements LoaderCache { await this.linkTags(cacheKey, tags); } + /** @inheritdoc */ async invalidate(filter: InvalidateInput): Promise<number> { const tags = filter.tags ?? []; if (tags.length === 0) { @@ -74,6 +80,7 @@ export class UnstorageLoaderCache implements LoaderCache { return marked; } + /** @inheritdoc */ async delete(cacheKey: string): Promise<boolean> { const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); if (!entry) { @@ -84,10 +91,12 @@ export class UnstorageLoaderCache implements LoaderCache { return true; } + /** @inheritdoc */ async flush(): Promise<void> { await this.storage.clear(); } + /** @inheritdoc */ async entries(): Promise<LoaderCacheEntryInfo[]> { const keys = await this.storage.getKeys(CACHE_KEY_PREFIX); const out: LoaderCacheEntryInfo[] = []; @@ -107,28 +116,44 @@ export class UnstorageLoaderCache implements LoaderCache { return out; } + /** @inheritdoc */ resolveTtl(): number { return this.config.revalidate; } + /** @inheritdoc */ enabled(): boolean { return this.config.enabled; } + /** @inheritdoc */ getConfig(): Readonly<GlobalLoaderCacheConfig> { return this.config; } - /** Cache entry: OSR-aligned `sc:loader:…` key → loader payload. */ + /** + * Cache entry storage key (OSR-aligned `sc:loader:…`). + * @param {string} cacheKey - Public loader cache key. + * @returns {string} Unstorage key for the entry payload. + */ private cacheStorageKey(cacheKey: string): string { return cacheKey; } - /** Tag index: `tag:{tag}` → cache keys. */ + /** + * Tag index storage key (`tag:{tag}`). + * @param {string} tag - OSR cache tag. + * @returns {string} Unstorage key for the tag index bucket. + */ private tagStorageKey(tag: string): string { return `${TAG_INDEX_PREFIX}${tag}`; } + /** + * Links a cache key into each tag bucket. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to link. + */ private async linkTags(cacheKey: string, tags: string[]): Promise<void> { for (const tag of tags) { const storageKey = this.tagStorageKey(tag); @@ -139,6 +164,11 @@ export class UnstorageLoaderCache implements LoaderCache { } } + /** + * Unlinks a cache key from each tag bucket. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to unlink. + */ private async unlinkTags(cacheKey: string, tags: string[]): Promise<void> { for (const tag of tags) { const storageKey = this.tagStorageKey(tag); @@ -152,6 +182,7 @@ export class UnstorageLoaderCache implements LoaderCache { } } + /** @param {string[]} tags - Tags to resolve. @returns {Promise<Set<string>>} Matching cache keys. */ private async resolveCacheKeysFromTags(tags: string[]): Promise<Set<string>> { const out = new Set<string>(); for (const tag of tags) { diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts index 9ae6372841..15cb7c7b11 100644 --- a/packages/angular/src/server/cache/utils.spec.ts +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect } from 'vitest'; -import { approxByteSize, dimensionsFromContext, resolveConfig, applyLoaderCacheConfigDefaults, urlToPathKey } from './utils'; +import { approxByteSize, dimensionsFromContext, resolveConfig, applyLoaderCacheConfigDefaults, urlToPathKey, evaluateCacheRead, sanitizeSitecoreCacheSegment, normalizeSitecoreItemIdForCacheKey, dedupeCacheStrings } from './utils'; import { DEFAULT_CACHE_TTL } from './models'; describe('urlToPathKey', () => { @@ -75,3 +75,57 @@ describe('approxByteSize', () => { expect(approxByteSize(circular)).toBe(0); }); }); + +describe('evaluateCacheRead', () => { + it('returns miss when entry is absent', () => { + expect(evaluateCacheRead('sc:key', null)).toEqual({ kind: 'miss', cacheKey: 'sc:key' }); + }); + + it('returns hit for fresh non-stale entries', () => { + const now = 1_000_000; + expect( + evaluateCacheRead( + 'sc:key', + { value: { ok: true }, tags: [], storedAt: now, expiresAt: now + 60_000, stale: false }, + now + ) + ).toEqual({ kind: 'hit', value: { ok: true }, cacheKey: 'sc:key' }); + }); + + it('returns stale when entry is flagged stale or past expiry', () => { + const now = 1_000_000; + expect( + evaluateCacheRead( + 'sc:key', + { value: { old: true }, tags: [], storedAt: now - 120_000, expiresAt: now - 1, stale: false }, + now + ) + ).toEqual({ kind: 'stale', value: { old: true }, cacheKey: 'sc:key' }); + + expect( + evaluateCacheRead( + 'sc:key', + { value: { flagged: true }, tags: [], storedAt: now, expiresAt: null, stale: true }, + now + ) + ).toEqual({ kind: 'stale', value: { flagged: true }, cacheKey: 'sc:key' }); + }); +}); + +describe('sanitizeSitecoreCacheSegment', () => { + it('lowercases and replaces separators with underscores', () => { + expect(sanitizeSitecoreCacheSegment(' Demo/Site ')).toBe('demo_site'); + }); +}); + +describe('normalizeSitecoreItemIdForCacheKey', () => { + it('strips braces and lowercases item ids', () => { + expect(normalizeSitecoreItemIdForCacheKey(' {ABC-123} ')).toBe('abc-123'); + }); +}); + +describe('dedupeCacheStrings', () => { + it('preserves first-seen order while removing duplicates', () => { + expect(dedupeCacheStrings(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 1760d04b1c..81407e7839 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -7,7 +7,10 @@ import { import { GlobalLoaderCacheConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; /** - * @deprecated only used for demo purposes. remove before release. + * Approximate serialized byte size of a cache value (demo/admin helper). + * @param {unknown} value - Value to measure. + * @returns {number} JSON string length, or `0` when serialization fails. + * @deprecated Only used for demo purposes. Remove before release. */ export function approxByteSize(value: unknown): number { try { @@ -17,14 +20,30 @@ export function approxByteSize(value: unknown): number { } } +/** + * Removes the query string from a URL path. + * @param {string} url - URL or path that may include `?query`. + * @returns {string} Pathname without query string. + * @internal + */ function stripQuery(url: string): string { const i = url.indexOf('?'); return i === -1 ? url : url.slice(0, i); } /** - * Converts a loader URL to the pathKey segment used in OSR-aligned cache keys. - * Strips an optional leading locale segment when it matches `params.locale`. + * Converts a loader URL to the `pathKey` segment used in OSR-aligned cache keys. + * Strips query strings, trims slashes, sanitizes segments, and removes a leading + * locale prefix when it matches `params.locale`. Home resolves to `'_'`. + * @param {string} url - Loader URL (may include query string). + * @param {string} [locale] - Optional locale used to strip a leading `/locale` prefix. + * @returns {string} Sanitized path key (`'_'` for home). + * @example + * ```ts + * urlToPathKey('/'); // '_' + * urlToPathKey('/About Us'); // 'about_us' + * urlToPathKey('/en/about', 'en'); // 'about' + * ``` * @internal */ export function urlToPathKey(url: string, locale?: string): string { @@ -40,13 +59,17 @@ export function urlToPathKey(url: string, locale?: string): string { } /** - * Builder hook for tests and the admin endpoint. + * Derives {@link CacheKeyDimensions} from a loader context. + * Used by {@link buildCacheKey} and admin tooling. + * @param {string} loaderId - Loader id being resolved. + * @param {LoaderContext} ctx - Loader context (URL + route params). + * @returns {CacheKeyDimensions} Parsed cache key dimensions. * @internal */ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { const params = (ctx.params ?? {}) as Record<string, unknown>; - const site = (params?.['site'] as string) || 'default'; - const locale = (params?.['locale'] as string) || 'en'; + const site = (params?.site as string) || 'default'; + const locale = (params?.locale as string) || 'en'; const pathKey = urlToPathKey(ctx.url || '/', locale); return { @@ -59,16 +82,21 @@ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): Cac } /** - * Strips `driver` from {@link GlobalLoaderCacheConfig}. + * Strips `driver` from {@link GlobalLoaderCacheConfig} before passing config to backends. + * @param {GlobalLoaderCacheConfig} config - Global cache config from {@link createLoaderCache}. + * @returns {LoaderCacheConfig} Backend-safe config without the unstorage driver instance. * @internal */ export function resolveConfig(config: GlobalLoaderCacheConfig): LoaderCacheConfig { - const { driver: _, ...rest } = config; + const { driver, ...rest } = config; + void driver; return rest; } /** * Applies defaults for every {@link LoaderCacheConfig} field. + * @param {LoaderCacheConfig} [config] - Partial config from `createLoaderCache()` or a backend constructor. + * @returns {Required<LoaderCacheConfig>} Fully populated config used by cache backends. * @internal */ export function applyLoaderCacheConfigDefaults( @@ -85,7 +113,11 @@ export function applyLoaderCacheConfigDefaults( } /** - * Maps a stored entry to the three-outcome read result used by the resolver (Phase 3 SWR). + * Maps a stored entry to the three-outcome read result used by {@link ServerLoaderDataProvider} (Phase 3 SWR). + * @param {string} cacheKey - Key being read. + * @param {LoaderCacheEntry | null | undefined} entry - Stored entry, if any. + * @param {number} [now] - Current timestamp for TTL comparison (defaults to `Date.now()`). + * @returns {LoaderCacheReadResult} Hit, stale, or miss classification. * @internal */ export function evaluateCacheRead( @@ -103,7 +135,9 @@ export function evaluateCacheRead( } /** - * Sanitizes a segment for Sitecore cache keys and tags. + * Sanitizes a segment for Sitecore cache keys and tags (lowercase, separators → `_`). + * @param {string} value - Raw segment from site, locale, path, or loader id. + * @returns {string} Sanitized segment safe for keys and tags. * @internal */ export function sanitizeSitecoreCacheSegment(value: string): string { @@ -115,6 +149,8 @@ export function sanitizeSitecoreCacheSegment(value: string): string { /** * Normalizes a Sitecore item GUID for cache keys/tags (lowercase, no braces). + * @param {string} itemId - Raw Sitecore item id or GUID. + * @returns {string} Normalized id segment. * @internal */ export function normalizeSitecoreItemIdForCacheKey(itemId: string): string { @@ -123,6 +159,8 @@ export function normalizeSitecoreItemIdForCacheKey(itemId: string): string { /** * Deduplicates strings while preserving first-seen order. + * @param {string[]} values - Tag or key candidates. + * @returns {string[]} Deduplicated list. * @internal */ export function dedupeCacheStrings(values: string[]): string[] { diff --git a/packages/angular/src/server/loader-data.provider.spec.ts b/packages/angular/src/server/loader-data.provider.spec.ts index 3cd2323002..072deaa169 100644 --- a/packages/angular/src/server/loader-data.provider.spec.ts +++ b/packages/angular/src/server/loader-data.provider.spec.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ServerLoaderDataProvider } from './loader-data.provider'; import type { LoaderCache, LoaderFn } from '../loaders/models'; import { createLoaderCache } from './cache/loader-cache'; +import { buildCacheKey } from './cache/cache-key'; describe('ServerLoaderDataProvider', () => { const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); @@ -208,4 +209,94 @@ describe('ServerLoaderDataProvider', () => { expect(result.kind).toBe('redirect'); expect(cache.set).not.toHaveBeenCalled(); }); + + it('should serve stale data immediately and refresh in the background', async () => { + let version = 1; + const loader = vi.fn(async () => ({ title: `v${version++}` })); + const cache = createLoaderCache({ revalidate: 300 }); + const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const request = { + loaderId: 'page', + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + + await provider.resolve(request); + const { key } = buildCacheKey('page', { + url: request.url, + params: request.params, + query: request.query, + }); + await cache.invalidate({ tags: [key] }); + + const staleResult = await provider.resolve(request); + expect(staleResult).toEqual({ kind: 'data', data: { title: 'v1' } }); + + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)); + + const freshResult = await provider.resolve(request); + expect(freshResult).toEqual({ kind: 'data', data: { title: 'v2' } }); + }); + + it('should coalesce concurrent stale-while-revalidate refreshes', async () => { + let version = 1; + const loader = vi.fn(async () => ({ title: `v${version++}` })); + const cache = createLoaderCache({ revalidate: 300 }); + const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const request = { + loaderId: 'page', + url: '/coalesce', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + + await provider.resolve(request); + const { key } = buildCacheKey('page', { + url: request.url, + params: request.params, + query: request.query, + }); + await cache.invalidate({ tags: [key] }); + + await Promise.all([provider.resolve(request), provider.resolve(request)]); + + await vi.waitFor(() => expect(loader.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(loader.mock.calls.length).toBe(2); + }); + + it('should warn when background cache write fails but still return stale data', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const loader = vi.fn().mockResolvedValue({ title: 'v2' }); + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ kind: 'stale', value: { title: 'v1' }, cacheKey: 'k' }), + set: vi.fn().mockRejectedValue(new Error('write failed')), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + resolveTtl: vi.fn().mockReturnValue(300), + enabled: vi.fn().mockReturnValue(true), + getConfig: vi.fn(), + }; + + const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/warn', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + + expect(result).toEqual({ kind: 'data', data: { title: 'v1' } }); + + await vi.waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '[sitecore-loader-cache] background refresh failed to write cache entry:', + 'write failed' + ) + ); + + warnSpy.mockRestore(); + }); }); diff --git a/packages/angular/src/server/loader-data.provider.ts b/packages/angular/src/server/loader-data.provider.ts index cb26702bd7..8f5b86ba05 100644 --- a/packages/angular/src/server/loader-data.provider.ts +++ b/packages/angular/src/server/loader-data.provider.ts @@ -11,18 +11,32 @@ import { buildLoaderCacheTags } from './cache/cache-tags'; /** * Server-side loader data provider with stale-while-revalidate cache reads (Phase 3). + * + * Resolution order when a {@link LoaderCache} is attached: + * 1. **hit** — return cached value immediately. + * 2. **stale** — return cached value immediately and schedule a background refresh + * (coalesced per cache key via `pendingCacheOps`). + * 3. **miss** — run the loader, persist the result with OSR tags, return data. + * + * Redirect responses are never cached. Per-route {@link LoaderCacheConfig} overrides + * from `loaderResolver(id, cacheOptions)` control TTL, tags, and opt-in caching when + * the global cache is disabled. * @public */ export class ServerLoaderDataProvider { /** Process-wide coalescing for stale-while-revalidate background refreshes. */ private static readonly pendingCacheOps = new Set<string>(); + /** + * @param registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware. + * @param cache - Optional cache instance from {@link createLoaderCache}. + */ constructor(private readonly registry: LoaderRegistry, private readonly cache?: LoaderCache) {} /** - * Resolve loader data: check cache, run loader on miss, store result. - * @param {LoaderApiRequest} request - Loader request payload - * @returns {Promise<LoaderDataResult>} Resolved loader result + * Resolve loader data with optional cache read-through and SWR refresh. + * @param request - Loader id, URL, params, optional request context and cache overrides. + * @returns Data, redirect, or error result for the middleware / SSR resolver. */ async resolve(request: LoaderApiRequest): Promise<LoaderDataResult> { const { loaderId, url, params, query, angularRequestContext, cacheOptions } = request; @@ -52,6 +66,7 @@ export class ServerLoaderDataProvider { return this.runLoader({ request, ctx, cacheable: !!cacheable }); } + /** Fire-and-forget SWR refresh; skipped when a refresh is already in flight for the key. */ private scheduleBackgroundRefresh( request: LoaderApiRequest, ctx: LoaderContext, diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts index 5a728a326b..ebfe163820 100644 --- a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts @@ -54,7 +54,14 @@ export type CollectSitecoreTagsFromEdgeBodyOptions = { /** * Maps an Experience Edge webhook JSON body to Sitecore cache tag strings. + * + * Accepts fully qualified `sc:…` tags in `body.tags`, raw content identifiers + * (with optional `-media`/`-layout` suffixes), and `updates[]` rows with + * `identifier` + `entity_culture`. * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. + * @param body - Parsed webhook JSON body. + * @param options - Locale fallback when an update omits `entity_culture`. + * @returns Deduplicated Sitecore cache tags ready for {@link LoaderCache.invalidate}. * @public */ export function collectSitecoreTagsFromEdgeRevalidateRequestBody( diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts index 441ca404f2..d3a839a383 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -36,14 +36,15 @@ export function resolveConfiguredRevalidateSecret( * @public */ export interface SitecoreRevalidateMiddlewareOptions { + /** Shared cache instance from {@link createLoaderCache}. */ cache: LoaderCache; /** Default: `process.env.SITECORE_REVALIDATE_SECRET` */ secret?: string; - /** Locale fallback when an update has no entity_culture; default `'en'`. */ + /** Locale fallback when an update has no `entity_culture`; default `'en'`. */ defaultLocale?: string; /** - * Optional sites list; when set, every call also marks stale one - * `sc:loader:dictionary:<site>:<locale>` entry per site. + * When set, every webhook also marks stale one + * `sc:loader:dictionary:<site>:<locale>` entry per site (dictionary fan-out). */ sites?: SiteInfo[]; /** Endpoint path; default `/api/revalidate`. */ @@ -52,7 +53,14 @@ export interface SitecoreRevalidateMiddlewareOptions { /** * Express middleware aligned with Next.js `createSitecoreRevalidateRouteHandler`. - * Marks matching loader cache entries stale via tag index (SWR semantics). + * + * Handles `POST /api/revalidate` (configurable via `endpoint`): + * - Authenticates with `SITECORE_REVALIDATE_SECRET` / `x-revalidate-secret` when configured. + * - Parses Experience Edge webhook bodies via {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. + * - Optionally appends dictionary loader tags for each configured site. + * - Calls {@link LoaderCache.invalidate} (marks entries stale; does not delete). + * + * Response shape: `{ revalidated, tagsCount, marked, invocation_id, continues, durationMs }`. * @public */ export function createSitecoreRevalidateMiddleware( diff --git a/packages/angular/src/testing/mock-sitecore-context.ts b/packages/angular/src/testing/mock-sitecore-context.ts index eb47f2af8a..2a9c965dfb 100644 --- a/packages/angular/src/testing/mock-sitecore-context.ts +++ b/packages/angular/src/testing/mock-sitecore-context.ts @@ -1,5 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable */ import { Component, EnvironmentProviders, diff --git a/yarn.lock b/yarn.lock index cbd4a701cf..e56a6d4818 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4911,6 +4911,7 @@ __metadata: rxjs: "npm:~7.8.0" tslib: "npm:^2.3.0" typescript: "npm:~5.9.2" + unstorage: "npm:^1.17.5" vitest: "npm:^4.0.8" zone.js: "npm:^0.15.0" peerDependencies: @@ -8228,6 +8229,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^1.2.3": + version: 1.2.3 + resolution: "cookie-es@npm:1.2.3" + checksum: 10/899f72d6354de72522ccf01c990c4f6caf8dd3180bd3cb426ea4be495af5acab6e74631e319b969285001ddecf9ea8a0657f71bf4dcd433238f2acc638f36d6f + languageName: node + linkType: hard + "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -8391,6 +8399,15 @@ __metadata: languageName: node linkType: hard +"crossws@npm:^0.3.5": + version: 0.3.5 + resolution: "crossws@npm:0.3.5" + dependencies: + uncrypto: "npm:^0.1.3" + checksum: 10/70a38525543293f88381b64650324c9de4a7e8a4dd86edf29e702b317d0d9fed2fb128a176242c90aa58d83acc64e62d35c919029f698a9868766b465430cd99 + languageName: node + linkType: hard + "css-select@npm:^6.0.0": version: 6.0.0 resolution: "css-select@npm:6.0.0" @@ -8720,6 +8737,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.6": + version: 6.1.7 + resolution: "defu@npm:6.1.7" + checksum: 10/09480a5fbe6318f622f30017f9386df6ae92ed895fb1ccc61e1ff0d5016b28a321c751749fdd52c996ddd4eafc2c95b77dc0c8cc109881a231c23c7fd630deb9 + languageName: node + linkType: hard + "del-cli@npm:^6.0.0": version: 6.0.0 resolution: "del-cli@npm:6.0.0" @@ -8797,6 +8821,13 @@ __metadata: languageName: node linkType: hard +"destr@npm:^2.0.5": + version: 2.0.5 + resolution: "destr@npm:2.0.5" + checksum: 10/0e4fba62a55a4188c7ab13eed5ebeeda037ead1ab21cf6be40ca39828b258475ad9eb1e7de50a5ea8041705d454a4d090caf9f92b89f03b04d2e229716f7da0a + languageName: node + linkType: hard + "detect-indent@npm:^6.0.0": version: 6.1.0 resolution: "detect-indent@npm:6.1.0" @@ -10936,6 +10967,23 @@ __metadata: languageName: node linkType: hard +"h3@npm:^1.15.10": + version: 1.15.11 + resolution: "h3@npm:1.15.11" + dependencies: + cookie-es: "npm:^1.2.3" + crossws: "npm:^0.3.5" + defu: "npm:^6.1.6" + destr: "npm:^2.0.5" + iron-webcrypto: "npm:^1.2.1" + node-mock-http: "npm:^1.0.4" + radix3: "npm:^1.1.2" + ufo: "npm:^1.6.3" + uncrypto: "npm:^0.1.3" + checksum: 10/8a13eef49f076eedf1aa6b32ab9190c647cbae517ed2945c951905ef018e2949dd0baa73d0954dc17eaf432d1657e3a09a10ebe5ac5532365083d3560d17d8b5 + languageName: node + linkType: hard + "handlebars@npm:^4.7.7, handlebars@npm:^4.7.9": version: 4.7.9 resolution: "handlebars@npm:4.7.9" @@ -11509,6 +11557,13 @@ __metadata: languageName: node linkType: hard +"iron-webcrypto@npm:^1.2.1": + version: 1.2.1 + resolution: "iron-webcrypto@npm:1.2.1" + checksum: 10/c1f52ccfe2780efa5438c134538ee4b26c96a87d22f351d896781219efbce25b4fe716d1cb7f248e02da96881760541135acbcc7c0622ffedf71cb0e227bebf9 + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -13683,6 +13738,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.2.7": + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10/02c4f73967d91fb101f4accf8ebac9e0541e08e16d987bdb9e9737f13e5f2c4bc33c593b98ec30e4486bf899bc97edb36fbd133684b36087336559e41edafdea + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -14539,6 +14601,13 @@ __metadata: languageName: node linkType: hard +"node-fetch-native@npm:^1.6.7": + version: 1.6.7 + resolution: "node-fetch-native@npm:1.6.7" + checksum: 10/b8a99e6adafbdbdd9373a6784c467ca5c7b95eeed4896ee2d604f0729962fda8d07cf7a85edd1e8bb3ee51e791dc55c30cbebeb46cbd1f086d74141b3769a680 + languageName: node + linkType: hard + "node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -14593,6 +14662,13 @@ __metadata: languageName: node linkType: hard +"node-mock-http@npm:^1.0.4": + version: 1.0.4 + resolution: "node-mock-http@npm:1.0.4" + checksum: 10/865bcc502a0b59f5504d014561ab0e3f4d8217c6b4022b621a1515503beaf1f526bb44cab43adb172e453992f75148ed13cc371bc6a3df1ad853430bf4bf8c62 + languageName: node + linkType: hard + "node-preload@npm:^0.2.1": version: 0.2.1 resolution: "node-preload@npm:0.2.1" @@ -15142,6 +15218,17 @@ __metadata: languageName: node linkType: hard +"ofetch@npm:^1.5.1": + version: 1.5.1 + resolution: "ofetch@npm:1.5.1" + dependencies: + destr: "npm:^2.0.5" + node-fetch-native: "npm:^1.6.7" + ufo: "npm:^1.6.1" + checksum: 10/2a1a9bf4f97eb5fe5ef52e87dc3f1fe01a335e6d57c8020a5eb557b4691f4d35b045a4c2d9c22d925945c5263de9fd5084efd5670bde7ade5f0fe6dfd797a346 + languageName: node + linkType: hard + "on-finished@npm:^2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -16233,6 +16320,13 @@ __metadata: languageName: node linkType: hard +"radix3@npm:^1.1.2": + version: 1.1.2 + resolution: "radix3@npm:1.1.2" + checksum: 10/5ed01a8e4b753e325c6ecb01d993de77f690e548ef9e149e7dc403ee7b109c2cb41e3d09bc3ce004d872c67c8dca1d556dbf7808b1ac7df9f86994e57d757557 + languageName: node + linkType: hard + "randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -18751,6 +18845,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.6.1, ufo@npm:^1.6.3": + version: 1.6.4 + resolution: "ufo@npm:1.6.4" + checksum: 10/dbf85425e00dd106abb852c0ea4cef6e58b395b9a43858049a8be0b0825e5cc4b53cf58a41da695c3c2a9ab4f8605923b64812be1358c39a56b3920504759d3a + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -18772,6 +18873,13 @@ __metadata: languageName: node linkType: hard +"uncrypto@npm:^0.1.3": + version: 0.1.3 + resolution: "uncrypto@npm:0.1.3" + checksum: 10/0020f74b0ce34723196d8982a73bb7f40cff455a41b8f88ae146b86885f4e66e41a1241fe80a887505c3bd2c7f07ed362b6ed041968370073c40a98496e6a737 + languageName: node + linkType: hard + "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0" @@ -18909,6 +19017,81 @@ __metadata: languageName: node linkType: hard +"unstorage@npm:^1.17.5": + version: 1.17.5 + resolution: "unstorage@npm:1.17.5" + dependencies: + anymatch: "npm:^3.1.3" + chokidar: "npm:^5.0.0" + destr: "npm:^2.0.5" + h3: "npm:^1.15.10" + lru-cache: "npm:^11.2.7" + node-fetch-native: "npm:^1.6.7" + ofetch: "npm:^1.5.1" + ufo: "npm:^1.6.3" + peerDependencies: + "@azure/app-configuration": ^1.8.0 + "@azure/cosmos": ^4.2.0 + "@azure/data-tables": ^13.3.0 + "@azure/identity": ^4.6.0 + "@azure/keyvault-secrets": ^4.9.0 + "@azure/storage-blob": ^12.26.0 + "@capacitor/preferences": ^6 || ^7 || ^8 + "@deno/kv": ">=0.9.0" + "@netlify/blobs": ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + "@planetscale/database": ^1.19.0 + "@upstash/redis": ^1.34.3 + "@vercel/blob": ">=0.27.1" + "@vercel/functions": ^2.2.12 || ^3.0.0 + "@vercel/kv": ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: ">=0.2.1" + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + "@azure/app-configuration": + optional: true + "@azure/cosmos": + optional: true + "@azure/data-tables": + optional: true + "@azure/identity": + optional: true + "@azure/keyvault-secrets": + optional: true + "@azure/storage-blob": + optional: true + "@capacitor/preferences": + optional: true + "@deno/kv": + optional: true + "@netlify/blobs": + optional: true + "@planetscale/database": + optional: true + "@upstash/redis": + optional: true + "@vercel/blob": + optional: true + "@vercel/functions": + optional: true + "@vercel/kv": + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + checksum: 10/e0059c08e87a86d2a43dc2a49853fd6bc324f655f3dba59fec2b1eb59bb784495772013a5925017a2970d63b9921d31d9211d42a361f810d3c20bded57c13d46 + languageName: node + linkType: hard + "upath@npm:2.0.1": version: 2.0.1 resolution: "upath@npm:2.0.1" From 48ffbf8316f9a8ca40d1538d6d65a1201c3055c3 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Wed, 27 May 2026 18:55:53 -0400 Subject: [PATCH 11/14] untrack temp files --- .gitignore | 1 + AGENTS.md | 70 -------- README.md | 3 +- llm-wiki/README.md | 14 -- llm-wiki/raw/2026-05-14-adapters.md | 14 -- .../raw/2026-05-14-architecture-overview.md | 25 --- ...026-05-14-content-sdk-services-and-apis.md | 14 -- ...05-14-editor-integration-using-metadata.md | 61 ------- ...5-14-example-environment-variable-files.md | 41 ----- ...14-internationalization-using-next-intl.md | 42 ----- ...14-jss-angular-live-design-architecture.md | 161 ------------------ ...-05-14-page-composition-sitecoreai-data.md | 16 -- llm-wiki/raw/2026-05-14-plugins.md | 42 ----- ...2026-05-14-route-handling-data-fetching.md | 38 ----- ...-14-sitecore-content-sdk-for-sitecoreai.md | 22 --- ...14-supporting-multilingual-applications.md | 16 -- ...6-05-14-the-sitecore-configuration-file.md | 131 -------------- llm-wiki/raw/README.md | 22 --- ...-Angular-Live-Design-Doc-140526-211917.pdf | Bin 319930 -> 0 bytes llm-wiki/wiki/common/doc-component-map.md | 81 --------- .../doc-config-environment-variables.md | 60 ------- .../common/doc-sitecore-client-and-graphql.md | 108 ------------ .../wiki/common/doc-sitecore-config-input.md | 64 ------- .../common/doc-terminology-platform-names.md | 13 -- llm-wiki/wiki/common/index.md | 23 --- ...tecture-goals-challenges-and-foundation.md | 21 --- .../doc-architecture-loaders-and-ssr.md | 32 ---- .../doc-components-and-placeholder-map.md | 23 --- .../doc-editing-and-page-context-angular.md | 52 ------ ...c-environment-and-define-config-angular.md | 23 --- .../doc-field-directives.md | 23 --- .../content-sdk-angular/doc-i18n-angular.md | 33 ---- ...er-resolver-transfer-state-and-endpoint.md | 77 --------- .../doc-loaders-outside-angular-di.md | 18 -- ...-loaders-route-registry-and-page-loader.md | 41 ----- .../doc-multisite-angular-roadmap.md | 17 -- .../doc-personalization-angular-roadmap.md | 7 - .../doc-preloader-data-service.md | 34 ---- .../doc-sitecore-config-typescript-angular.md | 28 --- .../doc-ssr-express-and-loader-middleware.md | 17 -- llm-wiki/wiki/content-sdk-angular/index.md | 55 ------ .../doc-architecture-edge-graphql.md | 31 ---- .../doc-editor-integration-metadata.md | 81 --------- .../doc-example-environment-variable-files.md | 91 ---------- .../doc-graphql-client-and-edge-urls.md | 15 -- .../doc-i18n-multilingual.md | 124 -------------- .../doc-page-composition-placeholders.md | 122 ------------- .../doc-plugins-and-adapters.md | 35 ---- .../doc-route-handling-data-fetching.md | 26 --- .../doc-sitecore-client-apis.md | 13 -- .../content-sdk-nextjs/doc-sitecore-config.md | 48 ------ .../doc-terminology-platform-names.md | 5 - llm-wiki/wiki/content-sdk-nextjs/index.md | 49 ------ .../overview-content-sdk.md | 52 ------ .../source-ingest-2026-05-14-official-docs.md | 22 --- llm-wiki/wiki/index.md | 20 --- llm-wiki/wiki/log.md | 75 -------- llm-wiki/wiki/plans/README.md | 13 -- 58 files changed, 2 insertions(+), 2403 deletions(-) delete mode 100644 llm-wiki/README.md delete mode 100644 llm-wiki/raw/2026-05-14-adapters.md delete mode 100644 llm-wiki/raw/2026-05-14-architecture-overview.md delete mode 100644 llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md delete mode 100644 llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md delete mode 100644 llm-wiki/raw/2026-05-14-example-environment-variable-files.md delete mode 100644 llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md delete mode 100644 llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md delete mode 100644 llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md delete mode 100644 llm-wiki/raw/2026-05-14-plugins.md delete mode 100644 llm-wiki/raw/2026-05-14-route-handling-data-fetching.md delete mode 100644 llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md delete mode 100644 llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md delete mode 100644 llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md delete mode 100644 llm-wiki/raw/README.md delete mode 100644 llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf delete mode 100644 llm-wiki/wiki/common/doc-component-map.md delete mode 100644 llm-wiki/wiki/common/doc-config-environment-variables.md delete mode 100644 llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md delete mode 100644 llm-wiki/wiki/common/doc-sitecore-config-input.md delete mode 100644 llm-wiki/wiki/common/doc-terminology-platform-names.md delete mode 100644 llm-wiki/wiki/common/index.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-field-directives.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md delete mode 100644 llm-wiki/wiki/content-sdk-angular/index.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/index.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md delete mode 100644 llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md delete mode 100644 llm-wiki/wiki/index.md delete mode 100644 llm-wiki/wiki/log.md delete mode 100644 llm-wiki/wiki/plans/README.md diff --git a/.gitignore b/.gitignore index 03ada4a200..9b9bbba2fa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !.yarn/releases !.yarn/sdks !.yarn/versions +llm-wiki/ *.pfx *.publishsettings diff --git a/AGENTS.md b/AGENTS.md index 2e072ad276..bfd21714b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,76 +147,6 @@ Branch: `dev`. Feature: `git switch -c feature/my-content-sdk-feature`. PRs agai --- -## LLM Wiki (persistent Content SDK knowledge base) - -This monorepo includes an **[LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** — a structured, interlinked markdown corpus maintained by an agent for **Content SDK developers** using LLM-assisted coding. - -### Purpose and scope - -| | | -|--|--| -| **Domain** | Sitecore **Content SDK** monorepo: packages, templates, CLI, samples | -| **Audience** | Developers and AI agents working **in this repo** (not head apps under `samples/` unless explicitly extending samples) | -| **“Done”** | Wiki explains **high- and medium-level** architecture, flows, and concepts well enough to speed up implementation work; pages cite **code** where behavior matters | -| **Location** | `llm-wiki/raw/` (immutable sources), `llm-wiki/wiki/` (agent-owned pages), `llm-wiki/README.md` (human orientation) | - -### Relationship to other agent guidance - -- **This file (`AGENTS.md`)** — monorepo tasks, commands, boundaries, package map; remains the primary **session** guide. -- **`.cursor/rules/*.mdc`**, **`CLAUDE.md`**, **`copilot-instructions.md`**, **`Skills.md` / `.agents/skills/`** — coding rules and capabilities; the wiki **compiles** and **cross-links** knowledge for longer-horizon memory, not replace those files. -- **Head application `AGENTS.md`** — still applies inside generated apps; do not merge this wiki’s repo scope into a head app’s file. - -### Source hierarchy (conflict resolution) - -1. **`packages/*/src/**` and tests** — **authoritative** for behavior and APIs. -2. **Official documentation** (including material from the **Sitecore Documentation MCP** or saved web articles in `llm-wiki/raw/`) — **secondary**; use for intent, terminology, and product framing. -3. **Wiki pages** — synthesized; must be **reconciled** with (1) on every ingest or when contradictions are found. - -If documentation and code disagree: **document the code’s behavior** in the wiki, link to paths/symbols, and add a short **“Documentation note”** or **contradiction** callout describing what the external doc claims. Optionally log in `llm-wiki/wiki/log.md`. - -### Directory contract - -| Path | Who edits | Contents | -|------|-----------|----------| -| `llm-wiki/raw/` | **Human** adds files; agent **does not** modify | Curated markdown/text copies of docs, MCP exports, articles | -| `llm-wiki/wiki/` | **Agent** creates/updates (per user direction) | Overviews, package notes, flows; **Next.js** pages under `wiki/content-sdk-nextjs/`; shared stubs under `wiki/common/`; Angular under `wiki/content-sdk-angular/`; **in-progress plans** under `wiki/plans/` | -| `llm-wiki/wiki/index.md` | **Agent** maintains | Root hub linking stack-specific indexes | -| `llm-wiki/wiki/content-sdk-nextjs/index.md` | **Agent** maintains | Next.js wiki catalog | -| `llm-wiki/wiki/log.md` | **Agent** appends | Chronological ingest / query / lint entries | - -### Workflows (agent) - -**Ingest** — When the user adds a source under `llm-wiki/raw/` or points to new doc/MCP material: - -1. Read the source; identify claims relevant to this repo. -2. **Verify** important claims against code (read `src`, follow imports, check tests). -3. Update or create wiki pages (package overviews, concept pages, flow diagrams in prose/mermaid as appropriate). -4. Update `index.md` and append `log.md` (consistent heading format, e.g. `## [YYYY-MM-DD] ingest | <title>`). - -Prefer **one source per ingest** when the user wants tight review; batch only when asked. - -**Query** — When answering questions about SDK behavior: - -1. Skim `llm-wiki/wiki/index.md`, then open the most relevant wiki pages. -2. If the wiki is incomplete or stale, read **code** and then **update the wiki** so the next session benefits. -3. Good answers (comparisons, non-trivial analyses) may be **saved** as new wiki pages and linked from `index.md`. - -**Lint** — Periodically or on request: - -- Orphan pages (no inbound links from index or other pages). -- Contradictions between wiki pages or between wiki and code. -- Stale summaries superseded by refactors; missing cross-links for major concepts. -- Gaps that need a doc fetch via MCP or a code dive — log suggested follow-ups in `log.md`. - -### Conventions - -- Prefer **relative links** between wiki pages; cite code as `` `packages/<pkg>/src/...` ``. -- **Platform naming:** In wiki and comments, **Sitecore AI / SitecoreAI / SAI / XM Cloud / Sitecore XM Cloud / XMC** refer to the **same** platform context unless code explicitly distinguishes behavior. See `llm-wiki/wiki/common/doc-terminology-platform-names.md`. -- Do **not** store secrets in raw or wiki; follow `.cursor/rules/safety.mdc`. -- Do **not** edit `dist/**`, `node_modules/`, or generated-only paths as part of wiki maintenance. - ---- - ## MCP Sitecore Documentation MCP: https://sitecore.mcp.kapa.ai diff --git a/README.md b/README.md index 8cc63eb492..5e7affcfb2 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,7 @@ For more information check out our [Getting Started Guide](https://doc.sitecore. ### AI Development Support -- [AGENTS.md](AGENTS.md) - AI agent guidance: structure, commands, DOs/DON'Ts, boundaries, quick reference, and **LLM Wiki** maintainer rules for `llm-wiki/` -- [LLM Wiki](llm-wiki/README.md) - persistent markdown knowledge base (raw sources + agent-maintained wiki); schema and workflows in the **LLM Wiki** section of [AGENTS.md](AGENTS.md) +- [AGENTS.md](AGENTS.md) - AI agent guidance: structure, commands, DOs/DON'Ts, boundaries, and quick reference - [Skills.md](Skills.md) - Capability groupings for the Content SDK (for AI tools and developers); [.agents/skills/](.agents/skills/) provides each capability as an Agent Skill (SKILL.md) for tools that support the [Agent Skills](https://agentskills.io) standard - [Claude Code Agent Guide](CLAUDE.md) - Comprehensive guide for Claude Code Agent to generate consistent and idiomatic Sitecore Content SDK code - [GitHub Copilot Instructions](copilot-instructions.md) - Instructions for GitHub Copilot to provide accurate Sitecore Content SDK suggestions diff --git a/llm-wiki/README.md b/llm-wiki/README.md deleted file mode 100644 index f4fb131d46..0000000000 --- a/llm-wiki/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Content SDK LLM Wiki - -Persistent markdown knowledge base for **Content SDK monorepo** development, maintained by an LLM per the [LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). - -| Layer | Path | Role | -|--------|------|------| -| **Schema** | [AGENTS.md](../AGENTS.md) (section *LLM Wiki*) | Conventions, workflows, truth hierarchy | -| **Wiki** | [`wiki/`](wiki/) | LLM-written synthesis, entities, flows (git-tracked) | -| **Plans (in progress)** | [`wiki/plans/`](wiki/plans/) | In-flight feature and wiki-change plans; not canonical until merged into `wiki/**` | -| **Raw sources** | [`raw/`](raw/) | Immutable inputs (clipped docs, exports you add) | - -Start with [`wiki/index.md`](wiki/index.md) and [`wiki/log.md`](wiki/log.md). Next.js head docs live under [`wiki/content-sdk-nextjs/`](wiki/content-sdk-nextjs/). In-progress plans live under [`wiki/plans/`](wiki/plans/). Do not edit files under `raw/` except by adding new source material. - -**Platform naming:** See [`wiki/common/doc-terminology-platform-names.md`](wiki/common/doc-terminology-platform-names.md). diff --git a/llm-wiki/raw/2026-05-14-adapters.md b/llm-wiki/raw/2026-05-14-adapters.md deleted file mode 100644 index 746b75cf48..0000000000 --- a/llm-wiki/raw/2026-05-14-adapters.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Adapters -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/adapters.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Adapters (snapshot) - -Adapters provide **environment-specific** implementations for **plugins** (browser vs server: cookies, request/response, fetching). In analytics, adapters implement **`AnalyticsAdapter`**, extending **`PluginAdapter`** from `@sitecore-content-sdk/core`. - -Example shape (per doc): `getClientId`, `setClientId`, `location.getSearchParams`. Passed into plugins at init so plugins stay environment-agnostic. Example: `initContentSdk` with `analyticsPlugin({ adapter: analyticsBrowserAdapter(), options: { enableCookie: true } })` and `eventsPlugin()`. - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-architecture-overview.md b/llm-wiki/raw/2026-05-14-architecture-overview.md deleted file mode 100644 index 6f68d73886..0000000000 --- a/llm-wiki/raw/2026-05-14-architecture-overview.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Architecture overview -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/architecture-overview.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Architecture overview (snapshot) - -Content SDK is part of the headless suite for SitecoreAI. - -## Experience Edge - -Experience Edge delivers layout and dictionary data via **GraphQL**. Official doc states the Edge delivery endpoint path is configured in the app’s **`package.json`** under `config.graphQLEndpointPath`, default `/sitecore/api/graph/edge`. - -**Wiki alignment:** Runtime GraphQL endpoint for `SitecoreClient` is resolved from **`sitecore.config` + env** (`api.edge` / `api.local`); see `llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md` and `content-sdk-nextjs/doc-sitecore-config.md`. - -## Content SDK components (per doc) - -- Core SDK: retrieve data from Sitecore services/APIs; work with Sitecore data and layout in JavaScript. -- Next.js SDKs: placeholders, field components, layout/field values editable by authors. -- Sample / starter app for Next.js. -- Developer tooling and utilities. - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md b/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md deleted file mode 100644 index 327670e573..0000000000 --- a/llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Content SDK Services and APIs -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/content-sdk-services-and-apis.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Content SDK Services and APIs (snapshot) - -Primary entry for apps: **`SitecoreClient`** — framework-agnostic client for headless SitecoreAI APIs: content, layout, dictionary, error pages, preview, sitemaps, robots.txt, other site-related data. - -Related doc topics linked from official page: Content SDK GraphQL API, Content SDK Media API. - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md b/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md deleted file mode 100644 index 615b92fd35..0000000000 --- a/llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Editor integration using metadata -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/editor-integration-using-metadata.html -doc_version: "2.x" -ingested: "2026-05-14" -fetch_note: "HTML retrieved via curl; body distilled to markdown (diagram omitted)." ---- - -# Editor integration using metadata (snapshot) - -**Scope (official):** Next.js **Pages Router** apps + SitecoreAI **Page builder**, using **layout service metadata** on placeholders, renderings, and fields so the editor can identify nodes for **in-browser visual editing**. - -**Stack (official):** [Sitecore Headless Services HTTP rendering engine](https://doc.sitecore.com/xp/en/developers/hd/22/sitecore-headless-development/http-rendering-engine.html), [Next.js API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes), [Next.js Preview Mode](https://nextjs.org/docs/pages/guides/preview-mode). - -**Note:** Official diagram: teal = Content SDK for Next.js APIs; other colors = sample app pieces. - -**Local Pages testing:** Connect [local host to Pages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/connect-your-local-host-to-pages.html) (doc link uses XMC path; same platform family as SAI — see wiki `wiki/common/doc-terminology-platform-names.md`). - -**Important — iframes:** `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors 'self'` can **block** the Pages iframe. Allow the Pages domain to frame the editing host (adjust headers / exceptions). - -## API routes (sample app) - -1. **`src/pages/api/editing/render.ts`** — `GET`; **`EditingRenderMiddleware`**. Use this URL as **`serverSideRenderingEngineEndpointUrl`** in Sitecore Content SDK app configuration. -2. **`src/pages/api/editing/config.ts`** — `GET`; **`EditingConfigMiddleware`**. -3. **Catch-all page** **`src/pages/[[...path]].tsx`** — main optional catch-all; renders Sitecore routes. - -## Editing secret - -Token securing editor endpoints exposed via the **Render** API route. **`EditingRenderMiddleware`** validates it; failure → **401**. - -## Next.js preview mode - -Draft / Page builder content at **request time**, bypassing static generation when appropriate. **`EditingRenderMiddleware`** enables preview mode (cookies on render response, passed to subsequent page request). In the catch-all, use **`SitecoreClient.getPreview`** or **`getDesignLibraryData`** when in preview (vs normal **`getPage`**). - -## Example render `GET` (metadata integration) - -``` -/api/editing/render?secret={EDITING_SECRET}&sc_site=nextjs-app&sc_itemid=54C8E9B5-0B2C-5363-8FA6-D32A3A302F51&sc_lang=en&route=/&mode=edit&sc_version=latest&sc_variant={VARIANT_ID}&sc_layoutKind=shared -``` - -- `sc_layoutKind`: enum, default **`final`**, optional **`shared`**. -- `sc_version`, `sc_variant`, `sc_layoutKind` — optional. - -## SDK APIs - -Import from **`@sitecore-content-sdk/nextjs/editing`**, e.g. `EditingRenderMiddleware`. - -### EditingRenderMiddleware (responsibilities, per doc) - -1. Validate editing secret. -2. Extract required query string parameters. -3. Enable Next.js preview mode; pass parameters as preview data. -4. Send internal request to editing host catch-all to fetch the page. -5. Return rendered page markup. - -### EditingConfigMiddleware (responsibilities, per doc) - -1. Validate editing secret. -2. Provide required configuration (**application metadata**) for feature compatibility. - -Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-example-environment-variable-files.md b/llm-wiki/raw/2026-05-14-example-environment-variable-files.md deleted file mode 100644 index 2ba5476d0b..0000000000 --- a/llm-wiki/raw/2026-05-14-example-environment-variable-files.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Example environment variable files -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/example-environment-variable-files.html -doc_version: "2.x" -ingested: "2026-05-14" -fetch_status: ok ---- - -# Example environment variable files (snapshot) - -Content SDK apps from **0.2.0+** ship **`.example`** env files to help configure local dev for **local container** vs **remote SitecoreAI**. - -## Purpose (per official doc) - -- Show which variables Content SDK expects. -- Show how to set them per environment. -- Avoid committing secrets (**.example** only — no real secrets). - -## Files (official) - -| File | Purpose | -|------|---------| -| **`.env.container.example`** | Local dev against a **local SitecoreAI container**. | -| **`.env.remote.example`** | Local dev against a **remote** SitecoreAI instance. | - -Templates in repo are editable; keep **`.example`** files updated when SDK adds vars. **Do not** put client secrets in `.example` files. - -## Implement - -Copy the relevant **`.example`** into **`.env.local`** and fill values. - -## Template code (Pages Router) - -Shipped under `packages/create-content-sdk-app/src/templates/nextjs/`: - -- `.env.container.example` — `SITECORE_EDITING_SECRET`, `NEXT_PUBLIC_DEFAULT_SITE_NAME`, `NEXT_PUBLIC_DEFAULT_LANGUAGE`, `NEXT_PUBLIC_SITECORE_API_KEY`, `NEXT_PUBLIC_SITECORE_API_HOST`, optional `DEBUG`. -- `.env.remote.example` — same defaults plus Edge (`SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional Edge hostname / Personalize / Design Library auth vars). - -Wiki: `llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md`. - -Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md b/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md deleted file mode 100644 index 7730afe675..0000000000 --- a/llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Internationalization using next-intl -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/internationalization-using-next-intl.html -doc_version: "2.x" -ingested: "2026-05-14" -fetch_status: ok ---- - -# Internationalization using next-intl (snapshot) - -**Scope (official):** Next.js **App Router** template — **`next-intl`** for locale routing, server/client translation patterns, and **dictionary phrases from Sitecore** namespaced by **`siteName`**. - -## Prerequisites - -Configure **languages in SitecoreAI** before app-level i18n ([working with languages](https://doc.sitecore.com/xmc/en/developers/xm-cloud/working-with-languages.html) — XMC path; same platform family as SAI). - -## Configuration files (App Router) - -| File | Role | -|------|------| -| **`src/i18n/routing.ts`** | `defineRouting`: supported **locales**, **defaultLocale** (often `sitecoreConfig.defaultLanguage`), **localePrefix** (e.g. `"as-needed"`). | -| **`src/i18n/request.ts`** | `defineRequestConfig`: per-request **locale** + **dictionary** from Sitecore for server components. | - -## Routing (App Router + multisite + SSG) - -- Catch-all: **`[site]/[locale]/[[...path]]`**. -- **`localeMiddleware`** first in **`src/middleware.ts`**, then other middlewares. -- **`generateStaticParams`** enumerates **site × locale** for SSG. - -## Components (official) - -- **Async server:** `getTranslations` / `getLocale` from **`next-intl/server`**; namespace `page.siteName`. -- **Sync server:** `useTranslations(page.siteName)`. -- **Client:** `NextIntlClientProvider` on catch-all page; `useTranslations()` / `useLocale()`. - -See **next-intl** docs for server vs client environments. - -## Pages Router (this repo) - -The **Pages Router** template does **not** use **`next-intl`**. It uses **Next.js built-in `i18n`**, **`next-localization`** (`I18nProvider` + rosetta), and **`context.locale`** in data fetching — see wiki **`doc-i18n-multilingual.md`** (code-first section). - -Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md b/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md deleted file mode 100644 index 9275ce5ed4..0000000000 --- a/llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: "JSS-Angular Live Design — Architecture" -source_type: pdf -source_file: "JSS-Angular Live Design Doc-140526-211917.pdf" -pdf_in_repo: "llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf" -ingested: "2026-05-14" -note: "Text extracted from user-supplied PDF; structure preserved for LLM Wiki. Internal POC doc references in original PDF appear as placeholders where not included." ---- - -# JSS-Angular Live Design Doc — Architecture (extract) - -## Goal - -Provide **Angular** support while reusing common Content SDK concepts: **`scClient`**, **component-map**, **import-map** (later), **`scConfig`** (CLI config too). - -## Challenges - -- All imported logic must not bloat the Angular bundle incorrectly (**server and client** split). -- **`process.env`** is **not** available on the **client** in the same way as Node-based heads. - -## Foundation - -Angular implementation rests on the **loader system** described in an internal POC doc (referenced in the original PDF; not attached to this snapshot). - ---- - -## Loaders - -Loaders are implemented as **Angular route data resolvers** (see Angular docs: *Route data resolvers*). They populate **`page`**, **`dictionary`**, and potentially other route props when a request is processed by **Angular SSR**. - -### Route configuration (example) - -```typescript -{ - path: '**', - component: PageComponent, - resolve: { - page: loaderResolver('page'), - dictionary: loaderResolver('dictionary'), - }, -} -``` - -### Registry (`app.config.ts`) - -Loaders are retrieved from a **loader registry** and provided in app config: - -```typescript -import { LOADERS } from '../content-sdk/loaders'; -// ... -providers: [ - // ... - provideLoaderRegistry(LOADERS), - // ... -]; -``` - -### Default page loader (example) - -Loader implementation lives in the app. Example pattern: - -```typescript -import type { LoaderFn, Page } from '@sitecore-content-sdk/angular'; -import { NotFoundNavigationError, resolveSitecorePage } from '@sitecore-content-sdk/angular'; -import scConfig from '../../../sitecore.config'; -import { getClient } from '../client/sitecore-client'; - -/** - * Page loader: fetches layout data from Sitecore for the current URL. - * Uses imported config and getClient so this runs outside Angular injection context. - */ -export const pageLoader: LoaderFn<Page> = async (context) => { - const page = await resolveSitecorePage(context.url, scConfig, getClient()); - if (!page) { - throw new NotFoundNavigationError(); - } - return page; -}; -``` - -### `loaderResolver` behavior - -**`loaderResolver`** is the main entry point for loader execution. - -**On server** - -- Retrieves loaders from **`LOADER_REGISTRY`** by name. -- Executes loaders. -- Writes loader results to **`TransferState`** so values are available quickly on subsequent client navigation. - -**On browser** - -- Tries to read loader result from **`TransferState`** first. -- If absent, calls **`loader-data-service`** Express middleware via **`loader-data.service`**. -- Request promise is tracked in a **pending** collection; duplicate concurrent requests for the same loader/route reuse the pending promise (performance). -- Express middleware loads the loader from the registry, runs it, returns the result. - -Depending on the result (**data**, **error**, **not found**), the resolver either sets the route prop or triggers navigation to **error** / **not found** routes. - -### `PreLoaderDataService` - -On **browser** routing, the service subscribes to Angular’s **`ActivationStart`** and runs **all** loaders for the target route **in parallel** as a **pre-warm**. Angular data resolvers run **sequentially** by default; this narrows the gap. - -### Loader constraints - -Loaders may run from **`loaderResolver`** **or** from the **Express** middleware — **not** inside a normal Angular **`inject()`** context. **Known limitation:** use **imports** for **`scConfig`**, **`getClient`**, etc., instead of constructor injection in loader bodies. - ---- - -## Config and environment - -Angular reuses common **`defineConfig`** logic. **`process.env`** is unavailable on the client; importing a config built only from **`process.env`** can throw. - -**Mitigation (build-time script):** - -1. Read **`CSDK_PUBLIC_*`** (and related) variables from **server** `process.env`. -2. Write **`environment.dev.ts` / `environment.prod.ts`** depending on the command (`npm run dev` / `npm run start`). -3. The Angular bundler folds these into a runtime **`environment.ts`** whose values are passed into **`defineConfig`** — instead of reading **`process.env`** in the browser. - ---- - -## Components - -- **Standalone** components only (Angular + Content SDK convention). -- **Component map** structure mirrors **Next.js**: PascalCase names for SAI compatibility, default + variant files (`file.default.ts`, `file.var.ts`), shared generation utilities between Next and Angular. -- **Placeholder** uses the same component map format and high-level resolution as Next.js. -- **Component map generation** is configured via **`sitecore.cli.config.ts`**. - ---- - -## SSR - -The Angular app registers **`loader-data-service`** middleware in **`server.ts`** **before** registering the browser bundle and the main Angular SSR bundle. - ---- - -## Config (sitecore) - -Angular reuses the common **`sitecore.config.ts`** approach. - ---- - -## Fields and directives - -Original PDF: **TBA**. - -## Editing - -Original PDF: **TBA**. - -## Multisite - -Original PDF: **TBA**. - -## Personalization - -Original PDF: **TBA**. - ---- - -_End of extracted pages (PDF indicated 5 pages)._ diff --git a/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md b/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md deleted file mode 100644 index 99fb108003..0000000000 --- a/llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Page composition in Content SDK apps using SitecoreAI data -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/page-composition-in-content-sdk-apps-using-sitecoreai-data.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Page composition (snapshot) - -Pages use SitecoreAI **layouts**: named placeholders hosting components. Authors use WYSIWYG in SitecoreAI. Content SDK uses a top-level **Layout** component with at least one **root** placeholder mirroring SitecoreAI. Component hierarchy is **dynamic** from layout **JSON** returned by **GraphQL** (Experience Edge or local) over HTTP. Placeholder names and component types in the app must match what authors configure. Layout data is JSON from SitecoreAI GraphQL via route handling / data fetching; front ends consume that JSON shape. - -**Wiki alignment:** Do not describe head layout as “CMS XML vs app JSON”; GraphQL responses are JSON. See `llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md`. - -Related official topics: dynamic placeholders, placeholders in Content SDK apps, components, route handling. - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-plugins.md b/llm-wiki/raw/2026-05-14-plugins.md deleted file mode 100644 index 05eaf02345..0000000000 --- a/llm-wiki/raw/2026-05-14-plugins.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Plugins -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/plugins.html -doc_version: "2.x" -ingested: "2026-05-14" -reingested: "2026-05-14" -fetch_status: ok ---- - -# Plugins (snapshot) - -Plugins are **modular** extensions to the Content SDK: type-safe, declarative, with explicit **dependencies** between plugins. See also: [Initializing tracking, events, and personalization in the Content SDK](https://doc.sitecore.com/sai/en/developers/content-sdk/20/initializing-tracking,-events,-and-personalization-in-the-content-sdk.html). - -## `Plugin` interface (per official doc) - -```ts -export interface Plugin<Options = unknown, Adapter = unknown> { - name: string; - options?: Options; - dependencies?: PluginDependency[]; - init?: () => void | Promise<void>; - adapter?: Adapter; -} -``` - -| Field | Description | -|--------|--------------| -| `name` | Plugin name | -| `options` | Plugin-specific options | -| `dependencies` | Declares plugins that must be present and initialized first | -| `init` | Runs once on first `init` (may be async) | -| `adapter` | Optional environment-specific adapter (type-safe) | - -## Built-in plugins (per official doc) - -| Plugin | Purpose | Package | -|--------|---------|---------| -| `analyticsPlugin()` | Core client ID + shared analytics init; **required** for events and personalization. Alone: visitor ID, no events/personalization. | `@sitecore-content-sdk/analytics-core` | -| `eventsPlugin()` | Page view and/or custom events on top of client ID logic. | `@sitecore-content-sdk/events` | -| `personalizeBrowserPlugin()` / `personalizeServerPlugin()` | Personalization (cookie, optional web personalization); depends on analytics. Browser = client contexts; server = Server Components or Next middleware/proxy. | `@sitecore-content-sdk/personalize` | - -Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md b/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md deleted file mode 100644 index ce2c538c66..0000000000 --- a/llm-wiki/raw/2026-05-14-route-handling-data-fetching.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Route handling and data fetching in Content SDK apps -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/route-handling-and-data-fetching-in-content-sdk-apps.html -doc_version: "2.x" -ingested: "2026-05-14" -reingested: "2026-05-14" -fetch_status: ok ---- - -# Route handling and data fetching (snapshot) - -## Content tree → URLs (SitecoreAI) - -Each page is a **content item**; tree structure defines **URL structure** (authors control URLs by hierarchy). Example: `site-1/Home/About`, `site-1/Home/Products/Item-1` → `/about`, `/products/item-1` on `site-1` hostname. - -Content SDK apps are expected to **fully support** SitecoreAI URL mapping. - -**Note:** Hostname and URL mapping rules are configured in Sitecore (site definitions, URL rewrite); custom routing needs front-end + Sitecore coordination. Prefer hierarchical routes (e.g. `/products/shoes/running`). - -## Route resolution (Next.js) - -Next.js uses **file-system routing** and **catch-all** routes. A **custom route resolver** maps incoming paths to Sitecore content items. - -## Data fetching flow (example `/products/item-1`) - -1. User navigates to `/products/item-1`. -2. Route resolver maps path → Sitecore route. -3. **GraphQL** to Edge (or Preview) API — official doc example host pattern: `https://edge-platform.sitecorecloud.io/v1/content/api/graphql/v1?sitecoreContextId=...` (actual URL comes from app **sitecore.config** / Edge settings). -4. Query returns: **route layout**, **component fields**, **context** (language, site, …). -5. Data passed to rendering (**Placeholders**, **Components**). - -**Documentation note:** The official page links `LayoutService` to `packages/core/src/layout/layout-service.ts` on GitHub; in **this** repo the implementation lives under **`packages/content/src/layout/layout-service.ts`** (see wiki `doc-route-handling-data-fetching.md`). - -## Display name–based routing (Content SDK 1.1+) - -URLs can derive from item **display name** instead of item **name**. Sitemaps for display-name routes may require a **Sitecore patch** (`linkManager` / `useDisplayName="true"`), redeploy, then Sitemap settings → link provider name. After patch, sitemaps may **only** include display-name routes (per official warning). - -Full page: `source_url`. diff --git a/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md b/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md deleted file mode 100644 index 9a39c49cd4..0000000000 --- a/llm-wiki/raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Sitecore Content SDK for SitecoreAI -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/sitecore-content-sdk-for-sitecoreai.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Sitecore Content SDK for SitecoreAI (snapshot) - -The Content SDK enables developers to integrate SitecoreAI content with front-end JavaScript applications. It includes APIs and services that fetch SitecoreAI data to build and deliver pages. Developers can work locally, connect to SitecoreAI Pages visual editor, and use a starter kit template. Open source Apache 2.0; GitHub `Sitecore/content-sdk`. - -## Key features (per official doc) - -- SitecoreAI Pages integration for visual editing and testing. -- Next.js starter template. -- Personalization and component A/B/n testing without custom coding. -- Multi-site support (note: different sites do not support different default languages by default; see Next.js i18n docs). -- GraphQL utilities for layout, content, site info, dictionaries. -- Analytics, events, personalization. -- Framework-specific capabilities (Next.js locales, SSR, SSG). - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md b/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md deleted file mode 100644 index 640c24572d..0000000000 --- a/llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Supporting multilingual applications in Content SDK -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/supporting-multilingual-applications-in-content-sdk.html -doc_version: "2.x" -ingested: "2026-05-14" ---- - -# Multilingual (snapshot) - -Uses SitecoreAI content language versioning. - -- **Page content:** layout service respects language context; GraphQL uses explicit `language` parameter. -- **Dictionary:** GraphQL-powered API; sample pattern `client.getDictionary({ site: page.siteName, locale: page.locale })`. -- **Routing:** Content SDK does not dictate URL structure; sample apps follow route item hierarchy and may use language prefixes (`/about`, `/en/about`, `/es-US/about`). - -Full page: see `source_url`. diff --git a/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md b/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md deleted file mode 100644 index 8538a06569..0000000000 --- a/llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: The Sitecore configuration file -source_url: https://doc.sitecore.com/sai/en/developers/content-sdk/20/the-sitecore-configuration-file.html -doc_version: "2.x" -ingested: "2026-05-14" -reingested: "2026-05-14" -fetch_status: ok ---- - -# The Sitecore configuration file (full snapshot) - -**Version:** 2.x (per official topic) - -Content SDK includes **`sitecore.config.ts`** at the **app root** — central configuration. Starter templates ship a **minimal** file; expand as needed. - -**Import:** - -```ts -import scConfig from 'sitecore.config'; -``` - -**Env resolution:** Many properties have a **corresponding environment variable**. If a property has no explicit value, it falls back to the env var when present; otherwise **defaults** in the config layer apply. - ---- - -## The base configuration - -| Property | Type | Description | Env var | -|----------|------|-------------|---------| -| `api` | object | Connection credentials for SitecoreAI (provide **`edge`** or **`local`**, not both) | n/a | -| `defaultSite` | string | If **multisite** enabled: fallback site. If multisite **off**: site for visitors. Default `''`. | `NEXT_PUBLIC_DEFAULT_SITE_NAME` | -| `defaultLanguage` | string (optional) | Default locale fallback (API, site resolution, middleware, etc.). Must align with framework i18n (e.g. Next.js). Default `'en'`. | `NEXT_PUBLIC_DEFAULT_LANGUAGE` | -| `editingSecret` | string (optional) | Secret for SitecoreAI **editing and preview** when the app is an editing host. Default `'editing-secret-missing'`. | `SITECORE_EDITING_SECRET` | - -### `api` — choose one - -| Branch | Use | -|--------|-----| -| `edge` | SaaS SitecoreAI | -| `local` | Local SitecoreAI in Docker | - -#### `api.edge` - -| Property | Type | Description | Env var | -|----------|------|-------------|---------| -| `contextId` | string | Connect / retrieve data. Default `''`. | Server: `SITECORE_EDGE_CONTEXT_ID`. Client / server fallback: `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | -| `clientContextId` | string (optional) | Client-side operations. Default `''`. | `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | -| `edgeUrl` | string (optional) | SitecoreAI endpoint URL. Default `https://edge-platform.sitecorecloud.io` | `NEXT_PUBLIC_SITECORE_EDGE_URL` | - -#### `api.local` - -| Property | Type | Description | Env var | -|----------|------|-------------|---------| -| `apiKey` | string | API key for GraphQL. Default `''`. | `NEXT_PUBLIC_SITECORE_API_KEY` | -| `apiHost` | string | API hostname. Default `''`. | `NEXT_PUBLIC_SITECORE_API_HOST` | -| `path` | string (optional) | Path appended to `apiHost` for full GraphQL URL. Default `/sitecore/api/graph/edge` | n/a | - ---- - -## Services configuration - -| Property | Purpose | -|----------|---------| -| `layout` | Extra **layout service** settings | -| `dictionary` | Extra **dictionary service** settings | -| `retries` | Retry behavior for **layout**, **dictionary**, and **ErrorPages** by default (on by default for Edge stability) | - -### `layout` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `formatLayoutQuery` | function (optional) | Args: `siteName`, `itemPath`, `locale` — returns first segment of layout GraphQL query. Default format: `layout(site:"${siteName}", routePath:"${itemPath}", language:"${language}")` | n/a | - -### `dictionary` → `caching` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `enabled` | boolean (optional) | Memory cache for dictionary. Default `true` | n/a | -| `timeout` | number (optional) | Cache TTL seconds. Default `60` | n/a | - -### `retries` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `count` | number (optional) | Max GraphQL retries; `0` disables. Default `3` | n/a | -| `retryStrategy` | `RetryStrategy` (optional) | From `@sitecore-content-sdk/nextjs/client`. Default **`DefaultRetryStrategy`**: exponential backoff factor **2** for **429, 502, 503, 504, 520–524** | n/a | - ---- - -## Extra middleware and other configurations - -*(Doc: oriented to Next.js middleware; other frameworks may differ.)* - -### `redirects` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `enabled` | boolean (optional) | Global redirects. Default **`true`** production, **`false`** development | n/a | -| `locales` | string[] (optional) | Locales for redirect strategy; must match app i18n (e.g. `next.config`). Default `['en']` | n/a | - -**Important (official):** SitecoreAI does **not** support redirect **items** — only **redirect maps**. - -### `multisite` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `enabled` | boolean (optional) | Multisite for normal rendering. **Preview mode: multisite always on.** Default `true` | n/a | -| `useCookieResolution` | function (optional) | `req: RequestInit` → optionally resolve site from **`sc_site`** cookie. Default **`true`** on Vercel preview, else **`false`** | n/a | - -### `personalize` - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `enabled` | boolean (optional) | Personalize feature. Default **`true`** prod, **`false`** dev | n/a | -| `edgeTimeout` | number (optional) | Edge personalization timeout (seconds). Default `400` | `PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT` | -| `cdpTimeout` | number (optional) | CDP timeout (seconds). Default `400` | `PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT` | -| `scope` | string (optional) | Personalize scope between environments. Default `''` | `NEXT_PUBLIC_PERSONALIZE_SCOPE` | -| `channel` | string (optional) | CDP channel. Default `WEB` | n/a | -| `currency` | string (optional) | CDP currency. Default `USD` | n/a | - -### Other - -| Property | Type | Description | Env | -|----------|------|-------------|-----| -| `generateStaticPaths` | boolean (optional) | Next.js: whether **`getStaticPaths`** pre-renders paths. **`false`** → ISR for all pages; use **`false`** when app is SitecoreAI **editing host**. Default `true` | `GENERATE_STATIC_PATHS` | -| `disableCodeGeneration` | boolean (optional) | Skip AI component generation / code extraction when `true` | n/a | -| `sitecoreInternalEditingHostUrl` | string (optional) | Internal URL for editing render scenarios (non-standard local setups). SDK **1.1+**. Default: SitecoreAI deploys → `http://localhost:3000`; else **request Host** | `SITECORE_INTERNAL_EDITING_HOST_URL` | - ---- - -Canonical page: `source_url`. diff --git a/llm-wiki/raw/README.md b/llm-wiki/raw/README.md deleted file mode 100644 index 366f503c5e..0000000000 --- a/llm-wiki/raw/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Raw sources (immutable) - -Place **documentation** and other reference material here. The LLM **reads** these files but must **not** modify them. - -## Intended sources - -- Clipped or exported **official documentation** web pages (markdown or text). -- Supplementary articles you curate. -- Material retrieved via **MCP** (e.g. Sitecore Documentation MCP) should be saved here as markdown when used as the basis for a wiki update, so provenance stays clear. - -## Naming - -Use descriptive filenames, for example: - -- `sitecore-content-sdk-overview.md` -- `creating-a-content-sdk-app.md` - -Optional: prefix with `YYYY-MM-DD-` if you care about ingest order. - -## Truth hierarchy - -When integrating into the wiki: **this repo’s source code and tests are authoritative** if they disagree with documentation. The wiki should state what the code does and cite file paths; documentation conflicts should be noted explicitly. diff --git a/llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf b/llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf deleted file mode 100644 index 6811353701ba3f6c50a8414b1e21af70dddcbb42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319930 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�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 P*Z_lT;X+j%HLm{y##2m< diff --git a/llm-wiki/wiki/common/doc-component-map.md b/llm-wiki/wiki/common/doc-component-map.md deleted file mode 100644 index a9d13f423b..0000000000 --- a/llm-wiki/wiki/common/doc-component-map.md +++ /dev/null @@ -1,81 +0,0 @@ -# Component map (shared contract) - -The component map is a TypeScript `Map` from **rendering name** (PascalCase string) to a **component value**, used by all Content SDK heads. Generated by the CLI from `sitecore.cli.config.ts`; output file is `.sitecore/component-map.ts`. - -**Core generator:** `packages/content/src/tools/templating/utils.ts` — `buildComponentMapContent()` (pure string concatenation; no template engine). The framework component type name is derived as `${Framework}ContentSdkComponent` where `Framework` is the capitalized `framework` option (e.g. `angular` → `AngularContentSdkComponent`, `nextjs` → `NextjsContentSdkComponent`). - -## Map format - -```typescript -// Angular — type from @sitecore-content-sdk/angular -import { AngularContentSdkComponent } from '@sitecore-content-sdk/angular'; -import { ScFormComponent } from '@sitecore-content-sdk/angular'; // built-in -import * as MyComponent from 'src/app/components/my.component'; - -export const componentMap = new Map<string, AngularContentSdkComponent>([ - ['Form', ScFormComponent], // built-in; always present in Angular maps - ['MyComponent', { ...MyComponent }], -]); -export default componentMap; - -// Next.js — type from @sitecore-content-sdk/nextjs (extends ReactContentSdkComponent) -import { NextjsContentSdkComponent } from '@sitecore-content-sdk/nextjs'; -import * as MyComponent from './components/MyComponent'; - -export const componentMap = new Map<string, NextjsContentSdkComponent>([ - ['MyComponent', { ...MyComponent, componentType: 'client' }], -]); -export default componentMap; -``` - -- **Key:** PascalCase rendering name — must match the Sitecore rendering name exactly. -- **Value:** spread of the component module (`{ ...Module }`) or a direct class/type reference. -- **`componentType: 'client'`**: marks a React Client Component (App Router); generated when `clientComponentMap` split is enabled. - -## Component map types - -| Framework | Value type | Package | -|-----------|-----------|---------| -| Angular | `AngularContentSdkComponent` = `Type<unknown> \| AngularModule` | `@sitecore-content-sdk/angular` | -| Next.js | `NextjsContentSdkComponent` extends `ReactContentSdkComponent` with `getComponentServerProps`, `dynamicModule`, `componentType` | `@sitecore-content-sdk/nextjs` | - -## CLI config — `GenerateMapArgs` - -`SitecoreCliConfigInput.componentMap` accepts `GenerateMapArgs` (`packages/content/src/tools/generate-map.ts`): - -| Field | Type | Notes | -|-------|------|-------| -| `paths` | `string[]` | Component source directories to scan | -| `destination` | `string?` | Output folder (default: `src/.sitecore`) | -| `componentImports` | `ComponentImport[]?` | Additional package components to inject into the map | -| `exclude` | `string[]?` | Glob patterns to exclude from scanning | -| `mapTemplate` | `ComponentMapTemplate \| EnhancedComponentMapTemplate \| undefined` | Custom generator for the main map file — replaces the default template entirely | -| `clientMapTemplate` | same | Custom generator for the client-only map (used when `clientComponentMap: true`) | -| `clientComponentMap` | `boolean?` | When `true`, generates a second `.sitecore/component-map.client.ts` containing only `client` + `universal` components (App Router split) | -| `includeVariants` | `boolean?` | Include SXA variant component paths in the map | - -Full CLI config shape: [doc-sitecore-config-input.md](doc-sitecore-config-input.md) — `SitecoreCliConfigInput`. - -## Built-in entries (Angular only) - -The Angular generator (`packages/angular/src/tools/generate-map.ts`) hardcodes two built-in values before calling `buildComponentMapContent`: - -```typescript -const DEFAULT_BUILTIN_IMPORTS = `import { ScFormComponent } from '@sitecore-content-sdk/angular';`; -const DEFAULT_BUILTIN_MAP_ENTRIES = [`['Form', ScFormComponent]`]; -``` - -These are passed as `builtInImports` / `builtInMapEntries` to `buildComponentMapContent` so `ScFormComponent` always appears as `'Form'` regardless of what components the app defines. Apps can provide a custom `mapTemplate` to override this behavior. - -## Head-specific wiring - -### Angular - -- Injected into DI via **`SITECORE_COMPONENT_MAP`** token in `app.config.ts` (`useValue: componentMap`). -- `sc-placeholder` reads the map through this token. -- See [doc-components-and-placeholder-map.md](../content-sdk-angular/doc-components-and-placeholder-map.md). - -### Next.js - -- Passed as a prop to `AppPlaceholder` (App Router) or provided via `SitecoreProvider` (Pages Router). -- See [doc-page-composition-placeholders.md](../content-sdk-nextjs/doc-page-composition-placeholders.md). \ No newline at end of file diff --git a/llm-wiki/wiki/common/doc-config-environment-variables.md b/llm-wiki/wiki/common/doc-config-environment-variables.md deleted file mode 100644 index 360863bca5..0000000000 --- a/llm-wiki/wiki/common/doc-config-environment-variables.md +++ /dev/null @@ -1,60 +0,0 @@ -# Environment variables and `buildFallbackConfig` - -How **`@sitecore-content-sdk/content`** fills **`SitecoreConfig`** from an env-like record (e.g. **`process.env`**, or **`clientEnv`** merged in by a head’s **`defineConfig`**). **`buildFallbackConfig`** in **`packages/content/src/config/define-config.ts`** uses **string literal** keys only; TypeScript **constant** names such as **`SITECORE_EDGE_PLATFORM_HOSTNAME_ENV`** are **not** environment variable names—they exist so the code can index **`env['SITECORE_EDGE_PLATFORM_HOSTNAME']`** without repeating the string. - -**Code:** `packages/content/src/config/define-config.ts` — **`buildFallbackConfig`**. - -## Key prefixes (by head) - -| Prefix | Typical use | -|--------|----------------| -| **`SITECORE_*`** | Server-side / shared secrets and IDs (Next **`.env`**, Angular server **`process.env`**, CI). | -| **`NEXT_PUBLIC_*`** | **Next.js** convention for values that must exist in the browser bundle; **`buildFallbackConfig`** reads several of these directly (alongside **`SITECORE_*`** / **`CSDK_PUBLIC_*`**). | -| **`CSDK_PUBLIC_*`** | **Angular** convention only: the scaffold’s **`generate-environment.ts`** copies only these keys into **`environment.*.ts`**, which become **`clientEnv`** for **`@sitecore-content-sdk/angular`** **`defineConfig`**. Next templates do **not** rely on this prefix for public config. | - -Any head may pass a merged map into **`defineConfig`**, so multiple prefixes can appear in the same object at runtime (e.g. Angular SSR merges **`clientEnv`** with **`process.env`**). - -## `buildFallbackConfig` env keys (exact names) - -Values below follow **`env.A \|\| env.B \|\| …`** in **`buildFallbackConfig`** unless noted. - -| Area | Environment variable keys (in evaluation order) | -|------|---------------------------------------------------| -| Edge hostname (input to **`resolveEdgeUrl`**) | `CSDK_PUBLIC_SITECORE_EDGE_HOSTNAME`, then `SITECORE_EDGE_PLATFORM_HOSTNAME` | -| Edge context ID | `SITECORE_EDGE_CONTEXT_ID` | -| Edge **client** context ID | `SITECORE_EDGE_CLIENT_CONTEXT_ID`, then `CSDK_PUBLIC_SITECORE_EDGE_CONTEXT_ID` | -| Local GraphQL **API key** | `SITECORE_API_KEY`, then `CSDK_PUBLIC_SITECORE_API_KEY`, then `NEXT_PUBLIC_SITECORE_API_KEY` | -| Local GraphQL **host** | `SITECORE_API_HOST`, then `CSDK_PUBLIC_SITECORE_API_HOST`, then `NEXT_PUBLIC_SITECORE_API_HOST` | -| Editing secret | `SITECORE_EDITING_SECRET` (if unset, code uses placeholder string **`editing-secret-missing`**) | -| Default site | `SITECORE_DEFAULT_SITE`, then `CSDK_PUBLIC_SITECORE_DEFAULT_SITE`, then `CSDK_PUBLIC_DEFAULT_SITE` | -| Default language | `SITECORE_DEFAULT_LANGUAGE`, then `CSDK_PUBLIC_DEFAULT_LANGUAGE`; if still empty, defaults to **`en`** | -| Personalize — Edge timeout (ms) | `PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT` (parsed integer; default **400** if missing/invalid) | -| Personalize — CDP timeout (ms) | `PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT` (parsed integer; default **400** if missing/invalid) | -| Personalize — scope | `SITECORE_PERSONALIZE_SCOPE`, then `CSDK_PUBLIC_PERSONALIZE_SCOPE`, then `NEXT_PUBLIC_PERSONALIZE_SCOPE` | -| Redirects / personalize **enabled** flags | Derived from **`NODE_ENV`** (`!== 'development'` → enabled), not separate `SITECORE_*` keys | -| Local GraphQL **path** | Not from env: hardcoded **`/sitecore/api/graph/edge`** in this fallback object (overridable via **`sitecore.config.ts`**) | - -**Source for `SITECORE_EDGE_PLATFORM_HOSTNAME`:** the content package imports **`SITECORE_EDGE_PLATFORM_HOSTNAME_ENV`** from **`@sitecore-content-sdk/core/tools`**; that export’s value is the string **`'SITECORE_EDGE_PLATFORM_HOSTNAME'`** (`packages/core/src/tools/resolve-edge-url.ts`). - -## By head (how env reaches `defineConfig`) - -### Next.js - -- **`getNextFallbackConfig`** (in **`@sitecore-content-sdk/nextjs/config`**) layers **`NEXT_PUBLIC_*`** and other Next-specific keys before calling content **`defineConfig`**. -- **`sitecore.config.ts`** may still set values explicitly; merge rules in [doc-sitecore-config-input.md](doc-sitecore-config-input.md) apply. - -### Angular - -- **Browser:** `process.env` is not reliable in the client bundle. The template runs **`scripts/generate-environment.ts`**, which reads **`.env`**, **`.env.local`**, **`.env.dev`** / **`.env.prod`** and writes only keys prefixed with **`CSDK_PUBLIC_*`** into **`src/environments/environment.dev.ts`** / **`environment.prod.ts`** as string literals. -- **`sitecore.config.ts`** passes that object as **`clientEnv`** into **`defineConfig`** from **`@sitecore-content-sdk/angular`**, which merges **`clientEnv`** with **`getProcessEnv()`** (Node/SSR only) and forwards to content **`defineConfig`**. -- Server entry loads dotenv (e.g. **`load-env.ts`**) so **`SITECORE_*`** secrets exist at runtime for SSR and Express without embedding them in the browser bundle. - -## Product / security notes - -- Use **`.env.*.example`** (placeholders only) in templates; copy to **`.env.local`** for real values. Never commit secrets. -- Next template examples: [../content-sdk-nextjs/doc-example-environment-variable-files.md](../content-sdk-nextjs/doc-example-environment-variable-files.md). - -## Related - -- [doc-sitecore-config-input.md](doc-sitecore-config-input.md) — full **`SitecoreConfigInput`** reference -- [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) — GraphQL endpoint resolution from merged config diff --git a/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md b/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md deleted file mode 100644 index 3e624190e8..0000000000 --- a/llm-wiki/wiki/common/doc-sitecore-client-and-graphql.md +++ /dev/null @@ -1,108 +0,0 @@ -# `SitecoreClient` and GraphQL (content package) - -**Mental model:** Experience Edge (or local GraphQL) returns **JSON** layout/dictionary data. The head’s **`sitecore.config.ts`**, merged via **`defineConfig`** ([doc-sitecore-config-input.md](doc-sitecore-config-input.md)) and env ([doc-config-environment-variables.md](doc-config-environment-variables.md)), drives **`createGraphQLClientFactory`** and **`SitecoreClient`**. This path is the same for **Next**, **Angular**, and any app using **`@sitecore-content-sdk/content`**. - -**Packages:** `@sitecore-content-sdk/core` (HTTP GraphQL client, retry), `@sitecore-content-sdk/content` (`SitecoreClient`, layout/dictionary/editing services). - -## GraphQL client factory - -**Source:** `packages/content/src/client/utils.ts` — **`createGraphQLClientFactory`**. - -`GraphQLClientOptions` = `Pick<SitecoreConfigInput, 'api'>` + optional **`FetchOptions`**. - -### Branching (resolved endpoint) - -| Condition | Result | -|-----------|--------| -| `api.edge.contextId` (server) | Edge endpoint from **`getEdgeProxyContentUrl(edgeUrl)`** + server `contextId` | -| Browser + `api.edge.clientContextId` | Same Edge base + **client** `contextId` | -| `api.local.apiKey` && `api.local.apiHost` | `` `${apiHost}${path}` `` + API key header | -| Browser, none of the above | Warn; dummy endpoint `/api/graphql` | -| Server, none | **Throw** (misconfiguration) | - -### Edge content URL - -`packages/content/src/client/edge-proxy.ts` — **`getEdgeProxyContentUrl`** appends **`/v1/content/api/graphql/v1`** to the normalized Edge base (not the bare `edgeUrl` root alone). - -### Local URL - -`` `${local.apiHost}${local.path}` ``; default **`path`** from **`buildFallbackConfig`**: **`/sitecore/api/graph/edge`**. - -### Head-specific consumers - -- **Any head:** **`SitecoreClient`** constructor uses the factory from merged **`api`** + **`retries`**. -- **Next.js only:** dev **proxy** (`packages/nextjs/src/proxy/proxy.ts`) — see [../content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md). - -## `SitecoreClient` role - -Framework-agnostic client: layout pages, dictionary, preview/editing, error pages, sitemap (**XML** string), robots, **`getData`** (raw GraphQL). - -**Implementation:** `packages/content/src/client/sitecore-client.ts`. - -### Construction - -- **`createGraphQLClientFactory`** from init **`api`** + retries — table above. -- Services: **`layoutService`** (**`LayoutService`** in **`packages/content/src/layout/`**), **`dictionaryService`**, **`editingService`**, **`errorPagesService`**, **`sitePathService`**, **`componentService`**. Overridable via **`SitecoreClientInit.custom`**. -- **`getPage`** → **`layoutService.fetchLayoutData`** → personalization hooks / **`applyContentRewrite`** → **`Page`**. - -### `BaseSitecoreClient` methods - -| Method | Role | -|--------|------| -| `getData` | Raw GraphQL | -| `getPage` | Route layout + metadata | -| `getDictionary` | Dictionary phrases | -| `getPreview` | Preview / editing layout via **`EditingService`** | -| `getDesignLibraryData` | Design library | -| `getErrorPages` / `getErrorPage` | Error content | -| `getPagePaths` | SSG paths | -| `getHeadLinks` | Styles / theme links | -| `getSiteMap` | Sitemap XML string | -| `getRobots` | robots.txt | - -## `Page` type contract - -`SitecoreClient.getPage` returns **`Page | null`** (`packages/content/src/client/sitecore-client.ts`). - -| Field | Type | Notes | -|-------|------|-------| -| `layout` | `LayoutServiceData` | Contains `layout.sitecore.route: RouteData \| null` — route fields and placeholder tree | -| `siteName` | `string?` | Resolved site name | -| `locale` | `string` | Active locale/language for this page | -| `mode` | `PageMode` | See flags below | - -**`PageMode` flags:** - -| Flag | Meaning | -|------|---------| -| `mode.isEditing` | Page is open in the Sitecore Pages editor | -| `mode.isPreview` | Preview mode | -| `mode.isNormal` | Normal rendering (not editing, not preview) | -| `mode.isDesignLibrary` | Design Library rendering | - -## Editing utilities (`content/editing`) - -`@sitecore-content-sdk/content/editing` exports two framework-agnostic helpers used by both Angular and Next.js: - -| Export | Purpose | -|--------|---------| -| `isEditorActive()` | Returns `true` when the page is loaded inside the Sitecore Pages editor (checks window/DOM signals) | -| `resetEditorChromes()` | Re-initializes the editor chrome decorators after client-side navigation or dynamic content changes | - -Both heads re-export these from their own packages. In Angular they appear in `SitecoreContextService`'s editing integration; in Next.js the React `Placeholder` calls `PagesEditor.resetChromes()` (same concept, reached through `@sitecore-content-sdk/react` re-export). - -**Source:** `packages/content/src/editing/utils.ts` - -## Head-specific usage - -- **Angular:** `getClient()` singleton, `resolveSitecorePage` — see [doc-sitecore-config-typescript-angular.md](../content-sdk-angular/doc-sitecore-config-typescript-angular.md). -- **Next.js:** `SitecoreNextjsClient` extensions — see [doc-sitecore-client-apis.md](../content-sdk-nextjs/doc-sitecore-client-apis.md). - -## Related - -- [doc-sitecore-config-input.md](doc-sitecore-config-input.md) -- [../content-sdk-nextjs/doc-architecture-edge-graphql.md](../content-sdk-nextjs/doc-architecture-edge-graphql.md) — official doc alignment + `package.json` note - -## Raw - -- `llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md` (Services and APIs ingest) diff --git a/llm-wiki/wiki/common/doc-sitecore-config-input.md b/llm-wiki/wiki/common/doc-sitecore-config-input.md deleted file mode 100644 index f485b183d3..0000000000 --- a/llm-wiki/wiki/common/doc-sitecore-config-input.md +++ /dev/null @@ -1,64 +0,0 @@ -# `SitecoreConfigInput` and `sitecore.config.ts` (content package) - -Framework-agnostic configuration consumed by **`@sitecore-content-sdk/content`** (`defineConfig`, `SitecoreClient`, GraphQL factory). Next.js and Angular add thin **`defineConfig`** wrappers that supply environment maps before calling this layer. - -**Code:** `packages/content/src/config/models.ts` (types), `packages/content/src/config/define-config.ts` (merge + validation). - -## Where `sitecore.config.ts` lives - -- Generated apps: root **`sitecore.config.ts`** (any head). -- Monorepo templates: Next (Pages / App Router), Angular under `packages/create-content-sdk-app/src/templates/*/`. - -## Merge pipeline (content `defineConfig`) - -1. **`buildFallbackConfig(env)`** fills gaps from process-backed env keys (see [doc-config-environment-variables.md](doc-config-environment-variables.md)). -2. **`resolveConfig`** **`deepMerge`** — skips **`undefined`** and empty string **`''`** overrides so env can intentionally clear some paths. -3. **`resolveEdgeUrl`** normalizes merged **`api.edge.edgeUrl`**. -4. **CLI mode** (`SITECORE_CLI_MODE=true`): lazy validation **Proxy** on sensitive paths; otherwise **`validateApiConfiguration`** runs immediately (server must have Edge **`contextId`** or local **`apiHost`** + **`apiKey`**). - -## `SitecoreConfig` shape - -Runtime type is **`SitecoreConfig`** = **`DeepRequired<SitecoreConfigInput>`**. - -### Top-level keys - -| Key | Type | Purpose | -|-----|------|---------| -| `api` | optional object | **`edge`** and/or **`local`**; runtime choice in **`createGraphQLClientFactory`**. | -| `defaultLanguage` | `string?` | Fallback locale. | -| `defaultSite` | `string?` | Fallback site name. | -| `editingSecret` | `string?` | Editing / preview route auth. | -| `retries` | object? | `count`, `retryStrategy` (**`RetryStrategy`**). | -| `layout` | object? | `formatLayoutQuery` hook. | -| `dictionary` | object? | `caching.enabled`, `caching.timeout` (seconds). | -| `multisite` | object? | `enabled`, `useCookieResolution(req?,res?) => boolean`. | -| `personalize` | object? | `enabled`, `edgeTimeout`, `cdpTimeout`, `scope`, `channel`, `currency`. | -| `redirects` | object? | `enabled`, `locales` — **redirect maps only** (not redirect items). | -| `rewriteMediaUrls` | `boolean \| ((v: string) => string)?` | Media/content URL rewrite in layout JSON (`SitecoreClient.applyContentRewrite`). | -| `disableCodeGeneration` | `boolean?` | Opt out of code-generation tooling. | - -### `api.edge` / `api.local` - -See **`SitecoreConfigInput`** JSDoc in **`models.ts`**: Edge **`contextId`**, **`clientContextId`**, **`edgeUrl`** vs local **`apiKey`**, **`apiHost`**, **`path`**. - -### `SitecoreCliConfigInput` (separate `sitecore.cli.config`) - -Holds **`config: SitecoreConfig`**, optional **`build.commands`**, **`scaffold.templates`** (`ScaffoldTemplate[]`), **`componentMap`** (`GenerateMapArgs` + optional **`generator`**). See **`models.ts`** and **`packages/content/src/tools/generate-map.ts`**. - -## Head-specific wrappers - -| Head | Wrapper | Notes | -|------|---------|-------| -| **Next.js** | `@sitecore-content-sdk/nextjs/config` **`defineConfig`** | **`getNextFallbackConfig`** adds **`NEXT_PUBLIC_*`**, preview multisite cookie behavior, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`** (full list: [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md)). | -| **Angular** | `@sitecore-content-sdk/angular` **`defineConfig`** | Merges **`clientEnv`** (generated **`environment*.ts`**) with **`getProcessEnv()`** on the server, then calls content **`defineConfig`**. | - -## Related - -- [doc-config-environment-variables.md](doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys -- [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) — **`SitecoreClient`** + GraphQL URL selection -- [../content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) — Next-only **`SitecoreConfigInput`** fields and App Router multisite notes -- [../content-sdk-angular/doc-environment-and-define-config-angular.md](../content-sdk-angular/doc-environment-and-define-config-angular.md) — Angular env generation - -## Raw (official topic) - -- `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md` diff --git a/llm-wiki/wiki/common/doc-terminology-platform-names.md b/llm-wiki/wiki/common/doc-terminology-platform-names.md deleted file mode 100644 index 5c2b737997..0000000000 --- a/llm-wiki/wiki/common/doc-terminology-platform-names.md +++ /dev/null @@ -1,13 +0,0 @@ -# Platform naming (SAI / XM Cloud / XMC) - -In official docs, URLs, and template comments you will see **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC**. For Content SDK work in **this monorepo**, treat them as the **same hosted platform context** unless code explicitly branches on a label. - -## Practical guidance - -- Do not treat mixed labels as conflicting products when reading issues, PRs, or wiki notes. -- Template `sitecore.config` comments may use **XMC**-style URLs while SAI doc URLs use `/sai/` — same product family for this wiki. - -## See also - -- [content-sdk-nextjs/overview-content-sdk.md](../content-sdk-nextjs/overview-content-sdk.md) -- [content-sdk-nextjs/doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) — example of mixed URL prefixes in comments vs docs diff --git a/llm-wiki/wiki/common/index.md b/llm-wiki/wiki/common/index.md deleted file mode 100644 index 10ae34a83a..0000000000 --- a/llm-wiki/wiki/common/index.md +++ /dev/null @@ -1,23 +0,0 @@ -# Common wiki (shared packages) - -Framework-agnostic Content SDK knowledge: **`packages/core`**, **`packages/content`** (`SitecoreClient`, `defineConfig`, layout GraphQL, editing helpers when described without a specific head), **`packages/cli`**, and **cross-head env / config contracts**. - -## Pages (canonical for all heads) - -| Page | Summary | -|------|---------| -| [doc-sitecore-config-input.md](doc-sitecore-config-input.md) | **`SitecoreConfigInput`** / **`SitecoreConfig`**, merge pipeline, CLI config, head wrappers | -| [doc-config-environment-variables.md](doc-config-environment-variables.md) | **`buildFallbackConfig`** exact env keys; **`SITECORE_*` / `NEXT_PUBLIC_*` / `CSDK_PUBLIC_*`** (Angular-only) | -| [doc-sitecore-client-and-graphql.md](doc-sitecore-client-and-graphql.md) | **`createGraphQLClientFactory`**, Edge/local URLs, **`SitecoreClient`** methods | -| [doc-component-map.md](doc-component-map.md) | Component map format, `GenerateMapArgs`, type names, head-specific wiring | -| [doc-terminology-platform-names.md](doc-terminology-platform-names.md) | **Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, **XMC** — same platform naming in docs and comments | -| [wiki-boundary-and-token-audit.md](../wiki-boundary-and-token-audit.md) | Next vs Angular vs **common** boundaries, LLM routing, vague-language checklist | - -## Head wikis - -| Stack | Index | -|-------|--------| -| Next.js | [../content-sdk-nextjs/index.md](../content-sdk-nextjs/index.md) — middleware, `SitecoreNextjsClient`, templates | -| Angular | [../content-sdk-angular/index.md](../content-sdk-angular/index.md) — loaders, SSR, `environment*.ts` | - -When a topic is duplicated between a head wiki and **common**, treat **common** as canonical for **`@sitecore-content-sdk/content`** behavior; head pages keep integration-only deltas. diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md deleted file mode 100644 index ad53f6e7d3..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-architecture-goals-challenges-and-foundation.md +++ /dev/null @@ -1,21 +0,0 @@ -# Goals, challenges, and foundation (Angular head) - -Aligned with the **Goal**, **Challenges**, and **Foundation** sections of the ingested design PDF and with the shipped Angular integration. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Goals - -The Angular head is meant to reuse the same Content SDK concepts as other stacks: a shared **Sitecore client**, **component map** (and future **import map** parity), **`sitecore.config`** / CLI tooling, and familiar layout/page data types from `@sitecore-content-sdk/content`. - -## Challenges - -1. **Bundle shape:** logic that must run only on the server must not be pulled into the browser bundle incorrectly. Loaders and middleware stay on clearly separated paths; loader bodies should use static imports rather than Angular DI when they also run inside Express (see [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md)). - -2. **`process.env` on the client:** the browser bundle does not have Node’s `process.env`. The scaffold mitigates this with a **build-time** script that emits `environment.*.ts` from **`CSDK_PUBLIC_*`** variables (see [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md)). - -## Foundation - -The PDF’s “foundation” points at the **loader system**: route **`resolve`** functions backed by a **loader registry**, server execution with **`TransferState`**, and a small **Express** RPC surface for client navigations. That design is implemented under `packages/angular/src/loaders/` and `packages/angular/src/server/loader-data-service-middleware.ts`. - -**Next:** [Loaders — routes and registry](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md b/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md deleted file mode 100644 index d896d9170f..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-architecture-loaders-and-ssr.md +++ /dev/null @@ -1,32 +0,0 @@ -# Angular architecture — index - -This hub splits the ingested **JSS-Angular Live Design** architecture PDF into focused wiki pages, each checked against **`@sitecore-content-sdk/angular`** and the **Angular template** under `packages/create-content-sdk-app/src/templates/angular/`. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · PDF in repo: [`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`](../../raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf) - -The Angular head fetches Sitecore layout data through a **loader system**: route `resolve` functions backed by a named loader registry, server execution with Angular's `TransferState`, and an Express RPC endpoint (`/_data`) for client navigations. Each subsection page below covers a section of this design. - -## Pages (by PDF section) - -| Topic | Page | -|--------|------| -| Goal, challenges, foundation | [doc-architecture-goals-challenges-and-foundation.md](doc-architecture-goals-challenges-and-foundation.md) | -| Route `resolve`, registry, `pageLoader` pattern | [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) | -| `loaderResolver`, `TransferState`, `/_data`, errors / redirects | [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) | -| `PreLoaderDataService` (parallel prefetch) | [doc-preloader-data-service.md](doc-preloader-data-service.md) | -| Loaders outside `inject()` in loader bodies | [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) | -| Env script + `defineConfig` | [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) | -| Standalone components, map, placeholders | [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) | -| SSR + Express middleware order | [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) | -| `sitecore.config.ts` | [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) | -| Field directives | [doc-field-directives.md](doc-field-directives.md) | -| Editing / page context | [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | -| Multisite | [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | -| Personalization | [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | -| ISR / server-side caching (investigation) | [doc-isr-investigation-and-caching.md](doc-isr-investigation-and-caching.md) | - -## See also - -- [index.md](index.md) — Angular wiki hub -- [Common wiki — config, env, SitecoreClient + GraphQL](../common/index.md) — **`@sitecore-content-sdk/content`** canonical pages -- [Next.js wiki](../content-sdk-nextjs/index.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md b/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md deleted file mode 100644 index c3478b60dc..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-components-and-placeholder-map.md +++ /dev/null @@ -1,23 +0,0 @@ -# Components, component map, and placeholders (Angular) - -Standalone components, generated **component map**, and placeholder resolution aligned with other Content SDK heads. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Standalone components - -The Angular template and package assume **standalone** components (no NgModule feature pattern for app components). - -## Component map - -The component map format and CLI generation contract are shared with Next.js — see [../common/doc-component-map.md](../common/doc-component-map.md) for the full specification. - -Angular-specific details: -- **`packages/angular/src/tools/generate-map.ts`** implements the Angular generator; it calls the shared `buildComponentMapContent()` and hardcodes the `ScFormComponent` built-in entry. -- The generated map is consumed via **`SITECORE_COMPONENT_MAP`** injection token in **`app.config.ts`** (`useValue: componentMap` from **`.sitecore/component-map`**). - -## Placeholders - -**`sc-placeholder`** and **`placeholder-utils.ts`** resolve rendering names to standalone components using the component map (PascalCase keys, default + variant files at generation time). Editing mode affects which renderings are exposed (`getPlaceholderRenderings` takes **`isEditing`** from **`SitecoreContextService`** — see [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md)). - -**Related:** [doc-field-directives.md](doc-field-directives.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md deleted file mode 100644 index 682412b6eb..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-editing-and-page-context-angular.md +++ /dev/null @@ -1,52 +0,0 @@ -# Editing and page context (Angular) - -**Status: editing integration is not yet implemented** for the Angular head. This page documents what is currently available. - -The PDF marked **Editing** as TBA. In code, editing is surfaced primarily through **`SitecoreContextService`** and layout **`Page.mode`**, not through a separate Next-style middleware document for Angular. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## `SitecoreContextService` - -Injectable **`providedIn: 'root'`** with: - -- **`page`** — read-only signal of the current **`Page | null`**. -- **`isEditing`** — derived from **`page()?.mode?.isEditing`**. - -Call **`setPage(page)`** from the route shell when **`page`** (and other) route data resolves so placeholders, forms, and directives see the same context. - -```4:27:packages/angular/src/lib/sitecore-context.service.ts -/** - * Provides request-scoped Sitecore context (current page, mode flags) to the Angular component tree. - * Analogous to React's `SitecoreProvider` / `useSitecore()`. - * - * Set once per navigation via `setPage(page)` — typically from the route component - * after the page loader resolves. All consumers (placeholders, field directives, forms) - * inject this service to read the current page and editing state. - * @public - */ -@Injectable({ providedIn: 'root' }) -export class SitecoreContextService { - /** Current Sitecore page data (layout + mode). */ - readonly page: Signal<Page | null>; - - /** Whether the current page is in editing mode. */ - readonly isEditing: Signal<boolean>; - - private readonly _page: WritableSignal<Page | null>; - - constructor() { - const pageSignal = signal<Page | null>(null); - this._page = pageSignal; - this.page = pageSignal.asReadonly(); - this.isEditing = computed(() => pageSignal()?.mode?.isEditing ?? false); - } -``` - -## Consumers - -- **`sc-placeholder`** uses **`isEditing`** when choosing placeholder renderings. -- **`sc-form`** skips certain client behavior when **`isEditing`** is true. -- **`@sitecore-content-sdk/angular`** re-exports **`isEditorActive`** and **`resetEditorChromes`** from **`@sitecore-content-sdk/content/editing`** (implementation: **`packages/content/src/editing/utils.ts`**). - -**Related:** [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md deleted file mode 100644 index 919352504c..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-environment-and-define-config-angular.md +++ /dev/null @@ -1,23 +0,0 @@ -# Environment and `defineConfig` (Angular) - -How the Angular head avoids raw **`process.env`** in the browser and still feeds **`defineConfig`** from `@sitecore-content-sdk/content`. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -**Shared with all heads:** [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) (**`buildFallbackConfig`** env keys; public prefix **`CSDK_PUBLIC_*`** vs **`NEXT_PUBLIC_*`**) · [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) (merge pipeline, **`SitecoreConfigInput`** tables). - -## Angular `defineConfig` wrapper - -`packages/angular/src/config/define-config.ts` merges a **`clientEnv`** record with **`getProcessEnv()`** (Node on the server, empty on the client) and forwards to the shared **`defineConfig`** from **`@sitecore-content-sdk/content/config`**. Call sites pass **`clientEnv`** from generated **`environment.ts`** so public values exist in browser bundles. - -## Scaffold: `generate-environment.ts` - -The template script **`packages/create-content-sdk-app/src/templates/angular/scripts/generate-environment.ts`** loads **`.env`**, **`.env.local`**, and mode-specific **`.env.dev`** / **`.env.prod`**, filters keys prefixed with **`CSDK_PUBLIC_`**, and writes **`src/environments/environment.dev.ts`** and **`environment.prod.ts`**. The build selects the right variant so **`defineConfig`** receives stable literals instead of runtime **`process.env`** reads in client code. - -Only **`CSDK_PUBLIC_*`** keys are embedded in those files; server secrets use **`process.env`** at runtime (see script header comment and **`load-env`** / server bootstrap). - -## GraphQL and `SitecoreClient` - -Merged **`SitecoreConfig`** drives **`createGraphQLClientFactory`** and **`SitecoreClient`** the same way as for any head using **`@sitecore-content-sdk/content`**; see [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **Angular usage** for **`getClient()`** + **`new SitecoreClient(scConfig)`**. - -**Related:** [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) · Next-only **`getNextFallbackConfig`** pipeline: [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md b/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md deleted file mode 100644 index 52ea2e7095..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-field-directives.md +++ /dev/null @@ -1,23 +0,0 @@ -# Field directives (`@sitecore-content-sdk/angular`) - -The PDF marked **Fields** as TBA; the package already ships a small set of **attribute directives** under `packages/angular/src/field-directives/`. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Overview - -| Directive | Role | -|-----------|------| -| **`scText`** | Binds **`TextField`** to host text; default **HTML-encode** via `textContent`, optional `innerHTML` when encoding is off. | -| **`scRichText`** | Binds **`TextField`** rich text to **`innerHTML`** using **`DomSanitizer.bypassSecurityTrustHtml`** (CMS HTML). | -| **`scImage`** | Renders **`ImageField`** on **`img`** (src, alt, dimensions when present). | -| **`scLink`** | Anchor from **`LinkField`** / helpers in **`link-field-utils.ts`**. | -| **`scRouterLink`** | Wraps **`RouterLink`** with Sitecore link resolution (internal vs external). | - -Specs live alongside each directive (`*.spec.ts`). - -## Security note - -**`scText`** with **`scTextEncode="false"`** assigns **`innerHTML`** from string values — use only for trusted content. **`scRichText`** intentionally bypasses strict sanitization for typical CMS HTML; follow Sitecore authoring and CSP practices. - -**Related:** [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md deleted file mode 100644 index e86f7b70b9..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-i18n-angular.md +++ /dev/null @@ -1,33 +0,0 @@ -# Locale and dictionary (Angular) — stub - -**Status: full i18n is not yet implemented** for the Angular head. This page documents what is currently available. - -## What is available now - -### Locale from the `Page` object - -The Angular template does not use locale URL segments. Locale is resolved by Sitecore on the server and returned as **`page.locale`** in the `Page` object from `SitecoreClient.getPage`. - -`resolveSitecorePage` accepts optional `options.locale`; if omitted it falls back to `sitecoreConfig.defaultLanguage`. The resolved locale is available to components via `SitecoreContextService.page()?.locale`. - -### Dictionary - -A **`dictionaryLoader`** in the route config calls **`getClient().getDictionary({ site, locale })`** (`SitecoreClient.getDictionary` from `@sitecore-content-sdk/content`). Dictionary data is accessed from route data as `data()?.dictionary`. - -**Source:** `packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts` - -### Default language env key - -`CSDK_PUBLIC_DEFAULT_LANGUAGE` maps to `defaultLanguage` in `sitecore.config.ts` via `buildFallbackConfig`. See [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md). - -## Not yet implemented - -- Locale URL segments / routing by locale -- Multi-locale route generation (`getPagePaths` with locale list) -- Third-party i18n library integration - -## Related - -- [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) — `resolveSitecorePage` options -- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — env keys -- [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) — `defaultLanguage` in config diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md b/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md deleted file mode 100644 index d1d9262d4a..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-loader-resolver-transfer-state-and-endpoint.md +++ /dev/null @@ -1,77 +0,0 @@ -# `loaderResolver` — `TransferState`, `/_data`, and outcomes - -Execution path for **`loaderResolver(loaderId)`**: server vs browser, **`TransferState`**, HTTP fallback, and error / redirect handling. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## State key - -Keys are built as `makeStateKey(\`loader:${loaderId}:${url}\`)` so each loader and URL pair is isolated (`loader-resolver.ts`). - -## Server (SSR) - -On the server, the resolver **`inject`s** the **`LOADER_REGISTRY`**, loads the **`LoaderFn`**, builds **`LoaderContext`** (including **`requestContext`** from the optional Angular **`REQUEST`** token when present), runs the loader, and on success **`transferState.set(key, result)`** before returning the value. Redirect results from the loader are turned into router redirects via **`applyRedirect`**. - -```109:139:packages/angular/src/loaders/loader-resolver.ts - const url = state.url; - const key = stateKey(loaderId, url); - - if (isPlatformBrowser(platformId)) { - try { - return await resolveOnBrowser(route, state, loaderId, router); - } catch (e) { - // special handling for browser, as navigation error for handleNavigationError is only generated on server - return redirectOnNavigationError(e as Error, url, notFoundRoute, errorRoute, router); - } - } - - const loader = registry[loaderId]; - - if (!loader) { - throw new Error(`No loader registered for id "${loaderId}"`); - } - - const requestContext = request ? extractRequestContext(request) : undefined; - - const result = await loader({ - url, - params: route.params, - query: route.queryParams, - requestContext, - }); - if (isLoaderRedirectResult(result)) { - return applyRedirect(router, result.loaderRedirectTarget); - } - transferState.set(key, result); - return result; -``` - -**Note:** On the server, **`route.params`** are the params for the **activated** route snapshot only; the browser path merges **`pathFromRoot`** when calling **`LoaderDataService.getData`** (see below). - -## Browser - -1. If **`TransferState.hasKey(key)`**, the value is **`get`** then **`remove`** (one-shot hydration / navigation). -2. Otherwise **`LoaderDataService.getData`** **`POST`s** to the loader endpoint (default **`/_data`**, from **`LOADER_DATA_ENDPOINT`** in `packages/angular/src/server/constants.ts`). The service deduplicates concurrent requests per cache key (`loader:${loaderId}:${url}`). - -Browser resolver merges **all** parent params for the HTTP request: - -```77:84:packages/angular/src/loaders/loader-resolver.ts - const allParams = route.pathFromRoot.reduce((acc, r) => ({ ...acc, ...r.params }), {}) as Params; - - const resp = await loaderData.getData({ - url, - loaderId, - params: allParams, - query: route.queryParams as Record<string, string | string[]>, - }); -``` - -3. **`LoaderApiResponse`** kinds map to throws or redirects: **`error`** → **`LoaderHttpError`**, **`notFound`** → **`NotFoundNavigationError`**, **`redirect`** → **`applyRedirect`**. - -Express mirrors the same execution and response kinds in **`createLoaderDataServiceMiddleware`** (`packages/angular/src/server/loader-data-service-middleware.ts`), including mapping **`NotFoundNavigationError`** to a **`notFound`** payload. - -## Custom endpoint - -Apps may provide **`FETCH_DATA_ENDPOINT`** to override the URL **`LoaderDataService`** calls; it defaults to **`LOADER_DATA_ENDPOINT`** when omitted (`loader-data.service.ts`). - -**Related:** [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) · [doc-preloader-data-service.md](doc-preloader-data-service.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md b/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md deleted file mode 100644 index 186e2a09cf..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-loaders-outside-angular-di.md +++ /dev/null @@ -1,18 +0,0 @@ -# Loaders outside Angular `inject()` in loader bodies - -Why loader functions should not rely on **`inject()`** or constructor DI for Sitecore wiring. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Two execution contexts - -A **`LoaderFn`** runs in: - -1. **Angular SSR / server resolver** — inside Angular’s injection context **for the resolver wrapper**, but the **loader callback** is still invoked as a plain async function with **`LoaderContext`** (`loader-resolver.ts` server branch). -2. **Express loader middleware** — **`executeLoader`** calls the same **`LoaderFn`** with only **`LoaderContext`**; there is **no** Angular injector (`loader-data-service-middleware.ts`). - -So anything used **inside** the loader body must be available **without** calling **`inject()`** from within that body. The supported pattern is **static imports**: default **`sitecore.config`**, **`getClient()`** factory module, and helpers such as **`resolveSitecorePage`** from **`@sitecore-content-sdk/angular`**. - -The **resolver factory** itself uses **`inject()`** for **`LOADER_REGISTRY`**, **`TransferState`**, **`Router`**, **`platformId`**, and optional **`REQUEST`**; that is fine because it only runs inside Angular. - -**Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md b/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md deleted file mode 100644 index 13229eef1e..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-loaders-route-registry-and-page-loader.md +++ /dev/null @@ -1,41 +0,0 @@ -# Loaders — route configuration, registry, and `pageLoader` - -How Angular wires **route `resolve`** to named loaders via **`provideLoaderRegistry`** and the typical **`pageLoader`** pattern. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Route configuration - -Routes attach **`loaderResolver('<id>')`** to `resolve` keys (for example `page`, `dictionary`). The resolver is created by `loaderResolver` in `packages/angular/src/loaders/loader-resolver.ts` and tagged with a **`LOADER_ID`** symbol so `PreLoaderDataService` can discover it (see [doc-preloader-data-service.md](doc-preloader-data-service.md)). - -## Registry in `app.config.ts` - -The generated app provides the registry object with **`provideLoaderRegistry(LOADERS)`** and registers **`PreLoaderDataService`** (template: `packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts`). - -## Default page loader (example) - -Loaders are plain **`LoaderFn`** functions in app code. They receive **`LoaderContext`** (`url`, `params`, `query`, optional `requestContext` on the server). The usual **page** loader calls **`resolveSitecorePage`** with **`context.url`**, the default **`sitecore.config`**, and a shared **`getClient()`** singleton — all **imported**, not injected in the loader body, so the same function runs in SSR resolvers and in the Express loader middleware. - -`resolveSitecorePage` is documented as taking a **path**; `LoaderContext.url` is the current URL path for the navigation. - -```19:33:packages/angular/src/lib/sitecore-page-resolver.ts -export async function resolveSitecorePage( - path: string, - sitecoreConfig: SitecoreConfig, - client: SitecoreClient, - options?: { locale?: string; site?: string } -): Promise<Page | null> { - const pageOptions: PageOptions = {}; - if (options?.locale) { - pageOptions.locale = options.locale || sitecoreConfig.defaultLanguage; - } - if (options?.site) { - pageOptions.site = options.site || sitecoreConfig.defaultSite; - } - return client.getPage(path, pageOptions); -} -``` - -On **not found**, loaders throw **`NotFoundNavigationError`** so the resolver / middleware can map that to a **404** response or not-found route (see [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md)). - -**Related:** [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) · [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md deleted file mode 100644 index 17472d02c2..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-multisite-angular-roadmap.md +++ /dev/null @@ -1,17 +0,0 @@ -# Multisite (Angular) — status - -The PDF marked **Multisite** as TBA. **`resolveSitecorePage`** already accepts optional **`site`** (and **`locale`**) overrides on top of **`sitecore.config`** defaults, but cookie-based or host-header-based site resolution is not yet implemented as a first-class Angular feature. - -```4:9:packages/angular/src/lib/sitecore-page-resolver.ts -/** - * Resolves layout/page data for a route path using a {@link SitecoreClient} and Sitecore config. - * Import your `sitecore.config` default and shared client (e.g. `getClient()`) from the app; - * this stays usable from route loaders without Angular injection context. - * - * Future: add helpers for personalization and multisite alongside this call. - * @param {string} path - Route path (e.g. `'/'` or `'/about'`). -``` - -**Practical note:** apps can pass **`options.site`** / **`options.locale`** from loader logic once they determine the active site (for example a custom resolver or request headers). Shared **`SitecoreConfig`** **`multisite`** keys are defined in [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md). Next.js **`[site]`** routing and **`getNextFallbackConfig`** multisite behavior are described in [doc-sitecore-config.md](../content-sdk-nextjs/doc-sitecore-config.md) under **Multisite (Next App Router)**. - -**Related:** [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md b/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md deleted file mode 100644 index 34f3ddd480..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-personalization-angular-roadmap.md +++ /dev/null @@ -1,7 +0,0 @@ -# Personalization (Angular) — status - -The PDF marked **Personalization** as TBA. The Angular **`resolveSitecorePage`** helper is a thin **`client.getPage`** wrapper; its JSDoc explicitly calls out **future** helpers for **personalization** (and multisite) next to this call. - -Until those helpers exist, personalization behavior depends on what **`SitecoreClient.getPage`** and your **`sitecore.config`** (the **`personalize`** block — `enabled`, `edgeTimeout`, `cdpTimeout`, `scope`, `channel`, `currency`) already provide. See [common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) for the full `SitecoreConfigInput` surface. - -**Related:** [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) · [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md b/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md deleted file mode 100644 index dc400868d0..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-preloader-data-service.md +++ /dev/null @@ -1,34 +0,0 @@ -# `PreLoaderDataService` - -Browser-only **prefetch** of loader data for all **`loaderResolver`** entries on the target route so sequential Angular resolvers often hit **`LoaderDataService`** cache or join in-flight requests. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Behavior - -- Subscribes to the router’s **`ActivationStart`** events. -- When the activated snapshot is a **leaf** (no `children.length`), it collects every **`resolve`** function on **`pathFromRoot`** that carries the **`LOADER_ID`** symbol (set by **`loaderResolver`**). -- For each collected **`LoaderDataRequest`**, it calls **`LoaderDataService.prefetch()`**, which is a **no-op on the server** (`isPlatformBrowser` guard in both services). - -```24:34:packages/angular/src/loaders/pre-loader-data.service.ts -/** - * PreLoaderDataService kicks off loader data fetches for all loaders in the current route - * and its parent routes in parallel, so that when Angular runs resolvers sequentially, - * resolvers get cache hits or join already-pending requests instead of waiting. - * - * Subscribes to the router's ActivationStart event and prefetches for the - * ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader - * resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then - * calls LoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches - * run in parallel; results are stored in LoaderDataService cache for getData() to consume. - * @public - */ -``` - -Prefetch issues **`HttpClient.post`** asynchronously; the constructor’s **`for`** loop starts each prefetch without awaiting, so multiple loaders start **without waiting for each other**. - -## Template wiring - -The Angular scaffold registers **`PreLoaderDataService`** next to **`provideLoaderRegistry(LOADERS)`** in **`app.config.ts`**. - -**Related:** [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md b/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md deleted file mode 100644 index 76af0105a0..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-sitecore-config-typescript-angular.md +++ /dev/null @@ -1,28 +0,0 @@ -# `sitecore.config.ts` (Angular) - -Angular apps use the same root **`sitecore.config.ts`** model as other Content SDK heads. - -**Shared reference:** [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, **`api.edge` / `api.local`**, merge pipeline. [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — env keys consumed by **`buildFallbackConfig`**. [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — how config becomes GraphQL URLs and **`SitecoreClient.getPage`**. - -## `provideSitecoreAngular` - -**`provideSitecoreAngular(config: SitecoreAngularConfig): EnvironmentProviders`** — the Angular app's DI bootstrap entry point. Defined in `packages/angular/src/lib/providers.ts`. - -| Parameter | Type | Required | Role | -|-----------|------|----------|------| -| `sitecoreConfig` | `SitecoreConfig` | No | Config from `sitecore.config.ts`; bound to `SITECORE_CONFIG_TOKEN` | -| `sitecoreClient` | `SitecoreClient` | No | App-owned client singleton; bound to `SITECORE_CLIENT_TOKEN` | -| `notFoundRoute` | `string` | No | Angular route path for 404; bound to `NOT_FOUND_ROUTE_TOKEN` | -| `errorRoute` | `string` | No | Angular route path for 500; bound to `ERROR_ROUTE_TOKEN` | - -## In the template - -The scaffold imports the default config into **`app.config.ts`**: `provideSitecoreAngular({ sitecoreConfig: scConfig, sitecoreClient: getClient(), notFoundRoute: '/not-found', errorRoute: '/error' })`. Loaders import **`sitecore.config`** statically (no DI in loader bodies — see [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md)). - -## `SitecoreClient` in generated apps - -`packages/create-content-sdk-app/src/templates/angular/src/content-sdk/client/sitecore-client.ts` exposes **`getClient()`**: a lazy singleton **`new SitecoreClient(scConfig)`** so the Angular build does not require live credentials during route extraction. **`resolveSitecorePage`** in loaders calls **`client.getPage(path, pageOptions)`** with optional **`locale`** / **`site`** overrides. - -The **`SitecoreClient`** class and GraphQL behavior are unchanged from **`@sitecore-content-sdk/content`**; **`@sitecore-content-sdk/angular`** re-exports the client surface from **`@sitecore-content-sdk/content/client`**. - -**Related:** [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) · [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) diff --git a/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md b/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md deleted file mode 100644 index 5d208aee7f..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/doc-ssr-express-and-loader-middleware.md +++ /dev/null @@ -1,17 +0,0 @@ -# SSR, Express, and loader middleware (Angular) - -Express bootstrap order for **`loader-data-service`**, JSON parsing, and the Angular SSR handler. - -**Sources:** [raw extract](../../raw/2026-05-14-jss-angular-live-design-architecture.md) · [architecture index](doc-architecture-loaders-and-ssr.md) - -## Middleware order - -The design PDF states that **`loader-data-service`** is registered **before** the browser static folder and the main Angular SSR handler. The template’s **`server.ts`** uses **`express.json()`** first (so **`POST /_data`** bodies parse), then **`createLoaderDataServiceMiddleware({ loaders: LOADERS })`**, then static assets, then the SSR entry. - -That order matters because the browser **`LoaderDataService`** issues **`POST`** requests with a JSON body to the default **`LOADER_DATA_ENDPOINT`** (**`/_data`**, `packages/angular/src/server/constants.ts`). - -## Handler shape - -**`createLoaderDataServiceMiddleware`** exposes **GET** and **POST** on the same path, validates **`loaderId` / `url` / params / query**, runs **`executeLoader`**, and returns a **`LoaderApiResponse`** JSON payload (`loader-data-service-middleware.ts`). - -**Related:** [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) diff --git a/llm-wiki/wiki/content-sdk-angular/index.md b/llm-wiki/wiki/content-sdk-angular/index.md deleted file mode 100644 index bf0406b11b..0000000000 --- a/llm-wiki/wiki/content-sdk-angular/index.md +++ /dev/null @@ -1,55 +0,0 @@ -# Content SDK Angular wiki - -**Scope:** **`@sitecore-content-sdk/angular`**, the **Angular** scaffold under **`packages/create-content-sdk-app/src/templates/angular`**, and Angular-specific **sitecore.config** / **environment** generation. - -## Pages - -| Page | Summary | -|------|---------| -| [doc-architecture-loaders-and-ssr.md](doc-architecture-loaders-and-ssr.md) | **Architecture index** — links to subsection pages + PDF path | -| [doc-architecture-goals-challenges-and-foundation.md](doc-architecture-goals-challenges-and-foundation.md) | Goals, bundle/env challenges, loader foundation | -| [doc-loaders-route-registry-and-page-loader.md](doc-loaders-route-registry-and-page-loader.md) | Route `resolve`, `provideLoaderRegistry`, `pageLoader` / `resolveSitecorePage` | -| [doc-loader-resolver-transfer-state-and-endpoint.md](doc-loader-resolver-transfer-state-and-endpoint.md) | `loaderResolver`, `TransferState`, `/_data`, merged params, outcomes | -| [doc-preloader-data-service.md](doc-preloader-data-service.md) | `PreLoaderDataService`, `ActivationStart`, parallel prefetch | -| [doc-loaders-outside-angular-di.md](doc-loaders-outside-angular-di.md) | Loaders in Express vs Angular — no `inject()` inside loader bodies | -| [doc-environment-and-define-config-angular.md](doc-environment-and-define-config-angular.md) | **`generate-environment`**, **`CSDK_PUBLIC_*`**, Angular **`defineConfig`**; links **common** for `buildFallbackConfig` keys | -| [doc-components-and-placeholder-map.md](doc-components-and-placeholder-map.md) | Standalone components, map generation, placeholders | -| [doc-ssr-express-and-loader-middleware.md](doc-ssr-express-and-loader-middleware.md) | Express order: `json()` → loader middleware → static → SSR | -| [doc-sitecore-config-typescript-angular.md](doc-sitecore-config-typescript-angular.md) | Root **`sitecore.config.ts`**, **`getClient()`** / **`SitecoreClient`**; links **common** for config types + GraphQL | -| [doc-field-directives.md](doc-field-directives.md) | `scText`, `scRichText`, `scImage`, `scLink`, `scRouterLink` | -| [doc-editing-and-page-context-angular.md](doc-editing-and-page-context-angular.md) | `SitecoreContextService`, `isEditing`, content `editing` re-exports | -| [doc-multisite-angular-roadmap.md](doc-multisite-angular-roadmap.md) | Multisite: PDF TBA vs `resolveSitecorePage` options + JSDoc “future” | -| [doc-personalization-angular-roadmap.md](doc-personalization-angular-roadmap.md) | Personalization: PDF TBA vs client/config reality | -| [doc-i18n-angular.md](doc-i18n-angular.md) | **Stub:** locale on `Page`, `dictionaryLoader`; URL-segment i18n not implemented | -| [doc-isr-investigation-and-caching.md](doc-isr-investigation-and-caching.md) | **ISR investigation** — unstorage, POC approaches 2–4, mainline vs POC; decision TBD | -| [doc-loader-cache-plan.md](doc-loader-cache-plan.md) | **Loader cache plan** — Option 3 implementation: server-only module, persistence, on/off, prerender, invalidation | - -## Sources - -| Source | Location | -|--------|----------| -| JSS-Angular Live Design PDF (ingest 2026-05-14) | **`llm-wiki/raw/design/JSS-Angular-Live-Design-Doc-140526-211917.pdf`** | -| Text extract (same document) | `llm-wiki/raw/2026-05-14-jss-angular-live-design-architecture.md` | -| JSS-Angular ISR PDF (ingest 2026-05-22) | **`llm-wiki/raw/design/JSS-Angular-ISR-220526-175323.pdf`** | -| Text extract (ISR document) | `llm-wiki/raw/2026-05-22-jss-angular-isr-investigation.md` | - -## Code anchors - -- `packages/angular/src/` — package implementation -- `packages/create-content-sdk-app/src/templates/angular/` — generated app template -- `packages/angular/src/config/define-config.ts` — Angular `defineConfig` wrapper -- `packages/angular/src/server/loader-data-service-middleware.ts` — loader RPC middleware - -## Shared packages (common wiki) - -These topics live under **`@sitecore-content-sdk/content`** (and related packages); the **common** wiki is canonical: - -- [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — **`SitecoreConfigInput`**, merge pipeline -- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys (**`CSDK_PUBLIC_*`** vs **`NEXT_PUBLIC_*`** on this page and in that doc’s prefix table) -- [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **`createGraphQLClientFactory`**, **`SitecoreClient`**, **`getPage`** -- [../common/doc-component-map.md](../common/doc-component-map.md) — component map format, `GenerateMapArgs`, CLI generation -- [../common/doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) — platform name conventions - -## See also - -- [Common wiki index](../common/index.md) — shared packages diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md b/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md deleted file mode 100644 index b5dedb9261..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-architecture-edge-graphql.md +++ /dev/null @@ -1,31 +0,0 @@ -# Architecture: Experience Edge and GraphQL - -From official [Architecture overview](https://doc.sitecore.com/sai/en/developers/content-sdk/20/architecture-overview.html), aligned with this repo. - -**Runtime detail (all heads):** [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — how **`sitecore.config.ts`** + **`defineConfig`** drive Edge/local GraphQL and **`SitecoreClient`**. - -## Official doc - -- **Experience Edge** delivers layout and dictionary (and related) data via **GraphQL**. -- Doc may cite **`package.json`** → `config.graphQLEndpointPath`, default **`/sitecore/api/graph/edge`**. - -## Runtime (this repo) - -GraphQL URLs for **`SitecoreClient`** come from **`sitecore.config.ts`** via **`defineConfig`** and **env** — not from `package.json`. See [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) and [doc-sitecore-config.md](doc-sitecore-config.md). - -## Templates - -`package.json` may still list `graphQLEndpointPath` but it has no role. Treat **`sitecore.config` + env** as authoritative. - -## Implementation - -- **`@sitecore-content-sdk/core`** — `GraphQLClient`, factories, retry. -- **`@sitecore-content-sdk/content`** — `SitecoreClient`, layout/dictionary/editing services under `packages/content/src/client`, `packages/content/src/layout`, … - -## Mental model - -Authors compose pages in SitecoreAI. The head consumes **JSON** (GraphQL responses) from Edge or local GraphQL via **`SitecoreClient`**. Fetch wiring is **Next.js-specific** — Pages Router `getPage` / App Router `draftMode` / middleware. **`@sitecore-content-sdk/react`** renders typed layout data but does not replace **`SitecoreClient`**. - -## Raw - -- `llm-wiki/raw/2026-05-14-architecture-overview.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md b/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md deleted file mode 100644 index bf12fdbf8c..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-editor-integration-metadata.md +++ /dev/null @@ -1,81 +0,0 @@ -# Editor integration (metadata, Pages Router) - -Official: [Editor integration using metadata](https://doc.sitecore.com/sai/en/developers/content-sdk/20/editor-integration-using-metadata.html). Raw: `llm-wiki/raw/2026-05-14-editor-integration-using-metadata.md`. - -**Scope:** Next.js **Pages Router** or **App Router** + SitecoreAI **Page builder** — metadata on placeholders, renderings, fields for visual editing. - -## Head routes (template) - -| Role | Path | -|------|------| -| Render | `src/pages/api/editing/render.ts` — **`EditingRenderMiddleware`** | -| Config / metadata | `src/pages/api/editing/config.ts` — **`EditingConfigMiddleware`** | -| FEaaS | `src/pages/api/editing/feaas/render.ts` — **`FEAASRenderMiddleware`** (template extra; `next.config.js` rewrite `/feaas-render` → API) | -| Page | `src/pages/[[...path]].tsx` — preview vs `getPage` — [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | - -## Editing secret - -**`editingSecret`** / **`SITECORE_EDITING_SECRET`** — [doc-sitecore-config.md](doc-sitecore-config.md). Invalid `secret` on render route → **401**. - -## Preview / editing flow (short) - -1. Editor calls **`GET /api/editing/render?...`** (CORS, secret, required params). -2. **`EditingRenderMiddleware`** sets **Next preview data**, CSP, optional preview cookies for **`mode=preview`**, then **server fetch** of the catch-all route with preview cookies + **`x-sitecore-editing-params`** header + **`__content_sdk_preview`**. -3. Catch-all uses **`getPreview`** / **`getDesignLibraryData`** when `context.preview` is set — **`EditingService`** GraphQL with **`sc_editMode` / `sc_previewMode`** headers. - -## CORS (editing API routes) - -Sitecore **Pages editor** run on fixed **Sitecore cloud origins** and may call your app’s **`/api/editing/*`** routes from the browser. Those requests are **cross-origin**, so the editing handlers must validate the request’s **`Origin`** and return appropriate **`Access-Control-*`** headers (including **`OPTIONS`** preflight). - -### Default allowed origins - -The SDK ships a built-in list used for every editing handler that calls **`getEnforcedCorsHeaders`** with **`allowedOrigins: EDITING_ALLOWED_ORIGINS`**: - -```39:44:packages/content/src/editing/utils.ts -export const EDITING_ALLOWED_ORIGINS = [ - 'https://pages.sitecorecloud.io', - 'https://xmapps.sitecorecloud.io', - 'https://designlibrary.sitecorecloud.io', - 'https://app.sitecorecloud.io', -]; -``` -Custom **`JSS_ALLOWED_ORIGINS`** env needs to be set when connecting to staging or dev Sitecore AI deployments with non-default hostname. - -### `getEnforcedCorsHeaders` (`@sitecore-content-sdk/core/tools`) - -Implementation: **`packages/core/src/tools/utils.ts`**. It: - -1. Reads the request **`Origin`** header (supports both Node **`IncomingHttpHeaders`** and Fetch **`Headers`**). -2. Builds the effective allowlist from **three** sources (concatenated): **`JSS_ALLOWED_ORIGINS`** (comma-separated env list, spaces stripped), the **`allowedOrigins`** argument (above defaults), and an optional **`presetCorsHeader`** (e.g. an origin already set by Next config). -3. Accepts the request if **`Origin`** equals an entry **or** matches an entry treated as a **wildcard pattern** (`*` → `.*` in a regex anchored to the full string). -4. On success, returns headers including **`Access-Control-Allow-Origin`** set to the **request’s** `Origin` (echo), **`Access-Control-Allow-Methods`**: `GET, POST, OPTIONS, DELETE, PUT, PATCH`, **`x-middleware-cache`**: `no-cache`, **`Cache-Control`**: `no-store, must-revalidate`. For **`OPTIONS`**, it also adds **`Access-Control-Allow-Headers`**: `Content-Type, Authorization`. -5. If **`Origin`** is present but **not** allowed, returns **`null`** (callers respond with **401** and an HTML/plain message that the origin is not allowed). Debug logs mention **`JSS_ALLOWED_ORIGINS`** for operators extending the allowlist. -6. If there is **no** `Origin` header, the helper returns **`{}`** (empty object). Callers treat that as “no CORS failure from this check” but typically **do not** emit `Access-Control-Allow-*` from this path—same-origin or non-browser callers often have no `Origin`. - -### Where it is applied (Next.js) - -| Surface | Behavior | -|---------|----------| -| **`EditingRenderMiddleware`** (Pages API) | CORS checked **before** editing secret; **`OPTIONS`** → **204** with CORS headers; invalid origin → **401** JSON/html. | -| **`EditingConfigMiddleware`** | Same pattern: CORS first, then secret, then **`OPTIONS`** **204**. | -| **`FEAASRenderMiddleware`** | Same for **`/api/editing/feaas/render`** (**GET** / **OPTIONS** only after CORS). | -| **App Router** `createEditingRenderRouteHandlers` | **`GET`** / **`OPTIONS`** use the same **`getEnforcedCorsHeaders`** helper. **`POST`** (Design Library server-action proxy): CORS may be **bypassed** when the request target is **`localhost`** or **same host** as the request `Origin` (e.g. some Vercel/Netlify setups); otherwise invalid origin → **401**. Successful responses still merge CORS headers into the proxied response where applicable. | - -Operational note: set **`JSS_ALLOWED_ORIGINS`** (comma-separated origins, optional `*` wildcards per segment) when editors or previews hit your app from hosts **outside** the default Sitecore cloud list (e.g. custom staging URLs). - -## CSP / iframes - -Strict **`X-Frame-Options`** or **`frame-ancestors 'self'`** breaks Pages iframe — allow the Pages host. - -## Code - -- `packages/core/src/tools/utils.ts` — **`getEnforcedCorsHeaders`**, **`getAllowedOriginsFromEnv`** -- `packages/content/src/editing/utils.ts` — **`EDITING_ALLOWED_ORIGINS`** -- `packages/nextjs/src/editing/editing-render-middleware.ts`, `editing-config-middleware.ts`, `feaas-render-middleware.ts`, `editing/utils.ts` (CSP helpers) -- `packages/nextjs/src/route-handler/editing-render-route-handler.ts`, `editing-config-route-handler.ts` — App Router handlers - -## Related - -- [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) -- [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) -- [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — add **`JSS_ALLOWED_ORIGINS`** to `.env` when extending editing CORS beyond defaults diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md b/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md deleted file mode 100644 index e05842f8ea..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-example-environment-variable-files.md +++ /dev/null @@ -1,91 +0,0 @@ -# Example environment variable files - -From [Example environment variable files](https://doc.sitecore.com/sai/en/developers/content-sdk/20/example-environment-variable-files.html). Raw: `llm-wiki/raw/2026-05-14-example-environment-variable-files.md`. - -## Product intent - -- **`.env.*.example`** files document two **lifecycle**-oriented setups: **container** (local stack) vs **remote** (hosted Sitecore AI / Edge). -- Copy into **`.env.local`** for real values; never commit secrets into **`.example`** files. - -## Where they live in this monorepo - -### CLI templates (source only) - -Templates under **`packages/create-content-sdk-app/src/templates/`** are **scaffolding sources** for `create-content-sdk-app`. They include committed **`.env.*.example`** files so generated apps get the right shape—but the **template folders themselves are not runnable apps** (no in-repo install/dev workflow as a full application). - -| Head / router | Template path | Example env files (in repo) | -|-----------------|---------------|------------------------------| -| **Next.js — Pages Router** | `packages/create-content-sdk-app/src/templates/nextjs/` | `.env.container.example`, `.env.remote.example` | -| **Next.js — App Router** | `packages/create-content-sdk-app/src/templates/nextjs-app-router/` | `.env.container.example`, `.env.remote.example` | - -| Template file | When to use | -|---------------|-------------| -| `.env.container.example` | **Local development** against **Docker / local** Sitecore (or equivalent local images): local GraphQL, `NEXT_PUBLIC_SITECORE_API_HOST`, `NEXT_PUBLIC_SITECORE_API_KEY`, editing + default site/language. | -| `.env.remote.example` | **Remote / hosted Sitecore AI** — **Experience Edge** and IDs for a cloud tenant; variables such as `SITECORE_EDGE_CONTEXT_ID`, `NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`, optional `NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME`, Personalize timeouts/scope, optional Design Library auth. Tuned for **authoring and editing** against a remote instance; you can still run the app in dev against remote, but that path is oriented to **Pages / editor** workflows rather than “pure local stack” day one. | - -### Container vs remote (lifecycle) - -- **Container** — You run **local** Sitecore (commonly **Docker** images) while building the head. GraphQL and keys target that **on-machine** stack; this is the usual **inner-loop development** story. -- **Remote** — The head talks to a **remote Sitecore AI** tenant: **Experience Edge** hostname, **`SITECORE_EDGE_CONTEXT_ID`**, **`NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID`**, and related keys as listed in **`.env.remote.example`** for your template. That flow is **mainly for editing and authoring** in the cloud product, not for replacing local Docker—but **development can continue** against a remote instance when you intentionally point `.env.local` at that tenant (for example a shared dev environment or editor smoke tests). - -**Pages vs App Router:** both Next templates ship **`.env.container.example`** and **`.env.remote.example`** under the same **container vs remote** idea; individual variable names can differ—always copy from the **template** (or generated app) you actually use. - -### Scaffolded samples (local dev, diagnostics) - -For **local testing, diagnostics, and anything that needs a working app** (install, dev server, real `.env.local`), use **`samples/`**, not the template tree. - -1. From the **monorepo root**, run **`yarn scaffold-samples`**. That runs **`scripts/scaffold-samples.js`**, which reads **`scripts/samples.json`** and, for each entry, calls **`initialize`** from **`packages/create-content-sdk-app`** with **`destination`** set to **`./samples/<folder>/`** (folder name from **`scripts/utils.js`** **`getAppFolder`**, e.g. **`sample-nextjs-SSG`** when `template` is **`nextjs`** and **`prerender`** is **`SSG`**). -2. The scaffold **copies/transforms** the chosen template into that **`samples/<folder>/`** app. That app is a **normal generated head**: copy **`.env.container.example`** or **`.env.remote.example`** to **`.env.local`**, install deps, run **`dev`** / **`start`**, use **`yarn lint-samples`** for CI-style lint of scaffolded apps. -3. **`samples/`** is listed in **`.gitignore`**—it is **not** checked in. Each developer (or CI job) regenerates samples when needed. - -**Summary:** **Templates** = canonical **examples** and CLI input. **`samples/*`** = disposable **runnable** copies for monorepo local dev; put env files and runtime diagnostics there. - -### Template watch mode (`yarn watch`) - -For **template authors** iterating on files under `src/templates/`: detects changes with **chokidar** and re-scaffolds the destination sample automatically. - -**Prerequisite:** Create **`watch.json`** in `packages/create-content-sdk-app/` (gitignored at package level): - -```json -{ - "template": "<template-name>", - "args": { - "yes": true, - "force": true, - "silent": false, - "appName": "<app-name>", - "destination": "..\\..\\samples\\<folder>", - "prerender": "SSG" - } -} -``` - -| Field | Notes | -|-------|-------| -| `template` | `"nextjs"`, `"nextjs-app-router"`, or `"angular"` | -| `args.destination` | Relative to `packages/create-content-sdk-app/`; typically `"../../samples/<folder>"` | -| `args.force` | Must be `true` — overwrites destination on every re-scaffold | -| `args.prerender` | `"SSG"` or `"SSR"` | - -**Run:** from `packages/create-content-sdk-app/`, run **`yarn watch`** (`ts-node ./scripts/watch-templates.ts`). - -**Lifecycle:** - -1. **Startup (`ready`):** scaffolds the sample once with a full `yarn install` in the destination. -2. **On any `src/templates/` file change:** re-scaffolds with `noInstall: true` — skips `yarn install` to keep the loop fast. -3. **After each scaffold:** `restoreLockfile` checks `git status`; if `yarn.lock` was modified, it runs `git restore ../../yarn.lock` so the sample's install changes do not pollute the monorepo lock file. - -**Env files in the sample:** copy `.env.container.example` or `.env.remote.example` to `.env.local` inside `samples/<folder>/` after the first scaffold. Re-scaffolds overwrite app source files but not `.env.local` (it is not a template file). - -**Script:** `packages/create-content-sdk-app/scripts/watch-templates.ts` - -## Relationship to `sitecore.config.ts` - -`defineConfig` / **`buildFallbackConfig`** read the same logical settings from **`process.env`** (and Next’s **`getNextFallbackConfig`** layers **`NEXT_PUBLIC_*`**). Keeping **`.env.local`** and **`sitecore.config.ts`** in sync (especially **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** ↔ **`defaultLanguage`**, site name, Edge IDs) avoids subtle mismatches. See [doc-sitecore-config.md](doc-sitecore-config.md). For another head in this monorepo, open the **[Angular wiki index](../content-sdk-angular/index.md)** (public env uses **`CSDK_PUBLIC_*`** and **`generate-environment.ts`**). - -## Related - -- [doc-sitecore-config.md](doc-sitecore-config.md) -- [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) -- [doc-i18n-multilingual.md](doc-i18n-multilingual.md) — `NEXT_PUBLIC_DEFAULT_LANGUAGE` and Next `i18n.defaultLocale` -- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) — **`JSS_ALLOWED_ORIGINS`** for editing CORS diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md b/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md deleted file mode 100644 index b9765855e1..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-graphql-client-and-edge-urls.md +++ /dev/null @@ -1,15 +0,0 @@ -# GraphQL client factory and Edge URLs (Next hub) - -Canonical **code-truth** for **`createGraphQLClientFactory`**, Edge vs local URLs, and **`SitecoreClient`** construction lives in the **common** wiki: - -- **[../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md)** - -## Next-only addition - -**Dev proxy:** `packages/nextjs/src/proxy/proxy.ts` — used in Next workflows alongside the shared factory. - -## Related - -- [doc-sitecore-config.md](doc-sitecore-config.md) — Next **`defineConfig`** pipeline -- [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) — official doc alignment -- [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) — **`SitecoreNextjsClient`** extensions diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md b/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md deleted file mode 100644 index 9552d6a883..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-i18n-multilingual.md +++ /dev/null @@ -1,124 +0,0 @@ -# Multilingual / i18n (Next.js) - -Official references: - -- [Supporting multilingual applications in Content SDK](https://doc.sitecore.com/sai/en/developers/content-sdk/20/supporting-multilingual-applications-in-content-sdk.html) — `llm-wiki/raw/2026-05-14-supporting-multilingual-applications.md` -- [Internationalization using next-intl](https://doc.sitecore.com/sai/en/developers/content-sdk/20/internationalization-using-next-intl.html) — **`App Router` + `next-intl`** — `llm-wiki/raw/2026-05-14-internationalization-using-next-intl.md` - -This wiki page contrasts **App Router (doc)** with **Pages Router (template code)** in this repo. - ---- - -## App Router template (official — `next-intl`) - -The SAI doc describes the **Next.js App Router** starter: - -- **`src/i18n/routing.ts`** — `defineRouting({ locales, defaultLocale, localePrefix })`; **`defaultLocale`** typically tied to **`sitecore.config`** `defaultLanguage`. -- **`src/i18n/request.ts`** — per-request locale + **Sitecore dictionary** for server components. -- Route shape **`[site]/[locale]/[[...path]]`**, **`localeMiddleware`** first in **`middleware.ts`**, **`generateStaticParams`** for SSG site×locale. -- Components: **`getTranslations` / `getLocale`** (async server), **`useTranslations` / `useLocale`** (server/client), **`NextIntlClientProvider`** for client subtree. - -Details and examples: see the **raw snapshot** above; product examples may contain typos (“Dafault”) — treat code in **`packages/create-content-sdk-app/src/templates/nextjs-app-router/`** as source of truth for App Router. - ---- - -## Pages Router template — code path (no `next-intl`) - -**Template:** `packages/create-content-sdk-app/src/templates/nextjs/`. - -### 1. Next.js `i18n` config (`next.config.js`) - -Next’s **built-in i18n routing** (not `next-intl`): - -```16:23:packages/create-content-sdk-app/src/templates/nextjs/next.config.js - i18n: { - // These are all the locales you want to support in your application. - // These should generally match (or at least be a subset of) those in Sitecore. - locales: ['en'], - // This is the locale that will be used when visiting a non-locale - // prefixed path e.g. `/about`. - defaultLocale: process.env.DEFAULT_LANGUAGE || process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || 'en', - }, -``` - -- Extend **`locales`** to match Sitecore languages you publish. -- **`defaultLocale`** reads **`DEFAULT_LANGUAGE`** or **`NEXT_PUBLIC_DEFAULT_LANGUAGE`**, else **`en`** — keep aligned with **`sitecore.config.ts`** **`defaultLanguage`** / **`defineConfig`**. - -### 2. Data fetching: locale on `SitecoreClient` (`[[...path]].tsx`) - -Catch-all **`getStaticProps` / `getServerSideProps`**: - -- **`extractPath(context)`** (`@sitecore-content-sdk/nextjs/utils`) returns the **Sitecore item path** from **`context.params.path`** only — it does **not** parse locale out of the path; locale comes from **`context.locale`** provided by Next when i18n is enabled. - -```57:63:packages/nextjs/src/utils/utils.ts -export const extractPath = (context: GetStaticPropsContext | GetServerSidePropsContext) => { - return context.params === undefined - ? '/' - : Array.isArray(context.params.path) - ? context.params.path.join('/') - : context.params.path ?? '/'; -}; -``` - -- **`getPage(path, { locale: context.locale })`** — layout GraphQL uses the active locale. -- **`getDictionary({ site: page.siteName, locale: page.locale })`** — dictionary phrases for that site/language pair after the page resolves. - -```86:104:packages/create-content-sdk-app/src/templates/nextjs/src/pages/[[...path]].tsx - const path = extractPath(context); - let page; - // ... - : await client.getPage(path, { locale: context.locale }); - // ... - dictionary: await client.getDictionary({ - site: page.siteName, - locale: page.locale, - }), -``` - -- **SSG `getStaticPaths`**: passes **`context?.locales || []`** into **`client.getPagePaths`** so static paths can be generated per Next locale when configured. - -### 3. Client dictionary provider (`_app.tsx`) - -**`next-localization`** wraps the tree with **`I18nProvider`** (rosetta-backed), not `next-intl`: - -```13:24:packages/create-content-sdk-app/src/templates/nextjs/src/pages/_app.tsx - <I18nProvider - lngDict={dictionary} - locale={pageProps.page?.locale || scConfig.defaultLanguage} - > - <Component {...rest} /> - </I18nProvider> -``` - -- **`dictionary`** comes from **`getStaticProps` / `getServerSideProps`** props (Sitecore dictionary service). -- **`locale`** falls back to **`scConfig.defaultLanguage`** if the page object has no locale. - -### 4. Error pages - -**`404.tsx` / `500.tsx`** use **`context.locale`**, **`context.defaultLocale`**, then **`scConfig.defaultLanguage`** for `getPage` / dictionary — same alignment pattern as the catch-all. - -### 5. Sitecore config / redirects - -- **`defaultLanguage`** / **`defaultSite`** in **`sitecore.config.ts`** should match how Next resolves locale and how **`getDictionary`** is called. -- **`redirects.locales`** in Sitecore config should stay consistent with **`next.config.js`** **`locales`** for redirect middleware (see [doc-sitecore-config.md](doc-sitecore-config.md)). - ---- - -## Sitecore side (both templates) - -- Layout GraphQL respects **language**. -- Dictionary is fetched via **`SitecoreClient.getDictionary`** (GraphQL-backed in **`@sitecore-content-sdk/content`**). - ---- - -## Env files - -See [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — **`NEXT_PUBLIC_DEFAULT_LANGUAGE`** is documented in **`.env.*.example`**. - ---- - -## Related - -- [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) -- [doc-sitecore-config.md](doc-sitecore-config.md) -- Skill (template): `packages/create-content-sdk-app/src/templates/nextjs/.agents/skills/content-sdk-dictionary-and-i18n/SKILL.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md b/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md deleted file mode 100644 index 1af51725d3..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-page-composition-placeholders.md +++ /dev/null @@ -1,122 +0,0 @@ -# Page composition and placeholders - -From [Page composition in Content SDK apps using SitecoreAI data](https://doc.sitecore.com/sai/en/developers/content-sdk/20/page-composition-in-content-sdk-apps-using-sitecoreai-data.html) plus templates in `packages/create-content-sdk-app/src/templates/nextjs*`. - -## Authoring vs runtime - -1. **SitecoreAI** — authors compose pages in WYSIWYG; **placeholders** nest **renderings** (components). -2. **App** — root **`Layout`** with a **root placeholder** whose name matches SitecoreAI. -3. **Runtime** — layout arrives as **JSON** from **GraphQL** (Edge or local) via **`SitecoreClient`** / layout service. - -## Developer constraints - -- Placeholder keys must match authoring. -- Rendering names map to **registered** front-end components (`.sitecore/component-map.ts`). -- **Dynamic placeholders** — supported per product doc; keep names in sync. - -## `Placeholder` vs `AppPlaceholder` (React / Next) - -Both ultimately render the same **placeholder resolution** pipeline (`getPlaceholderRenderings`, component map, editing metadata). The split is **where context comes from** and **whether the tree can run as a React Server Component (RSC)**. - -### `AppPlaceholder` (`@sitecore-content-sdk/react`) - -- **No `use client`** on the module: safe to import from **server components** when the build wires **`#rsc-env`** for App Router. -- **Requires** explicit **`page`** and **`componentMap`** props (`AppPlaceholderProps`); it does **not** call **`useSitecore()`**. -- Uses **`rsc`** from **`#rsc-env`**: when **`rsc`** is true and a mapped component is a **client** component, it wraps the child in **`ClientComponentWrapper`** so the server placeholder can host client leaves without illegal boundary crossing. -- In **editing** mode, wraps output in **`PlaceholderMetadata`** for Pages chromes / hydration markers. - -**Typical use:** **Next.js App Router** root **`Layout.tsx`** in the template (`packages/create-content-sdk-app/src/templates/nextjs-app-router/src/Layout.tsx`) — default **Layout is a server component**; it renders **`<AppPlaceholder page={page} componentMap={componentMap} name="…" rendering={route} />`**. Server-only editing surfaces such as **`DesignLibraryServer`** also use **`AppPlaceholder`**. - -### `Placeholder` (React — `@sitecore-content-sdk/react`) - -- Declares **`'use client'`** and uses **`useSitecore()`** to obtain **`page`** and **`componentMap`** when callers omit them (Pages-style apps rely on **`SitecoreProvider`**). -- Runs a **`useEffect`** that calls **`PagesEditor.resetChromes()`** when the placeholder is empty and the Pages editor is active (client-only editor UX). -- Delegates rendering to **`<AppPlaceholder {...appProps} />`** after merging props. - -```1:34:packages/react/src/components/Placeholder/Placeholder.tsx -'use client'; -import React, { useEffect } from 'react'; -import { PlaceholderProps } from './models'; -import { PagesEditor } from '@sitecore-content-sdk/content/editing'; -import { getPlaceholderRenderings } from './placeholder-utils'; -import { useSitecore } from '../SitecoreProvider'; -import { AppPlaceholder } from './AppPlaceholder'; -// ... -export const Placeholder = (props: PlaceholderProps) => { - const { page, componentMap } = useSitecore(); - // ... - const appProps = { ...props, page, componentMap }; - - return <AppPlaceholder {...appProps} />; -}; -``` - -**Typical use:** **Pages Router** template **`Layout.tsx`** imports **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** and passes **`name`** + **`rendering`** only; **`SitecoreProvider`** supplies **`page`** / **`componentMap`**. Nested placeholders inside **client** route trees use the same **`Placeholder`**. - -### `Placeholder` (Next.js — `@sitecore-content-sdk/nextjs`) - -- Also **`'use client'`**; wraps the React **`Placeholder`** and merges **`getComponentData`** output from **`ComponentPropsReactContext`** into each child’s props via **`modifyComponentProps`**. - -```1:38:packages/nextjs/src/components/Placeholder.tsx -'use client'; -import React, { useContext } from 'react'; -import { - Placeholder as ReactPlaceholder, - PlaceholderComponentProps, - EnhancedOmit, - SitecoreProviderState, -} from '@sitecore-content-sdk/react'; -import { ComponentPropsReactContext } from './ComponentPropsContext'; -// ... -export const Placeholder = (props: PlaceholderProps) => { - const componentPropsContext = useContext(ComponentPropsReactContext); - - return ( - <ReactPlaceholder - {...props} - modifyComponentProps={(initialProps) => { - if (!initialProps.rendering.uid) return initialProps; - const data = componentPropsContext[initialProps.rendering.uid] as { - [key: string]: unknown; - }; - - return { ...initialProps, ...data }; - }} - /> - ); -}; -``` - -**Typical use:** **Pages Router** only (Next-specific component props hydration). App Router template uses **`AppPlaceholder`** from **`@sitecore-content-sdk/nextjs`** directly, not this wrapper. - -### HOCs - -| HOC | Declares client? | Inner component | -|-----|------------------|-----------------| -| **`withPlaceholder`** | **`'use client'`** | React **`Placeholder`** (optional `page` / `componentMap` from props or context) | -| **`withAppPlaceholder`** | No **`use client`** on the module | **`AppPlaceholder`** with required **`page`** / **`componentMap`** on the wrapper props | - -### Quick matrix - -| Stack | Root layout pattern | Component | -|--------|----------------------|-----------| -| **Pages Router** (template) | Client/SSR page tree with **`SitecoreProvider`** | **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** | -| **App Router** (template) | Server **`Layout`**; **`Providers`** (`'use client'`) wraps **`SitecoreProvider`** around children, but root placeholders still use explicit props | **`AppPlaceholder`** from **`@sitecore-content-sdk/nextjs`** | -| **RSC / server-only branches** | Must pass **`page`** + **`componentMap`** | **`AppPlaceholder`** | -| **Client subtrees** (hooks, chromes reset, Next `getComponentData` merge) | Under **`SitecoreProvider`** | **`Placeholder`** from **`@sitecore-content-sdk/nextjs`** (App Router) or React **`Placeholder`** (any app using provider only) | - -## Code anchors - -- `packages/react` — **`Placeholder`**, **`AppPlaceholder`**, **`placeholder-utils`**, **`withPlaceholder`**, **`withAppPlaceholder`** -- `packages/nextjs` — Next **`Placeholder`**, **`ComponentPropsReactContext`**, editing, `getComponentData`, App Router helpers -- Templates — Pages **`Layout.tsx`** (`Placeholder`); App Router **`Layout.tsx`** (`AppPlaceholder`); `[[...path]].tsx` / `[[...path]]/page.tsx` - -## Related - -- [../common/doc-component-map.md](../common/doc-component-map.md) — component map format and CLI generation (shared contract) -- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) -- [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) - -## Raw - -- `llm-wiki/raw/2026-05-14-page-composition-sitecoreai-data.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md b/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md deleted file mode 100644 index b8073ead48..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-plugins-and-adapters.md +++ /dev/null @@ -1,35 +0,0 @@ -# Plugins and adapters (Next.js) - -**Scope: Next.js head only.** The Angular head does not use this plugin system. For Angular bootstrap, start at the **[Angular wiki index](../content-sdk-angular/index.md)**. - -Official: [Plugins](https://doc.sitecore.com/sai/en/developers/content-sdk/20/plugins.html) · [Adapters](https://doc.sitecore.com/sai/en/developers/content-sdk/20/adapters.html). Raw: `llm-wiki/raw/2026-05-14-plugins.md`, `2026-05-14-adapters.md`. - -## Initialization (`initContentSdk`) - -**`initContentSdk`** (`packages/core/src/initialization/init-content-sdk.ts`) is called from **`Bootstrap.tsx`** in Next.js templates. It: - -1. Resolves core context from `{ contextId, edgeUrl, siteName }`. -2. Registers all supplied plugins into an internal map keyed by plugin name. -3. Calls each plugin's `init()` function (if present) asynchronously and awaits completion. - -Called from: `packages/create-content-sdk-app/src/templates/nextjs/src/Bootstrap.tsx`. - -## Plugins - -Declarative typed extensions with `name`, `options`, `dependencies`, optional `init`, and optional `adapter`. - -## Built-in stack - -| Plugin | Role | Package | -|--------|------|---------| -| `analyticsPlugin` | Client ID + shared analytics init; base for events/personalize | `@sitecore-content-sdk/analytics-core` | -| `eventsPlugin` | Page view / custom events | `@sitecore-content-sdk/events` | -| `personalizeBrowserPlugin` / `personalizeServerPlugin` | Personalization | `@sitecore-content-sdk/personalize` | - -## Adapters - -Environment-specific implementations for plugins (browser vs server: cookies, headers, location). Analytics adapters extend **`PluginAdapter`** / **`AnalyticsAdapter`** from **`@sitecore-content-sdk/core`**. - -## Related - -- [doc-sitecore-config.md](doc-sitecore-config.md) — personalize block diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md b/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md deleted file mode 100644 index 9a222d14b7..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-route-handling-data-fetching.md +++ /dev/null @@ -1,26 +0,0 @@ -# Route handling and data fetching - -Official: [Route handling and data fetching](https://doc.sitecore.com/sai/en/developers/content-sdk/20/route-handling-and-data-fetching-in-content-sdk-apps.html). Raw: `llm-wiki/raw/2026-05-14-route-handling-data-fetching.md`. - -## Model - -1. **Content tree → URLs** — hierarchy drives URLs; multisite hostnames; Sitecore URL rules need front-end coordination. -2. **Route resolution** — Next catch-all / App Router dynamic segments; path → Sitecore route. -3. **Data fetch** — **`SitecoreClient`** + GraphQL ([../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md)); config from **`sitecore.config`** / **`defineConfig`**. -4. **Rendering** — JSON → **Placeholders** / components; **`getComponentData`** for props. - -### Documentation note (LayoutService path) - -Official doc may link **`packages/core/.../layout-service.ts`**. In **this repo**, layout GraphQL is **`packages/content/src/layout/layout-service.ts`**, consumed by **`SitecoreClient`** (`packages/content/src/client/sitecore-client.ts`). - -## Templates (Next) - -- **Pages Router:** `src/pages/[[...path]].tsx` — `extractPath`, `context.preview` → `getPreview` / `getDesignLibraryData` / `getPage`, `getDictionary`, `getComponentData`. -- **App Router:** `src/app/[site]/[locale]/[[...path]]/page.tsx` — **`draftMode()`**, **`getPreviewData(headers)`**, same client branches. - -## Related - -- [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) -- [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) -- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) -- [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) — Next dev proxy pointer diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md deleted file mode 100644 index 3dfd4b2c9b..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-client-apis.md +++ /dev/null @@ -1,13 +0,0 @@ -# SitecoreClient — services and APIs - -Official hub: [Content SDK Services and APIs](https://doc.sitecore.com/sai/en/developers/content-sdk/20/content-sdk-services-and-apis.html). - -**Shared (all heads):** [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — **`SitecoreClient`** role, construction, **`createGraphQLClientFactory`**, **`BaseSitecoreClient`** method table, GraphQL branching. - -## Next: `SitecoreNextjsClient` - -`packages/nextjs/src/client/sitecore-nextjs-client.ts` — **`parsePath`** (site + personalization rewrites), **`getPage`** (site from path, personalization), **`getComponentData`**, **`getAppRouterStaticParams`**, **`getPreviewData(headers)`** (App Router; reads **`x-sitecore-editing-params`**). - -## Raw - -- `llm-wiki/raw/2026-05-14-content-sdk-services-and-apis.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md b/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md deleted file mode 100644 index baa27d5ccb..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-sitecore-config.md +++ /dev/null @@ -1,48 +0,0 @@ -# Sitecore configuration (`sitecore.config.ts`) - -Synthesized from official [The Sitecore configuration file](https://doc.sitecore.com/sai/en/developers/content-sdk/20/the-sitecore-configuration-file.html) (tables in `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md`) and **`SitecoreConfigInput`** in `packages/content/src/config/models.ts`. - -**Shared reference (all heads):** [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) — full **`SitecoreConfigInput`** tables, merge pipeline, **`api.edge` / `api.local`**, CLI config. [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) — **`buildFallbackConfig`** env keys. [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) — GraphQL URL selection and **`SitecoreClient`**. - -## Where it lives - -- Generated apps: root **`sitecore.config.ts`**. -- Templates: `packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts`, `nextjs-app-router/sitecore.config.ts`. - -## Next.js resolution pipeline - -1. **`defineConfig`** from **`@sitecore-content-sdk/nextjs/config`** (`packages/nextjs/src/config/define-config.ts`) runs **`getNextFallbackConfig`**: merges **`NEXT_PUBLIC_*`**, **`VERCEL_ENV === 'preview'`** for multisite cookie resolution, **`GENERATE_STATIC_PATHS`**, **`SITECORE_INTERNAL_EDITING_HOST_URL`** (see `packages/nextjs/src/config/define-config.ts` for the complete list). -2. Passes merged env to content **`defineConfig`** — the shared `buildFallbackConfig` → `deepMerge` → `resolveEdgeUrl` → CLI validation pipeline applies. See [common merge pipeline](../common/doc-sitecore-config-input.md). - -## Next-only `SitecoreConfigInput` fields - -(`packages/nextjs/src/config/define-config.ts`) - -| Key | Type | Purpose | -|-----|------|---------| -| `generateStaticPaths` | `boolean?` | SSG path prebuild; env **`GENERATE_STATIC_PATHS`** overrides; default **true** if unset. | -| `sitecoreInternalEditingHostUrl` | `string?` | Base URL for editing middleware internal fetch; env **`SITECORE_INTERNAL_EDITING_HOST_URL`**. | - -## Multisite (Next App Router) - -When using the **`[site]`** segment pattern, keep **`multisite.enabled`** consistent with routing expectations — disabling it can break site resolution for that layout. Cookie resolution defaults may change under preview (`VERCEL_ENV`); see **`getNextFallbackConfig`** in `packages/nextjs/src/config/define-config.ts`. - -## Code as source of truth - -| Need | Path | -|------|------| -| Types | `packages/content/src/config/models.ts` | -| Fallback + merge | `packages/content/src/config/define-config.ts` | -| Next wrapper | `packages/nextjs/src/config/define-config.ts` | -| GraphQL URL / `SitecoreClient` | [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | - -## Related - -- [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) — `.env.*.example` vs `defineConfig` / env. -- [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) — `editingSecret`, render host. -- [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) -- [../content-sdk-angular/index.md](../content-sdk-angular/index.md) — other heads in this monorepo (start here for Angular) - -## Raw - -- `llm-wiki/raw/2026-05-14-the-sitecore-configuration-file.md` diff --git a/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md b/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md deleted file mode 100644 index 97fd25af2e..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/doc-terminology-platform-names.md +++ /dev/null @@ -1,5 +0,0 @@ -# Platform naming (moved) - -**Canonical page:** [../common/doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) - -This stub remains so older links under `content-sdk-nextjs/` still resolve. All new links should target the **common** wiki. diff --git a/llm-wiki/wiki/content-sdk-nextjs/index.md b/llm-wiki/wiki/content-sdk-nextjs/index.md deleted file mode 100644 index 0197c42058..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/index.md +++ /dev/null @@ -1,49 +0,0 @@ -# Content SDK — Next.js wiki index - -Catalog of **Next.js head** pages (`@sitecore-content-sdk/nextjs`, Pages/App Router templates, editing). **Update this file** when you add, rename, or materially change pages here. - -## Meta - -| Page | Summary | -|------|---------| -| [../index.md](../index.md) | Wiki root hub (all stacks) | -| [../log.md](../log.md) | Append-only timeline (repo-wide) | -| [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | Bibliography for 2026-05-14 official doc batch + follow-up ingests | - -## Overview - -| Page | Summary | -|------|---------| -| [overview-content-sdk.md](overview-content-sdk.md) | Monorepo purpose, package map, doc topic map, head-app vs SDK scope | -| [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) | **SAI / Sitecore AI / XMC / XM Cloud** — interchangeable names in docs and comments | - -## Official docs (SAI 2.x) — synthesized - -| Page | Summary | -|------|---------| -| [doc-sitecore-config.md](doc-sitecore-config.md) | `sitecore.config.ts` + Next **`defineConfig`** / **`getNextFallbackConfig`**; links to **common** for full **`SitecoreConfigInput`**, env keys, GraphQL + `SitecoreClient` | -| [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) | Experience Edge, GraphQL; runtime vs `package.json` doc note; points to **common** for implementation | -| [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) | Next hub → **common** canonical factory doc; Next dev proxy pointer | -| [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | Authoring vs GraphQL JSON; **`Placeholder`** vs **`AppPlaceholder`** (Pages vs App Router, RSC); component map | -| [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | Catch-all, `getPage` / preview / `getComponentData`; LayoutService path under `packages/content` | -| [doc-i18n-multilingual.md](doc-i18n-multilingual.md) | i18n: App Router `next-intl` (raw) + **Pages Router** code (`next.config` i18n, `extractPath`, `getDictionary`, `next-localization`) | -| [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | `.env` examples: **Pages + App Router** template paths; **`yarn scaffold-samples`** → **`samples/`** for runnable local dev | -| [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) | **Next** `SitecoreNextjsClient` extensions; **common** for base `SitecoreClient` + GraphQL | -| [doc-plugins-and-adapters.md](doc-plugins-and-adapters.md) | Plugins, adapters, analytics / personalize stack | -| [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | Page builder, editing API routes, preview, FEaaS, **CORS** (`getEnforcedCorsHeaders`, `JSS_ALLOWED_ORIGINS`), CSP | - -## Concepts & flows - -| Page | Summary | -|------|---------| -| [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | HTTP / GraphQL endpoint selection (all heads) | - -## Source notes - -| Page | Summary | -|------|---------| -| [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | URLs → `raw/` + these wiki pages | - ---- - -**Convention:** Relative links within this folder. Shared package topics: [../common/index.md](../common/index.md). diff --git a/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md b/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md deleted file mode 100644 index ef874f6af4..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/overview-content-sdk.md +++ /dev/null @@ -1,52 +0,0 @@ -# Overview: Sitecore Content SDK monorepo - -> When this diverges from code, **update this page** to match code and note drift in `../log.md`. - -## Platform naming (read this first) - -**Sitecore AI**, **SitecoreAI**, **SAI**, **XM Cloud**, **Sitecore XM Cloud**, and **XMC** (in URLs and comments) refer to the **same** platform context for Content SDK work in this repo. See [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md). - -## Purpose - -This repository ships **TypeScript packages**, a **scaffolding CLI** (`create-content-sdk-app`), and **templates** for building applications on **SitecoreAI / XM Cloud**. Consumer applications are generated from templates and depend on `@sitecore-content-sdk/*`. - -## Doc topic map (ingested 2026-05-14) - -| Topic | Wiki | -|--------|------| -| Naming | [doc-terminology-platform-names.md](../common/doc-terminology-platform-names.md) | -| Config types (all heads) | [../common/doc-sitecore-config-input.md](../common/doc-sitecore-config-input.md) | -| Env fallbacks / `buildFallbackConfig` (all heads) | [../common/doc-config-environment-variables.md](../common/doc-config-environment-variables.md) | -| `SitecoreClient` + GraphQL factory (all heads) | [../common/doc-sitecore-client-and-graphql.md](../common/doc-sitecore-client-and-graphql.md) | -| Config / env (Next) | [doc-sitecore-config.md](doc-sitecore-config.md) | -| Edge + GraphQL | [doc-architecture-edge-graphql.md](doc-architecture-edge-graphql.md) | -| GraphQL client factory (Next hub) | [doc-graphql-client-and-edge-urls.md](doc-graphql-client-and-edge-urls.md) | -| Layout / placeholders | [doc-page-composition-placeholders.md](doc-page-composition-placeholders.md) | -| Next data fetching | [doc-route-handling-data-fetching.md](doc-route-handling-data-fetching.md) | -| i18n + dictionary | [doc-i18n-multilingual.md](doc-i18n-multilingual.md) | -| Example `.env` files | [doc-example-environment-variable-files.md](doc-example-environment-variable-files.md) | -| SitecoreClient APIs | [doc-sitecore-client-apis.md](doc-sitecore-client-apis.md) | -| Plugins + adapters | [doc-plugins-and-adapters.md](doc-plugins-and-adapters.md) | -| Page builder / editing | [doc-editor-integration-metadata.md](doc-editor-integration-metadata.md) | -| Ingest bibliography | [source-ingest-2026-05-14-official-docs.md](source-ingest-2026-05-14-official-docs.md) | - -## Package map (high level) - -| Package | Responsibility | -|---------|----------------| -| `core` | GraphQL client, cache, retry, fetch | -| `analytics-core` | Analytics foundation | -| `content` | Layout, editing, site, media, `SitecoreClient` | -| `search` | Search APIs | -| `events` | Event tracking | -| `personalize` | Personalization | -| `cli` | `sitecore-tools` | -| `create-content-sdk-app` | Scaffolding + templates | -| `nextjs` | Next integration, middleware, editing | -| `react` | Text, Image, Placeholder, … | - -## Key repo locations - -- Sources: `packages/<name>/src/**` -- Templates: `packages/create-content-sdk-app/src/templates/**` -- LLM raw snapshots: `llm-wiki/raw/` diff --git a/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md b/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md deleted file mode 100644 index e85ebb3cb3..0000000000 --- a/llm-wiki/wiki/content-sdk-nextjs/source-ingest-2026-05-14-official-docs.md +++ /dev/null @@ -1,22 +0,0 @@ -# Official doc ingest — 2026-05-14 - -**Initial batch:** nine SitecoreAI Content SDK **2.x** URLs. **Follow-up:** editor integration (metadata); example env files; next-intl (App Router) + i18n wiki merge. - -| # | Topic | Raw snapshot | Wiki synthesis | -|---|--------|----------------|-----------------| -| 1 | Content SDK for SitecoreAI | `raw/2026-05-14-sitecore-content-sdk-for-sitecoreai.md` | `content-sdk-nextjs/overview-content-sdk.md` | -| 2 | Sitecore configuration file | `raw/2026-05-14-the-sitecore-configuration-file.md` | `content-sdk-nextjs/doc-sitecore-config.md` | -| 3 | Architecture overview | `raw/2026-05-14-architecture-overview.md` | `content-sdk-nextjs/doc-architecture-edge-graphql.md` | -| 4 | Page composition | `raw/2026-05-14-page-composition-sitecoreai-data.md` | `content-sdk-nextjs/doc-page-composition-placeholders.md` | -| 5 | Route handling & data fetching | `raw/2026-05-14-route-handling-data-fetching.md` | `content-sdk-nextjs/doc-route-handling-data-fetching.md` | -| 6 | Multilingual | `raw/2026-05-14-supporting-multilingual-applications.md` | `content-sdk-nextjs/doc-i18n-multilingual.md` | -| 7 | Services and APIs | `raw/2026-05-14-content-sdk-services-and-apis.md` | `content-sdk-nextjs/doc-sitecore-client-apis.md` | -| 8 | Plugins | `raw/2026-05-14-plugins.md` | `content-sdk-nextjs/doc-plugins-and-adapters.md` | -| 9 | Adapters | `raw/2026-05-14-adapters.md` | `content-sdk-nextjs/doc-plugins-and-adapters.md` | -| 10 | Editor integration using metadata | `raw/2026-05-14-editor-integration-using-metadata.md` | `content-sdk-nextjs/doc-editor-integration-metadata.md` | -| 11 | Example environment variable files | `raw/2026-05-14-example-environment-variable-files.md` | `content-sdk-nextjs/doc-example-environment-variable-files.md` | -| 12 | Internationalization using next-intl | `raw/2026-05-14-internationalization-using-next-intl.md` | `content-sdk-nextjs/doc-i18n-multilingual.md` (merged with Pages Router code) | - -**Code-truth supplements:** `doc-sitecore-config.md` (Next `defineConfig` pipeline; full **`SitecoreConfigInput`** / env / GraphQL in **`../common/`**), `doc-graphql-client-and-edge-urls.md` (Next hub → **`../common/doc-sitecore-client-and-graphql.md`**), `doc-sitecore-client-apis.md` (Next `SitecoreNextjsClient`; base client in **common**), architecture + editor pages (template vs doc deltas). - -**Catalog:** [content-sdk-nextjs/index.md](index.md) diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md deleted file mode 100644 index 144bb15b08..0000000000 --- a/llm-wiki/wiki/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# LLM Wiki hub - -Agent-maintained markdown under `llm-wiki/wiki/`, split by **head stack** and **shared** concepts. - -| Folder | Scope | Start here | -|--------|--------|------------| -| **[content-sdk-nextjs/](content-sdk-nextjs/index.md)** | **Content SDK Next.js** — `@sitecore-content-sdk/nextjs`, templates, editing, routing, i18n, doc synthesis | [content-sdk-nextjs/index.md](content-sdk-nextjs/index.md) | -| **[common/](common/index.md)** | **Shared** — `SitecoreConfigInput`, env / `buildFallbackConfig`, `SitecoreClient` + GraphQL factory (`packages/content`) | [common/index.md](common/index.md) | -| **[content-sdk-angular/](content-sdk-angular/index.md)** | **Content SDK Angular** — `@sitecore-content-sdk/angular`, Angular template, loaders/SSR architecture (ingested design doc) | [content-sdk-angular/index.md](content-sdk-angular/index.md) | - -## Repo-wide meta - -| | | -|--|--| -| [log.md](log.md) | Append-only ingest / query / lint log for **all** wiki areas | -| [wiki-boundary-and-token-audit.md](wiki-boundary-and-token-audit.md) | Boundary rules (Next vs Angular vs **common**), LLM routing, conformance checklist | -| [plans/](plans/) | In-progress feature and wiki-change plans ([plans/README.md](plans/README.md)) | -| [AGENTS.md](../AGENTS.md) | LLM Wiki schema, workflows, truth hierarchy | - -**Agents:** For Next-specific answers, open **`content-sdk-nextjs/index.md`** then the linked page. For **`sitecore.config`**, env fallbacks, or **`SitecoreClient`** / GraphQL behavior shared by all heads, start with **`common/index.md`**. For Angular integration (loaders, SSR), use **`content-sdk-angular/`**. For wiki structure and vague-language rules, read **`wiki-boundary-and-token-audit.md`** first when auditing docs. For **in-progress** wiki or feature notes, see **`plans/`**. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md deleted file mode 100644 index 0839de4795..0000000000 --- a/llm-wiki/wiki/log.md +++ /dev/null @@ -1,75 +0,0 @@ -# Wiki log (append-only) - -Chronological record of ingests, major queries, and lint passes. New entries at the **top** (after this paragraph) or bottom — pick one convention and keep it; default here is **newest first** after the title block. - -Prefix suggestion for parseability: `## [YYYY-MM-DD] ingest | <short title>` / `query |` / `lint |` - ---- - -## [2026-05-14] wiki | Boundary and LLM-usability audit - -Added **`wiki/wiki-boundary-and-token-audit.md`** (routing rules, conformance checklist, delta table, diagram). Stubbed **`content-sdk-nextjs/doc-terminology-platform-names.md`** → **common** canonical. Applied link and wording fixes (Angular multisite → common config, editing chrome → `content/editing`, removed **etc.**, Angular index “Shared packages”, **AGENTS.md** / **README** terminology paths). - -## [2026-05-14] wiki | Editor integration — CORS (`getEnforcedCorsHeaders`, `JSS_ALLOWED_ORIGINS`) - -Expanded **`content-sdk-nextjs/doc-editor-integration-metadata.md`** from **`packages/core`** / **`packages/content`** / **`packages/nextjs`** editing handlers (Pages + App Router POST bypass). - -## [2026-05-14] wiki | Common wiki — config, env, SitecoreClient + GraphQL - -Extracted framework-agnostic material from **Next.js** wiki into **`common/doc-sitecore-config-input.md`**, **`common/doc-config-environment-variables.md`**, **`common/doc-sitecore-client-and-graphql.md`**. Trimmed **`content-sdk-nextjs/doc-sitecore-config.md`**, **`doc-graphql-client-and-edge-urls.md`**, **`doc-sitecore-client-apis.md`** to Next-specific deltas; pointed **`doc-architecture-edge-graphql`**, **`doc-route-handling-data-fetching`**, **`overview-content-sdk`**, root **`index.md`** at **common**. Expanded **Angular** env + `sitecore.config` pages and **`doc-example-environment-variable-files`** (Angular **`CSDK_PUBLIC_*`**). - -## [2026-05-14] wiki | Angular design PDF — split wiki + binary in repo - -Copied **`JSS-Angular-Live-Design-Doc-140526-211917.pdf`** to **`llm-wiki/raw/design/`**. Split architecture into subsection pages under **`content-sdk-angular/`**; **`doc-architecture-loaders-and-ssr.md`** is now an index hub. Raw extract frontmatter **`pdf_in_repo`** updated. **`content-sdk-angular/index.md`** and **`log.md`** updated. - -## [2026-05-14] ingest | JSS-Angular Live Design PDF (architecture) - -Extracted *JSS-Angular Live Design Doc-140526-211917.pdf* → `raw/2026-05-14-jss-angular-live-design-architecture.md`. Added `content-sdk-angular/doc-architecture-loaders-and-ssr.md` and updated `content-sdk-angular/index.md` (catalog + sources). - -## [2026-05-14] ingest + wiki | Example env files + next-intl / Pages Router i18n - -Ingested `example-environment-variable-files.html` and `internationalization-using-next-intl.html` → `raw/2026-05-14-example-environment-variable-files.md`, `raw/2026-05-14-internationalization-using-next-intl.md`. Added `content-sdk-nextjs/doc-example-environment-variable-files.md`. Expanded `doc-i18n-multilingual.md` with App Router (`next-intl`) summary from raw + **Pages Router** code: `next.config.js` i18n, `extractPath`, `[[...path]].tsx` locale/dictionary, `_app.tsx` `next-localization`, error pages. Updated `index.md`, `overview-content-sdk.md`, `source-ingest`, `doc-sitecore-config` cross-link. - -## [2026-05-14] wiki | Restore `content-sdk-nextjs/` (wiki not in git) - -Previous **`llm-wiki/wiki/**`** markdown was **lost** (untracked + accidental delete). Recreated **13 pages** under **`wiki/content-sdk-nextjs/`** from `llm-wiki/raw/*` + monorepo source (config, GraphQL factory, `SitecoreClient`, route/editor topics). Hub **`wiki/index.md`** now points to **`content-sdk-nextjs/`** (removed duplicate **`nextjs/`** folder). Updated **`AGENTS.md`**, **`llm-wiki/README.md`**, raw wiki-alignment paths, **`common/`** and **`content-sdk-angular/`** cross-links. - -## [2026-05-14] wiki | doc-sitecore-config — full TypeScript reference - -Documented every **`SitecoreConfigInput`** key from `packages/content/src/config/models.ts` (types + purpose): `api.*`, `retries`, `layout`, `dictionary`, `multisite`, `personalize`, `redirects`, **`rewriteMediaUrls`**, **`disableCodeGeneration`**. Added Next-only **`generateStaticPaths`** / **`sitecoreInternalEditingHostUrl`**, and **`SitecoreCliConfigInput`** + **`GenerateMapArgs`** / **`ScaffoldTemplate`**. Clarified multisite **`useCookieResolution`** default via `getNextFallbackConfig`. Index summary updated. - -## [2026-05-14] wiki | Code-truth: config pipeline, GraphQL factory, SitecoreClient wiring - -Added **`doc-graphql-client-and-edge-urls.md`** (`createGraphQLClientFactory`, Edge URL path, server/browser rules). Expanded **`doc-sitecore-config.md`** (Next `getNextFallbackConfig` → content `defineConfig`, `buildFallbackConfig` env table, `deepMerge` / CLI validation). Expanded **`doc-sitecore-client-apis.md`** (constructor services, `LayoutService` path under `packages/content`, `SitecoreNextjsClient` overrides). Linked architecture wiki; FEaaS row in editor wiki; **`index.md`**, **`overview-content-sdk.md`**, **`source-ingest-2026-05-14-official-docs.md`** updated. - -## [2026-05-14] wiki | layout data = GraphQL JSON (no CMS XML framing) - -Removed incorrect “layout stored as XML / head avoids XML” wording from `doc-architecture-edge-graphql.md`, `doc-page-composition-placeholders.md`, and `index.md`; aligned `raw/2026-05-14-page-composition-sitecoreai-data.md`. Route-handling wiki bullet rephrased URL rules without implying layout XML. `doc-sitecore-client-apis` “Sitemap XML” kept (sitemap format). - -## [2026-05-14] wiki | doc-architecture-edge-graphql corrections - -Clarified runtime GraphQL endpoint resolution via `sitecore.config` + env (cross-ref `doc-sitecore-config`, `doc-sitecore-client-apis`); separated Next.js head fetch path from `@sitecore-content-sdk/react`; noted layout service runs through `SitecoreClient`. `package.json` `graphQLEndpointPath` framed as Pages template / doc artifact. Raw `2026-05-14-architecture-overview.md` annotated. Index summary updated. - -## [2026-05-14] ingest | The Sitecore configuration file (full) - -Re-ingested official topic; replaced `raw/2026-05-14-the-sitecore-configuration-file.md` with full markdown tables. Expanded `wiki/doc-sitecore-config.md` with base/api/services/middleware summaries and code-truth pointers. - -## [2026-05-14] ingest | Editor integration using metadata (SAI doc) - -Added `raw/2026-05-14-editor-integration-using-metadata.md` (HTML via curl → distilled markdown) and `wiki/doc-editor-integration-metadata.md`. Updated `index.md`, `overview-content-sdk.md` topic map, `source-ingest-2026-05-14-official-docs.md`; cross-link from `doc-route-handling-data-fetching.md`. - -## [2026-05-14] ingest | Re-fetch plugins + route-handling (raw) - -Automated fetch succeeded for `plugins.html` and `route-handling-and-data-fetching-in-content-sdk-apps.html`. Replaced `raw/2026-05-14-plugins.md` and `raw/2026-05-14-route-handling-data-fetching.md` stubs with snapshots. Updated `doc-plugins-and-adapters.md`, `doc-route-handling-data-fetching.md`, `source-ingest-2026-05-14-official-docs.md`. Noted official doc’s incorrect `LayoutService` GitHub path vs `packages/content/src/layout/`. - -## [2026-05-14] wiki | Platform terminology (SAI / XMC / XM Cloud) - -Added `doc-terminology-platform-names.md`; linked from `overview-content-sdk`, `index`, `doc-sitecore-config`, and **AGENTS.md** LLM Wiki conventions. Clarifies doc URLs and comments that mix **Sitecore AI**, **SAI**, **XMC**, and **XM Cloud** are equivalent naming for this wiki’s scope. - -## [2026-05-14] ingest | Official SAI Content SDK 2.x docs (9 URLs) - -Fetched 7 pages successfully; **route handling** and **plugins** URLs returned Cloudflare challenge (snapshots are stubs in `raw/`). Wiki pages added: `doc-sitecore-config`, `doc-architecture-edge-graphql`, `doc-page-composition-placeholders`, `doc-route-handling-data-fetching`, `doc-i18n-multilingual`, `doc-sitecore-client-apis`, `doc-plugins-and-adapters`, `source-ingest-2026-05-14-official-docs`; `overview-content-sdk` updated. Raw snapshots under `llm-wiki/raw/2026-05-14-*.md`. - -## [2026-05-14] init | LLM Wiki scaffold - -Initial `llm-wiki/` layout and AGENTS.md schema section added. No sources ingested yet. diff --git a/llm-wiki/wiki/plans/README.md b/llm-wiki/wiki/plans/README.md deleted file mode 100644 index c9902d1d56..0000000000 --- a/llm-wiki/wiki/plans/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Wiki plans (in progress) - -This folder holds **in-progress** plans for wiki updates and **in-progress product or documentation features** that are not yet fully captured in canonical wiki pages under `wiki/common/`, `wiki/content-sdk-nextjs/`, or `wiki/content-sdk-angular/`. - -## Conventions - -- **Naming:** Use clear filenames (for example `feature-name-outline.md`, `doc-topic-refactor.md`). -- **Lifecycle:** When work is done, fold outcomes into the appropriate wiki pages (and [log.md](../log.md) if the change is significant), then **delete** or **archive** the plan file so agents do not treat stale plans as truth. -- **Authority:** Plans here are **not** the truth hierarchy. Prefer [wiki/index.md](../index.md), [wiki/wiki-boundary-and-token-audit.md](../wiki-boundary-and-token-audit.md), and repository `packages/**` sources. - -## Relation to IDE plans - -Editor-local plan files (for example under **`.cursor/plans/`**) are outside this tree. **`wiki/plans/`** is **git-tracked** under `llm-wiki` so the team can share in-flight wiki or feature notes in-repo. From 99e17e9c86ac3a2e665fc64c2713ee899e980e60 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Thu, 28 May 2026 16:17:43 -0400 Subject: [PATCH 12/14] some missing tests, better refactored flow, tsdoc, misc --- ....ts => client-loader-data.service.spec.ts} | 12 +- ...rvice.ts => client-loader-data.service.ts} | 20 ++- .../src/loaders/loader-resolver.spec.ts | 34 +++-- .../angular/src/loaders/loader-resolver.ts | 59 ++++---- packages/angular/src/loaders/models.ts | 29 ++-- .../loaders/pre-loader-data.service.spec.ts | 20 +-- .../src/loaders/pre-loader-data.service.ts | 14 +- ...token.ts => server-loader-runner.token.ts} | 6 +- packages/angular/src/loaders/utils.ts | 6 +- packages/angular/src/public-api.ts | 10 +- .../angular/src/server/cache/cache-key.ts | 12 +- .../angular/src/server/cache/cache-tags.ts | 29 ++-- .../src/server/cache/cache.spec-helpers.ts | 9 ++ .../{ => demo}/cache-admin-middleware.spec.ts | 8 +- .../{ => demo}/cache-admin-middleware.ts | 4 +- packages/angular/src/server/cache/index.ts | 21 +-- packages/angular/src/server/cache/models.ts | 6 +- .../cache/unstorage-loader-cache.spec.ts | 10 -- packages/angular/src/server/index.ts | 4 +- .../loader-data-service-middleware.spec.ts | 15 +- .../loader-data-service-middleware.ts | 4 +- .../sitecore-edge-webhook-revalidation.ts | 15 +- .../sitecore-revalidate-middleware.spec.ts | 129 ++++++++++++++++++ .../sitecore-revalidate-middleware.ts | 11 +- ...der.ts => provide-server-loader-runner.ts} | 12 +- ...r.spec.ts => server-loader-runner.spec.ts} | 74 ++++++++-- ...ta.provider.ts => server-loader-runner.ts} | 31 +++-- .../src/templates/angular/eslint.config.mjs | 1 - .../angular/src/app/app.config.server.ts | 4 +- .../templates/angular/src/app/app.config.ts | 4 +- 30 files changed, 413 insertions(+), 200 deletions(-) rename packages/angular/src/loaders/{loader-data.service.spec.ts => client-loader-data.service.spec.ts} (96%) rename packages/angular/src/loaders/{loader-data.service.ts => client-loader-data.service.ts} (85%) rename packages/angular/src/loaders/{server-loader-data-provider.token.ts => server-loader-runner.token.ts} (81%) rename packages/angular/src/server/cache/{ => demo}/cache-admin-middleware.spec.ts (96%) rename packages/angular/src/server/cache/{ => demo}/cache-admin-middleware.ts (96%) rename packages/angular/src/server/{provide-server-loader-data-provider.ts => provide-server-loader-runner.ts} (71%) rename packages/angular/src/server/{loader-data.provider.spec.ts => server-loader-runner.spec.ts} (77%) rename packages/angular/src/server/{loader-data.provider.ts => server-loader-runner.ts} (76%) diff --git a/packages/angular/src/loaders/loader-data.service.spec.ts b/packages/angular/src/loaders/client-loader-data.service.spec.ts similarity index 96% rename from packages/angular/src/loaders/loader-data.service.spec.ts rename to packages/angular/src/loaders/client-loader-data.service.spec.ts index 25479001fd..366a8a5e27 100644 --- a/packages/angular/src/loaders/loader-data.service.spec.ts +++ b/packages/angular/src/loaders/client-loader-data.service.spec.ts @@ -4,13 +4,13 @@ import { PLATFORM_ID } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { LoaderDataService } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { FETCH_DATA_ENDPOINT } from './loader-registry.token'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import * as sdkCore from '@sitecore-content-sdk/core'; -describe('LoaderDataService', () => { - let service: LoaderDataService; +describe('ClientLoaderDataService', () => { + let service: ClientLoaderDataService; let httpController: HttpTestingController; let debugCommonSpy: ReturnType<typeof vi.spyOn>; @@ -23,7 +23,7 @@ describe('LoaderDataService', () => { const platformId = overrides.platformId ?? 'browser'; TestBed.configureTestingModule({ providers: [ - LoaderDataService, + ClientLoaderDataService, provideHttpClient(), provideHttpClientTesting(), { provide: PLATFORM_ID, useValue: platformId }, @@ -32,7 +32,7 @@ describe('LoaderDataService', () => { : []), ], }); - service = TestBed.inject(LoaderDataService); + service = TestBed.inject(ClientLoaderDataService); httpController = TestBed.inject(HttpTestingController); } @@ -85,7 +85,7 @@ describe('LoaderDataService', () => { expect(result).toEqual({ kind: 'error', status: 500, - message: 'LoaderDataService only works in browser', + message: 'ClientLoaderDataService only works in browser', }); httpController.expectNone(LOADER_DATA_ENDPOINT); }); diff --git a/packages/angular/src/loaders/loader-data.service.ts b/packages/angular/src/loaders/client-loader-data.service.ts similarity index 85% rename from packages/angular/src/loaders/loader-data.service.ts rename to packages/angular/src/loaders/client-loader-data.service.ts index 1054ac0852..7bd09bc5c3 100644 --- a/packages/angular/src/loaders/loader-data.service.ts +++ b/packages/angular/src/loaders/client-loader-data.service.ts @@ -35,7 +35,7 @@ export interface LoaderDataRequest { } /** - * Browser-only loader data client. POSTs to the `/_data` endpoint and holds + * Loader data client for browser loader data resolution. POSTs to the `/_data` endpoint and holds * short-lived prefetched responses for parallel navigation prefetching. * Not aware of the server-side {@link LoaderCache}. * @public @@ -43,7 +43,7 @@ export interface LoaderDataRequest { @Injectable({ providedIn: 'root', }) -export class LoaderDataService { +export class ClientLoaderDataService { private readonly prefetchedResponses = new Map<string, LoaderApiResponse>(); private readonly pending = new Map<string, Promise<LoaderApiResponse>>(); private readonly http = inject(HttpClient); @@ -83,7 +83,11 @@ export class LoaderDataService { */ async getData(request: LoaderDataRequest): Promise<LoaderApiResponse> { if (!isPlatformBrowser(this.platformId)) { - return { kind: 'error', status: 500, message: 'LoaderDataService only works in browser' }; + return { + kind: 'error', + status: 500, + message: 'ClientLoaderDataService only works in browser', + }; } const key = requestKey(request.loaderId, request.url); @@ -94,11 +98,13 @@ export class LoaderDataService { return staged; } + // Wait for pending loader data request if one exists const pendingRequest = this.pending.get(key); if (pendingRequest) { return pendingRequest; } + // Make new request; add to pending so concurrent callers reuse the same promise const pendingFetchData = this.fetchData(request); this.pending.set(key, pendingFetchData); return pendingFetchData; @@ -121,6 +127,7 @@ export class LoaderDataService { query: request.query ?? {}, cacheOptions: request.cacheOptions, }; + console.log('DEBUG: ClientLoaderDataService fetchData', endpoint, reqBody); try { const resp = await firstValueFrom( @@ -128,13 +135,18 @@ export class LoaderDataService { ); if (!resp) { const message = `No response from ${endpoint}`; + console.log(`DEBUG: ClientLoaderDataService fetchData: ${message}`); return { kind: 'error', status: 500, message } as LoaderApiResponse; } - if (resp.kind === 'data' || resp.kind === 'redirect') { + if (resp.kind === 'data') { + console.log('DEBUG: ClientLoaderDataService fetchData: data', resp.data); + this.prefetchedResponses.set(key, resp); + } else if (resp.kind === 'redirect') { this.prefetchedResponses.set(key, resp); } return resp; } catch (error) { + console.log('DEBUG: ClientLoaderDataService fetchData: error', error); const message = error instanceof Error ? error.message : 'Fetch failed'; return { kind: 'error', status: 500, message } as LoaderApiResponse; } finally { diff --git a/packages/angular/src/loaders/loader-resolver.spec.ts b/packages/angular/src/loaders/loader-resolver.spec.ts index 6dc9d6873e..5653efbb35 100644 --- a/packages/angular/src/loaders/loader-resolver.spec.ts +++ b/packages/angular/src/loaders/loader-resolver.spec.ts @@ -7,8 +7,8 @@ import { provideRouter, RedirectCommand, Router } from '@angular/router'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loaderResolver } from './loader-resolver'; import { LOADER_ID, LOADER_REGISTRY } from './loader-registry.token'; -import { LoaderDataService } from './loader-data.service'; -import { provideServerLoaderDataProvider } from '../server/provide-server-loader-data-provider'; +import { ClientLoaderDataService } from './client-loader-data.service'; +import { provideServerLoaderRunner } from '../server/provide-server-loader-runner'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import { createLoaderCache } from '../server/cache/loader-cache'; import type { LoaderFn } from './models'; @@ -48,7 +48,7 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, - { provide: LoaderDataService, useValue: mockLoaderData }, + { provide: ClientLoaderDataService, useValue: mockLoaderData }, ], }); transferState = TestBed.inject(TransferState); @@ -74,7 +74,7 @@ describe('loaderResolver', () => { expect(mockLoaderData.getData).not.toHaveBeenCalled(); }); - it('should call LoaderDataService.getData with correct request and return data', async () => { + it('should call ClientLoaderDataService.getData with correct request and return data', async () => { mockLoaderData.getData.mockResolvedValue({ kind: 'data', data: { title: 'Home' } }); const resolver = loaderResolver('page'); @@ -159,7 +159,7 @@ describe('loaderResolver', () => { }); }); - describe('browser with real LoaderDataService (pending handling)', () => { + describe('browser with real ClientLoaderDataService (pending handling)', () => { let httpController: HttpTestingController; beforeEach(() => { @@ -170,7 +170,7 @@ describe('loaderResolver', () => { provideHttpClient(), provideHttpClientTesting(), TransferState, - LoaderDataService, + ClientLoaderDataService, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, ], @@ -233,7 +233,7 @@ describe('loaderResolver', () => { }); it('should remove pending promise when fetch settles so a later call triggers a new request', async () => { - const loaderData = TestBed.inject(LoaderDataService); + const loaderData = TestBed.inject(ClientLoaderDataService); const resolver = loaderResolver('page'); const route = makeRouteSnapshot({ pathFromRoot: [{ params: {} }] }); const state = makeRouterStateSnapshot('/after-settle'); @@ -277,8 +277,8 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, - provideServerLoaderDataProvider(), + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderRunner(), ], }); transferState = TestBed.inject(TransferState); @@ -360,9 +360,7 @@ describe('loaderResolver', () => { it('should reuse cached loader output on SSR when REQUEST_CONTEXT provides a cache', async () => { TestBed.resetTestingModule(); - const cachedLoader = vi.fn().mockResolvedValue({ cached: true }) as ReturnType< - typeof vi.fn - > & + const cachedLoader = vi.fn().mockResolvedValue({ cached: true }) as ReturnType<typeof vi.fn> & LoaderFn; const cache = createLoaderCache({ revalidate: 300 }); TestBed.configureTestingModule({ @@ -371,9 +369,9 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: cachedLoader } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST_CONTEXT, useValue: { cache } }, - provideServerLoaderDataProvider(), + provideServerLoaderRunner(), ], }); @@ -413,9 +411,9 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: loaderWithRequest } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST, useValue: mockRequest }, - provideServerLoaderDataProvider(), + provideServerLoaderRunner(), ], }); }); @@ -463,8 +461,8 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, - provideServerLoaderDataProvider(), + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderRunner(), { provide: SITECORE_CONFIG_TOKEN, useValue: { diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 40e7c935b8..7c99c8bfe4 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -9,18 +9,18 @@ import { RedirectCommand, } from '@angular/router'; import { LOADER_ID } from './loader-registry.token'; -import { LoaderDataService } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { extractRequestContext, applyRedirect } from './utils'; import { DEFAULT_ERROR_ROUTE, DEFAULT_NOT_FOUND_ROUTE, LoaderHttpError, NotFoundNavigationError, - LoaderCacheConfig, + PerRouteLoaderCacheConfig, } from './models'; import { redirectOnNavigationError } from './router-error-handling'; import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN } from '../lib/tokens'; -import { SERVER_LOADER_DATA_PROVIDER } from './server-loader-data-provider.token'; +import { SERVER_LOADER_RUNNER } from './server-loader-runner.token'; import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; /** * Create a state key for the loader @@ -66,26 +66,33 @@ function buildLoaderParams(route: ActivatedRouteSnapshot, defaultLanguage?: stri } /** - * Browser-only: load data from transfer state or LoaderDataService. - * Injects TransferState, LoaderDataService. Called by the resolver when isPlatformBrowser. + * Browser-only: load data from transfer state or ClientLoaderDataService. + * Injects TransferState, ClientLoaderDataService. Called by the resolver when isPlatformBrowser. * @param {ActivatedRouteSnapshot} route - The current route snapshot * @param {RouterStateSnapshot} state - The router state snapshot - * @param {string} loaderId - loader ID to resolve, used for transfer state key and LoaderDataService call + * @param {string} loaderId - loader ID to resolve, used for transfer state key and ClientLoaderDataService call * @param {Router} router - The Angular router instance * @param {string} [defaultLanguage] - Default language for locale fallback in params * @param {LoaderCacheConfig} [cacheOptions] - Cache options for the loader * @returns {Promise<unknown | RedirectCommand>} The resolved data or redirect command */ -async function resolveOnBrowser( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - loaderId: string, - router: Router, - defaultLanguage?: string, - cacheOptions?: LoaderCacheConfig -): Promise<unknown | RedirectCommand> { +async function resolveOnBrowser({ + route, + state, + loaderId, + router, + defaultLanguage, + cacheOptions, +}: { + route: ActivatedRouteSnapshot; + state: RouterStateSnapshot; + loaderId: string; + router: Router; + defaultLanguage?: string; + cacheOptions?: PerRouteLoaderCacheConfig; +}): Promise<unknown | RedirectCommand> { const transferState = inject(TransferState); - const browserLoaderData = inject(LoaderDataService); + const browserLoaderData = inject(ClientLoaderDataService); const url = state.url; const key = stateKey(loaderId, url); @@ -118,9 +125,15 @@ async function resolveOnBrowser( return resp.data; } +/** + * Create a loader resolver function that resolver loader data with optional cache options on server or browser. + * @param loaderId - The loader ID + * @param cacheOptions - The cache options + * @returns loader resolver function + */ export const loaderResolver = ( loaderId: LoaderId, - cacheOptions?: LoaderCacheConfig + cacheOptions?: PerRouteLoaderCacheConfig ): ResolveFn<unknown> => { const resolver = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const transferState = inject(TransferState); @@ -137,30 +150,30 @@ export const loaderResolver = ( if (isPlatformBrowser(platformId)) { try { - return await resolveOnBrowser( + return await resolveOnBrowser({ route, state, loaderId, router, defaultLanguage, - cacheOptions - ); + cacheOptions, + }); } catch (e) { // special handling for browser, as navigation error for handleNavigationError is only generated on server return redirectOnNavigationError(e as Error, url, notFoundRoute, errorRoute, router); } } - const serverLoaderData = inject(SERVER_LOADER_DATA_PROVIDER, { optional: true }); - if (!serverLoaderData) { + const serverLoaderRunner = inject(SERVER_LOADER_RUNNER, { optional: true }); + if (!serverLoaderRunner) { throw new Error( - 'SSR loader resolution requires provideServerLoaderDataProvider() in server application providers' + 'SSR loader resolution requires provideServerLoaderRunner() in server application providers' ); } const angularRequestContext = request ? extractRequestContext(request) : undefined; - const result = await serverLoaderData.resolve({ + const result = await serverLoaderRunner.resolve({ loaderId, url, params: buildLoaderParams(route, defaultLanguage), diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index b3a4eaf9e9..c1a554a669 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -155,25 +155,16 @@ export class LoaderHttpError extends Error { } /** - * Base config for loader cache. Can be applied per loader. + * Base browser-safe config type for loader cache. * * `revalidate` is in seconds. A positive value caches the entry for that many * seconds; `0` or a negative value means "never expire" (rely on explicit * invalidation). There is no `'infinite'` sentinel. * @public */ -export interface LoaderCacheConfig { - /** TTL in seconds. Positive → expires after N seconds; `0` or negative → never expires. */ - revalidate?: number; - /** Master switch — when false, every call falls through to the raw loader. */ - enabled?: boolean; +export interface LoaderCacheConfig extends PerRouteLoaderCacheConfig { /** Default site name for tag helpers and admin tooling. Defaults to `'default'`. */ defaultSiteName?: string; - /** - * Custom tags applied to every entry this loader writes. Merged with built-in - * OSR tags (self-key, `sc:site`, `sc:locale`, and `sc:item` for page loaders). - */ - tags?: string[]; /** * Site names used by revalidation middleware to fan out dictionary loader tags * (`sc:loader:dictionary:<site>:<locale>`) on every webhook call. @@ -183,6 +174,22 @@ export interface LoaderCacheConfig { defaultLocale?: string; } +/** + * Per-route cache configuration. + * @public + */ +export interface PerRouteLoaderCacheConfig { + /** TTL in seconds. Positive → expires after N seconds; `0` or negative → never expires. */ + revalidate?: number; + /** Master switch — when false, every call falls through to the raw loader. */ + enabled?: boolean; + /** + * Custom tags applied to every entry this loader writes. Merged with built-in + * OSR tags (self-key, `sc:site`, `sc:locale`, and `sc:item` for page loaders). + */ + tags?: string[]; +} + /** * Metadata returned by cache.entries() — sufficient for an admin UI without * shipping the cached values themselves (which can be large). diff --git a/packages/angular/src/loaders/pre-loader-data.service.spec.ts b/packages/angular/src/loaders/pre-loader-data.service.spec.ts index 019258433a..b60eb1e252 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.spec.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.spec.ts @@ -4,8 +4,8 @@ import { PLATFORM_ID } from '@angular/core'; import { provideRouter } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { PreLoaderDataService } from './pre-loader-data.service'; -import { LoaderDataService } from './loader-data.service'; +import { ClientPreLoaderDataService } from './pre-loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { LOADER_ID } from './loader-registry.token'; function makeResolverWithLoaderId(loaderId: string): (() => void) & { [LOADER_ID]: string } { @@ -36,18 +36,18 @@ function makeRouterStateSnapshot(url: string): RouterStateSnapshot { return { url } as RouterStateSnapshot; } -describe('PreLoaderDataService', () => { +describe('ClientPreLoaderDataService', () => { let loaderDataPrefetchSpy: ReturnType<typeof vi.fn>; beforeEach(() => { loaderDataPrefetchSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ - PreLoaderDataService, + ClientPreLoaderDataService, provideRouter([]), { provide: PLATFORM_ID, useValue: 'browser' }, { - provide: LoaderDataService, + provide: ClientLoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy }, }, ], @@ -75,7 +75,7 @@ describe('PreLoaderDataService', () => { (child as MutableSnapshot).pathFromRoot = [root, child]; const state = makeRouterStateSnapshot('/page/123'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(child as ActivatedRouteSnapshot, state); @@ -111,7 +111,7 @@ describe('PreLoaderDataService', () => { (child as MutableSnapshot).pathFromRoot = [root, child]; const state = makeRouterStateSnapshot('/page'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(child as ActivatedRouteSnapshot, state); @@ -128,10 +128,10 @@ describe('PreLoaderDataService', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ - PreLoaderDataService, + ClientPreLoaderDataService, provideRouter([]), { provide: PLATFORM_ID, useValue: 'server' }, - { provide: LoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy } }, + { provide: ClientLoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy } }, ], }); const root = makeRouteSnapshot({ @@ -141,7 +141,7 @@ describe('PreLoaderDataService', () => { }); (root as MutableSnapshot).pathFromRoot = [root]; const state = makeRouterStateSnapshot('/'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(root as ActivatedRouteSnapshot, state); diff --git a/packages/angular/src/loaders/pre-loader-data.service.ts b/packages/angular/src/loaders/pre-loader-data.service.ts index 84fd45e4c4..4a32c91611 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.ts @@ -9,8 +9,8 @@ import { Router, RouterStateSnapshot, } from '@angular/router'; -import { LoaderDataService } from './loader-data.service'; -import { LoaderDataRequest } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; +import { LoaderDataRequest } from './client-loader-data.service'; import { LOADER_ID } from './loader-registry.token'; /** @@ -22,22 +22,22 @@ interface ResolverWithLoaderId { } /** - * PreLoaderDataService kicks off loader data fetches for all loaders in the current route + * ClientPreLoaderDataService kicks off loader data fetches for all loaders in the current route * and its parent routes in parallel, so that when Angular runs resolvers sequentially, * resolvers get staged prefetched responses or join already-pending requests instead of waiting. * * Subscribes to the router's ActivationStart event and prefetches for the * ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader * resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then - * calls LoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches - * run in parallel; results are stored in LoaderDataService prefetchedResponses for getData() to consume. + * calls ClientLoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches + * run in parallel; results are stored in ClientLoaderDataService prefetchedResponses for getData() to consume. * @public */ @Injectable({ providedIn: 'root', }) -export class PreLoaderDataService { - private readonly loaderData = inject(LoaderDataService); +export class ClientPreLoaderDataService { + private readonly loaderData = inject(ClientLoaderDataService); private readonly platformId = inject(PLATFORM_ID); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); diff --git a/packages/angular/src/loaders/server-loader-data-provider.token.ts b/packages/angular/src/loaders/server-loader-runner.token.ts similarity index 81% rename from packages/angular/src/loaders/server-loader-data-provider.token.ts rename to packages/angular/src/loaders/server-loader-runner.token.ts index 7e9f42e430..5821efc33e 100644 --- a/packages/angular/src/loaders/server-loader-data-provider.token.ts +++ b/packages/angular/src/loaders/server-loader-runner.token.ts @@ -7,7 +7,7 @@ import { LoaderApiRequest, LoaderDataResult } from './models'; * {@link provideServerLoaderDataProvider}. * @public */ -export interface ServerLoaderDataProviderPort { +export interface ServerLoaderRunnerPort { /** * Resolve loader data on the server (cache-aware) using the shared {@link LOADER_REGISTRY}. * @param {LoaderApiRequest} request - Loader request payload @@ -21,6 +21,6 @@ export interface ServerLoaderDataProviderPort { * Must be provided via `provideServerLoaderDataProvider()` in server application config. * @public */ -export const SERVER_LOADER_DATA_PROVIDER = new InjectionToken<ServerLoaderDataProviderPort>( - 'SERVER_LOADER_DATA_PROVIDER' +export const SERVER_LOADER_RUNNER = new InjectionToken<ServerLoaderRunnerPort>( + 'SERVER_LOADER_RUNNER' ); diff --git a/packages/angular/src/loaders/utils.ts b/packages/angular/src/loaders/utils.ts index 23255dbc63..7828eae8a9 100644 --- a/packages/angular/src/loaders/utils.ts +++ b/packages/angular/src/loaders/utils.ts @@ -129,7 +129,6 @@ export function extractRequestContext(req: Request | ExpressLikeRequest): Reques }; } - // Express-like request object const hostHeader = req.headers?.host; const hostname = pickHostnameFromHostHeader( Array.isArray(hostHeader) ? hostHeader[0] : hostHeader @@ -142,6 +141,11 @@ export function extractRequestContext(req: Request | ExpressLikeRequest): Reques }; } +/** + * Pick the hostname from the host header + * @param {string | undefined} host - The host header + * @returns {string | undefined} The hostname + */ function pickHostnameFromHostHeader(host: string | undefined): string | undefined { if (!host) return undefined; const colon = host.indexOf(':'); diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 093321bc89..a98d307d4f 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -71,12 +71,12 @@ import { Router } from '@angular/router'; // Angular-specific exports export * from './loaders/loader-resolver'; export * from './loaders/loader-registry.token'; -export * from './loaders/loader-data.service'; +export * from './loaders/client-loader-data.service'; export * from './loaders/pre-loader-data.service'; export { - SERVER_LOADER_DATA_PROVIDER, - type ServerLoaderDataProviderPort, -} from './loaders/server-loader-data-provider.token'; + SERVER_LOADER_RUNNER, + type ServerLoaderRunnerPort, +} from './loaders/server-loader-runner.token'; export { type LoaderRegistry } from './loaders/loader-registry.token'; export { NotFoundNavigationError, @@ -84,6 +84,8 @@ export { type LoaderFn, type LoaderContext, type LoaderDataResult, + type PerRouteLoaderCacheConfig, + type LoaderCacheConfig, } from './loaders/models'; export { handleNavigationError } from './loaders/router-error-handling'; export { applyRedirect } from './loaders/utils'; diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts index 357f2c7267..823be603d0 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -4,7 +4,7 @@ import { dimensionsFromContext } from './utils'; import { sanitizeSitecoreCacheSegment } from './utils'; import { SITECORE_CONTENT_CACHE_TAG_PREFIX } from './cache-tags'; -/** Prefix for OSR-aligned loader cache keys (`sc:loader:…`). @public */ +/** Prefix for OSR-aligned loader cache keys (`sc:loader:…`). @internal */ export const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`; /** @@ -17,7 +17,7 @@ export const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`; * buildCacheKey('page', { url: '/about', params: { site: 'demo', locale: 'en' }, query: {} }); * // → { key: 'sc:loader:page:demo:en:default:about', dimensions: { … } } * ``` - * @public + * @internal */ export function buildCacheKey( loaderId: string, @@ -34,7 +34,7 @@ export function buildCacheKey( * {@link buildGenericLoaderCacheKey} based on `dimensions.loaderId`. * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. * @returns {string} OSR-aligned cache key. - * @public + * @internal */ export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string { if (dimensions.loaderId === 'page') { @@ -50,7 +50,7 @@ export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string * Page loader key: `sc:loader:page:<site>:<locale>:<variantId>:<pathKey>`. * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. * @returns {string} Page loader cache key. - * @public + * @internal */ export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { const site = sanitizeSitecoreCacheSegment(dimensions.site); @@ -63,7 +63,7 @@ export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { * Dictionary loader key: `sc:loader:dictionary:<site>:<locale>` (one entry per site/locale). * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. * @returns {string} Dictionary loader cache key. - * @public + * @internal */ export function buildDictionaryCacheKey(dimensions: CacheKeyDimensions): string { const site = sanitizeSitecoreCacheSegment(dimensions.site); @@ -76,7 +76,7 @@ export function buildDictionaryCacheKey(dimensions: CacheKeyDimensions): string * Used for loaders other than `page` and `dictionary` (for example `404`, `500`). * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. * @returns {string} Generic loader cache key. - * @public + * @internal */ export function buildGenericLoaderCacheKey(dimensions: CacheKeyDimensions): string { const loaderId = sanitizeSitecoreCacheSegment(dimensions.loaderId); diff --git a/packages/angular/src/server/cache/cache-tags.ts b/packages/angular/src/server/cache/cache-tags.ts index ccbd8c77e0..caadb44c1b 100644 --- a/packages/angular/src/server/cache/cache-tags.ts +++ b/packages/angular/src/server/cache/cache-tags.ts @@ -10,13 +10,13 @@ import type { CacheKeyDimensions } from './models'; /** * Sitecore OSR namespace prefix shared with Next.js (`sc:`). * All loader cache keys and invalidation tags use this prefix. - * @public + * @internal */ export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; /** * Parameters for {@link buildSitecoreItemCacheTag}. - * @public + * @internal */ export type BuildSitecoreItemCacheTagParams = { /** Sitecore item GUID or content id. */ @@ -29,10 +29,9 @@ export type BuildSitecoreItemCacheTagParams = { /** * Builds an item-scoped revalidation tag: `sc:item:<id>:<locale>:<version>`. - * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. * @param {BuildSitecoreItemCacheTagParams} params - Item id, locale, and optional version. * @returns {string} Sitecore item cache tag. - * @public + * @internal */ export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParams): string { const id = normalizeSitecoreItemIdForCacheKey(params.itemId); @@ -46,7 +45,7 @@ export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParam /** * Parameters for {@link buildSitecoreDictionaryCacheTag} and related dictionary tag helpers. - * @public + * @internal */ export type SitecoreDictionaryCacheTagParams = { /** Site name segment. */ @@ -57,7 +56,7 @@ export type SitecoreDictionaryCacheTagParams = { /** * Site entry used when fanning out dictionary loader tags from webhook middleware. - * @public + * @internal */ export type LoaderDictionaryCacheSiteInfo = { /** Site name. */ @@ -68,7 +67,7 @@ export type LoaderDictionaryCacheSiteInfo = { /** * Parameters for {@link buildLoaderDictionaryCacheTagsFromSites}. - * @public + * @internal */ export type BuildLoaderDictionaryCacheTagsFromSitesParams = { /** Sites to emit dictionary loader tags for. */ @@ -80,10 +79,9 @@ export type BuildLoaderDictionaryCacheTagsFromSitesParams = { /** * Builds a Next.js-compatible dictionary tag: `sc:dict:<site>:<locale>`. * Used for dictionary loader entries and cross-stack webhook fan-out. - * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. * @returns {string} Dictionary cache tag. - * @public + * @internal */ export function buildSitecoreDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { const site = sanitizeSitecoreCacheSegment(params.site); @@ -94,11 +92,10 @@ export function buildSitecoreDictionaryCacheTag(params: SitecoreDictionaryCacheT /** * Builds an item tag from layout route data when `itemId` is present. * Returns `null` when the route has no item id (non-content routes). - * Authority: `packages/nextjs/src/cache/sitecore-cache-tags.ts`. * @param {RouteData | null | undefined} route - Layout route metadata. * @param {string} fallbackLocale - Locale used when `route.itemLanguage` is absent. * @returns {string | null} Item cache tag, or `null` when no item id is available. - * @public + * @internal */ export function buildSitecoreItemCacheTagFromRouteData( route: RouteData | null | undefined, @@ -124,7 +121,7 @@ export function buildSitecoreItemCacheTagFromRouteData( * When a site has no `language`, `baseLocale` is used. * @param {BuildLoaderDictionaryCacheTagsFromSitesParams} params - Sites and fallback locale. * @returns {string[]} Deduplicated loader dictionary cache tags. - * @public + * @internal */ export function buildLoaderDictionaryCacheTagsFromSites( params: BuildLoaderDictionaryCacheTagsFromSitesParams @@ -146,7 +143,7 @@ export function buildLoaderDictionaryCacheTagsFromSites( * Loader-cache self-tag for the dictionary loader: `sc:loader:dictionary:<site>:<locale>`. * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. * @returns {string} Loader dictionary self-tag (same shape as the cache key). - * @public + * @internal */ export function buildLoaderDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { const site = sanitizeSitecoreCacheSegment(params.site); @@ -159,7 +156,7 @@ export function buildLoaderDictionaryCacheTag(params: SitecoreDictionaryCacheTag * Invalidating this tag marks every cached entry for the site stale. * @param {string} site - Site name segment. * @returns {string} Site fan-out cache tag. - * @public + * @internal */ export function buildSitecoreSiteCacheTag(site: string): string { return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:${sanitizeSitecoreCacheSegment(site)}`; @@ -169,7 +166,7 @@ export function buildSitecoreSiteCacheTag(site: string): string { * Locale-wide fan-out tag: `sc:locale:<locale>`. * @param {string} locale - Locale segment. * @returns {string} Locale fan-out cache tag. - * @public + * @internal */ export function buildSitecoreLocaleCacheTag(locale: string): string { return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:${sanitizeSitecoreCacheSegment(locale)}`; @@ -185,7 +182,7 @@ export function buildSitecoreLocaleCacheTag(locale: string): string { * @param {unknown} [loaderValue] - Loader payload (page layout is inspected for item tags). * @param {string[]} [customTags] - Optional per-route tags from `loaderResolver(id, { tags })`. * @returns {string[]} Tag set to persist with the cache entry. - * @public + * @internal */ export function buildLoaderCacheTags( loaderId: string, diff --git a/packages/angular/src/server/cache/cache.spec-helpers.ts b/packages/angular/src/server/cache/cache.spec-helpers.ts index d560a956f2..e177bfed04 100644 --- a/packages/angular/src/server/cache/cache.spec-helpers.ts +++ b/packages/angular/src/server/cache/cache.spec-helpers.ts @@ -95,6 +95,15 @@ export function runSharedLoaderCacheContract( expect((await cache.get(key)).kind).toBe('stale'); }); + it('counts already stale entries during invalidate without rewriting them', async () => { + const key = sampleKey('stale-twice'); + await cache.set(key, { value: 1 }, 300, sampleTags('stale-twice')); + + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + it('deletes a key and unlinks it from the tag index', async () => { const key = sampleKey('delete-me'); await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); diff --git a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts similarity index 96% rename from packages/angular/src/server/cache/cache-admin-middleware.spec.ts rename to packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts index 577b7a9a37..3caba0d4b4 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.spec.ts +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createCacheAdminMiddleware } from './cache-admin-middleware'; -import { createLoaderCache } from './loader-cache'; -import { buildCacheKey } from './cache-key'; -import { buildLoaderCacheTags } from './cache-tags'; -import type { ExpressRequest, ExpressResponse } from '../models'; +import { createLoaderCache } from '../loader-cache'; +import { buildCacheKey } from '../cache-key'; +import { buildLoaderCacheTags } from '../cache-tags'; +import type { ExpressRequest, ExpressResponse } from '../../models'; function createMockRes() { return { diff --git a/packages/angular/src/server/cache/cache-admin-middleware.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts similarity index 96% rename from packages/angular/src/server/cache/cache-admin-middleware.ts rename to packages/angular/src/server/cache/demo/cache-admin-middleware.ts index 2de532d147..165a6136c2 100644 --- a/packages/angular/src/server/cache/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts @@ -3,8 +3,8 @@ * This middleware is only used for testing and should be removed before release. * TODO: Remove this middleware before release. */ -import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; -import { InvalidateInput, LoaderCache } from '../../loaders/models'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../../models'; +import { InvalidateInput, LoaderCache } from '../../../loaders/models'; /** * Options for the admin middleware. diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts index a2ce198ef0..44e5f3b5a1 100644 --- a/packages/angular/src/server/cache/index.ts +++ b/packages/angular/src/server/cache/index.ts @@ -1,23 +1,6 @@ -export type { CacheKeyDimensions, GlobalLoaderCacheConfig } from './models'; +export type { GlobalLoaderCacheConfig } from './models'; export { createLoaderCache } from './loader-cache'; export { createCacheAdminMiddleware, type CacheAdminMiddlewareOptions, -} from './cache-admin-middleware'; -export { - buildCacheKey, - buildPageCacheKey, - buildDictionaryCacheKey, - buildGenericLoaderCacheKey, - serializeLoaderCacheKey, - CACHE_KEY_PREFIX, -} from './cache-key'; -export { buildLoaderCacheTags } from './cache-tags'; -export { - buildSitecoreItemCacheTag, - buildSitecoreDictionaryCacheTag, - buildLoaderDictionaryCacheTag, - buildLoaderDictionaryCacheTagsFromSites, - SITECORE_CONTENT_CACHE_TAG_PREFIX, -} from './cache-tags'; -export { dimensionsFromContext, urlToPathKey } from './utils'; +} from './demo/cache-admin-middleware'; diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index 8b9ae99ee9..fcb93ec03c 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -1,12 +1,12 @@ import { Driver } from 'unstorage'; import { LoaderCacheConfig } from '../../loaders/models'; -/** Default global revalidate TTL (seconds) when {@link LoaderCacheConfig.revalidate} is omitted. @public */ +/** Default global revalidate TTL (seconds) when {@link LoaderCacheConfig.revalidate} is omitted. @internal */ export const DEFAULT_CACHE_TTL = 300; /** * Identity dimensions of a cache key. Derived from {@link LoaderContext} by {@link buildCacheKey}. - * @public + * @internal */ export interface CacheKeyDimensions { /** Site name from route params (defaults to `'default'`). */ @@ -25,6 +25,8 @@ export interface CacheKeyDimensions { * Global config for the loader cache. Consumed by `createLoaderCache()` in * the app's `server.ts`. * + * Moved to separate file to avoid accidental `unstorage` imports in browser-safe code. + * * Drivers are imported and instantiated in the app (e.g. * `fsDriver({ base: './.cache/loaders' })`) — the package does not own driver * selection. When `driver` is omitted, the cache falls back to its built-in diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts index d8b493f1ed..48a315a96e 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts @@ -52,16 +52,6 @@ describe('UnstorageLoaderCache', () => { expect(await cache.invalidate({ tags: [key] })).toBe(0); }); - it('counts already stale entries during invalidate without rewriting them', async () => { - const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); - const key = sampleKey('stale-twice'); - await cache.set(key, { value: 1 }, 300, sampleTags('stale-twice')); - - expect(await cache.invalidate({ tags: [key] })).toBe(1); - expect(await cache.invalidate({ tags: [key] })).toBe(1); - expect((await cache.get(key)).kind).toBe('stale'); - }); - it('omits ghost keys from entries listing', async () => { const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); const key = sampleKey('ghost-entry'); diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index 1123ff4379..d09ef94f90 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -11,8 +11,8 @@ export { DataHandlerConfig, } from './models'; -export { ServerLoaderDataProvider } from './loader-data.provider'; -export { provideServerLoaderDataProvider } from './provide-server-loader-data-provider'; +export { ServerLoaderRunner } from './server-loader-runner'; +export { provideServerLoaderRunner } from './provide-server-loader-runner'; export * from './middleware'; diff --git a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts index 169b6e8e97..2ba8253c34 100644 --- a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts @@ -294,6 +294,7 @@ describe('createLoaderDataServiceMiddleware', () => { it('should serve cached loader data on repeat requests without re-running the loader', async () => { const mockLoader = vi.fn().mockResolvedValue({ title: 'Cached page' }) as LoaderFn; const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); const middleware = createMiddleware({ loaders: { page: mockLoader }, endpoint, @@ -302,7 +303,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/cached-page', params: { site: 'demo' }, query: {} }, + body: { loaderId: 'page', url: '/cached-page', params: { site: 'demo', locale: 'en' }, query: {} }, query: {}, headers: {}, }; @@ -313,6 +314,17 @@ describe('createLoaderDataServiceMiddleware', () => { await middleware(req as any, res2 as any, createMockNext()); expect(mockLoader).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + 'sc:loader:page:demo:en:default:cached-page', + { title: 'Cached page' }, + 300, + expect.arrayContaining([ + 'sc:loader:page:demo:en:default:cached-page', + 'sc:site:demo', + 'sc:locale:en', + ]) + ); expect(res1.json).toHaveBeenCalledWith({ kind: 'data', data: { title: 'Cached page' }, @@ -321,6 +333,7 @@ describe('createLoaderDataServiceMiddleware', () => { kind: 'data', data: { title: 'Cached page' }, }); + setSpy.mockRestore(); }); it('should return 400 when POST body missing loaderId', async () => { diff --git a/packages/angular/src/server/middleware/loader-data-service-middleware.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.ts index b13ac7e576..e341602d51 100644 --- a/packages/angular/src/server/middleware/loader-data-service-middleware.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.ts @@ -14,7 +14,7 @@ import { ExpressResponse, } from '../models'; import { LOADER_DATA_ENDPOINT } from '../constants'; -import { ServerLoaderDataProvider } from '../loader-data.provider'; +import { ServerLoaderRunner } from '../server-loader-runner'; /** * Map loader resolution result to wire-level API response. @@ -110,7 +110,7 @@ export function createLoaderDataServiceMiddleware( options: ExpressDataHandlerOptions ): ExpressMiddleware { const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options; - const serverLoaderData = new ServerLoaderDataProvider(loaders, cache); + const serverLoaderData = new ServerLoaderRunner(loaders, cache); return async ( req: ExpressRequest, diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts index ebfe163820..8f3aafeef0 100644 --- a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts @@ -3,7 +3,6 @@ import { dedupeCacheStrings } from '../cache/utils'; /** * One content change entry as commonly seen in Experience Edge / Content Operations payloads. - * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. * @public */ export type SitecoreEdgeRevalidateUpdate = { @@ -26,7 +25,6 @@ export type SitecoreEdgeRevalidateRequestBody = { /** * Strips Experience Edge style suffixes from an `identifier`. - * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. * @param {string} identifier - Raw identifier from a webhook update row. * @public */ @@ -40,10 +38,6 @@ export function extractSitecoreEdgeContentId(identifier: string): string { const FULL_TAG_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:`; -function isFullSitecoreContentCacheTag(value: string): boolean { - return value.startsWith(FULL_TAG_PREFIX); -} - /** * Options for {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. * @public @@ -58,10 +52,9 @@ export type CollectSitecoreTagsFromEdgeBodyOptions = { * Accepts fully qualified `sc:…` tags in `body.tags`, raw content identifiers * (with optional `-media`/`-layout` suffixes), and `updates[]` rows with * `identifier` + `entity_culture`. - * Authority: `packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts`. - * @param body - Parsed webhook JSON body. - * @param options - Locale fallback when an update omits `entity_culture`. - * @returns Deduplicated Sitecore cache tags ready for {@link LoaderCache.invalidate}. + * @param {SitecoreEdgeRevalidateRequestBody | null | undefined} body - Parsed webhook JSON body. + * @param {CollectSitecoreTagsFromEdgeBodyOptions} options - Locale fallback when an update omits `entity_culture`. + * @returns {string[]} Deduplicated Sitecore cache tags ready for {@link LoaderCache.invalidate}. * @public */ export function collectSitecoreTagsFromEdgeRevalidateRequestBody( @@ -79,7 +72,7 @@ export function collectSitecoreTagsFromEdgeRevalidateRequestBody( if (!s) { continue; } - if (isFullSitecoreContentCacheTag(s)) { + if (s.startsWith(FULL_TAG_PREFIX)) { out.push(s); } else { const id = extractSitecoreEdgeContentId(s); diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts index e18376fd46..e32d4d8041 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts @@ -23,6 +23,7 @@ describe('createSitecoreRevalidateMiddleware', () => { beforeEach(async () => { delete process.env.SITECORE_REVALIDATE_SECRET; + next.mockClear(); cache = createLoaderCache({ revalidate: 300 }); const built = buildCacheKey('page', { url: '/about', @@ -67,6 +68,20 @@ describe('createSitecoreRevalidateMiddleware', () => { expect(res.status).toHaveBeenCalledWith(200); expect((await cache.get(cacheKey)).kind).toBe('stale'); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + revalidated: true, + tagsCount: expect.any(Number), + marked: expect.any(Number), + invocation_id: null, + continues: false, + durationMs: expect.any(Number), + }) + ); + const body = (res.json as ReturnType<typeof vi.fn>).mock.calls[0][0]; + expect(body.tagsCount).toBeGreaterThan(0); + expect(body.marked).toBeGreaterThan(0); + expect(body.durationMs).toBeGreaterThanOrEqual(0); }); it('returns 401 when secret is configured but header mismatches', async () => { @@ -109,4 +124,118 @@ describe('createSitecoreRevalidateMiddleware', () => { expect(next).toHaveBeenCalled(); }); + + it('returns 400 when request body is not a JSON object', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: ['not', 'an', 'object'], + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Request body must be a JSON object.' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 400 when resolved tags are empty', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { updates: [] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: + 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.', + }); + }); + + it('marks dictionary loader entries stale via sites fan-out even without webhook tags', async () => { + const dictBuilt = buildCacheKey('dictionary', { + url: '/', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + const dictKey = dictBuilt.key; + await cache.set(dictKey, { hello: 'world' }, 300, buildLoaderCacheTags('dictionary', dictBuilt.dimensions, dictKey)); + + const middleware = createSitecoreRevalidateMiddleware({ + cache, + defaultLocale: 'en', + sites: [{ name: 'demo', hostName: '*', language: 'en' }], + }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { invocation_id: 'dict-fanout', continues: true }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect((await cache.get(dictKey)).kind).toBe('stale'); + expect(res.json).toHaveBeenCalledWith({ + revalidated: true, + tagsCount: 1, + marked: 1, + invocation_id: 'dict-fanout', + continues: true, + durationMs: expect.any(Number), + }); + }); + + it('returns 500 when cache.invalidate throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failingCache = { + ...cache, + invalidate: vi.fn().mockRejectedValue(new Error('invalidate failed')), + }; + const middleware = createSitecoreRevalidateMiddleware({ cache: failingCache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { tags: ['sc:site:demo'] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error.' }); + errorSpy.mockRestore(); + }); }); diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts index d3a839a383..a6aac48f05 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -12,6 +12,11 @@ const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; const DEFAULT_ENDPOINT = '/api/revalidate'; +/** + * Read a process environment variable + * @param {string} name - The name of the environment variable + * @returns {string | undefined} The value of the environment variable + */ function readProcessEnv(name: string): string | undefined { const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process; return proc?.env?.[name]; @@ -19,7 +24,9 @@ function readProcessEnv(name: string): string | undefined { /** * Returns a non-empty trimmed secret, or `undefined` when unset or whitespace-only. - * Authority: `packages/nextjs/src/route-handler/sitecore-revalidate-route-handler.ts`. + * @param {string | undefined} secretOption - Explicit secret from handler options. + * @param {string | undefined} envValue - Secret from `process.env` (e.g. `SITECORE_REVALIDATE_SECRET`). + * @returns {string | undefined} The resolved secret * @internal */ export function resolveConfiguredRevalidateSecret( @@ -61,6 +68,8 @@ export interface SitecoreRevalidateMiddlewareOptions { * - Calls {@link LoaderCache.invalidate} (marks entries stale; does not delete). * * Response shape: `{ revalidated, tagsCount, marked, invocation_id, continues, durationMs }`. + * @param {SitecoreRevalidateMiddlewareOptions} options - The options for the middleware + * @returns {ExpressMiddleware} The middleware function * @public */ export function createSitecoreRevalidateMiddleware( diff --git a/packages/angular/src/server/provide-server-loader-data-provider.ts b/packages/angular/src/server/provide-server-loader-runner.ts similarity index 71% rename from packages/angular/src/server/provide-server-loader-data-provider.ts rename to packages/angular/src/server/provide-server-loader-runner.ts index 16d95814b5..7be5c2c464 100644 --- a/packages/angular/src/server/provide-server-loader-data-provider.ts +++ b/packages/angular/src/server/provide-server-loader-runner.ts @@ -5,21 +5,21 @@ import { REQUEST_CONTEXT, } from '@angular/core'; import { LOADER_REGISTRY } from '../loaders/loader-registry.token'; -import { SERVER_LOADER_DATA_PROVIDER } from '../loaders/server-loader-data-provider.token'; +import { SERVER_LOADER_RUNNER } from '../loaders/server-loader-runner.token'; import { LoaderCache, LoaderApiRequest } from '../loaders/models'; -import { ServerLoaderDataProvider } from './loader-data.provider'; +import { ServerLoaderRunner } from './server-loader-runner'; /** - * Wires SSR {@link SERVER_LOADER_DATA_PROVIDER} to {@link ServerLoaderDataProvider} + * Wires SSR {@link SERVER_LOADER_DATA_PROVIDER} to {@link ServerLoaderRunner} * using the shared {@link LOADER_REGISTRY}. Include in server application providers * alongside {@link provideLoaderRegistry}. * @returns Environment providers for SSR loader data resolution * @public */ -export function provideServerLoaderDataProvider(): EnvironmentProviders { +export function provideServerLoaderRunner(): EnvironmentProviders { return makeEnvironmentProviders([ { - provide: SERVER_LOADER_DATA_PROVIDER, + provide: SERVER_LOADER_RUNNER, useFactory: () => { const registry = inject(LOADER_REGISTRY); return { @@ -28,7 +28,7 @@ export function provideServerLoaderDataProvider(): EnvironmentProviders { | { cache?: LoaderCache } | undefined; const cache = ssrContext?.cache; - return new ServerLoaderDataProvider(registry, cache).resolve(request); + return new ServerLoaderRunner(registry, cache).resolve(request); }, }; }, diff --git a/packages/angular/src/server/loader-data.provider.spec.ts b/packages/angular/src/server/server-loader-runner.spec.ts similarity index 77% rename from packages/angular/src/server/loader-data.provider.spec.ts rename to packages/angular/src/server/server-loader-runner.spec.ts index 072deaa169..82c38743df 100644 --- a/packages/angular/src/server/loader-data.provider.spec.ts +++ b/packages/angular/src/server/server-loader-runner.spec.ts @@ -1,11 +1,11 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ServerLoaderDataProvider } from './loader-data.provider'; +import { ServerLoaderRunner } from './server-loader-runner'; import type { LoaderCache, LoaderFn } from '../loaders/models'; import { createLoaderCache } from './cache/loader-cache'; import { buildCacheKey } from './cache/cache-key'; -describe('ServerLoaderDataProvider', () => { +describe('ServerLoaderRunner', () => { const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); beforeEach(() => { @@ -14,7 +14,7 @@ describe('ServerLoaderDataProvider', () => { }); it('should return error when loader id is not in registry', async () => { - const provider = new ServerLoaderDataProvider({}); + const provider = new ServerLoaderRunner({}); const result = await provider.resolve({ loaderId: 'missing', url: '/path', @@ -29,7 +29,7 @@ describe('ServerLoaderDataProvider', () => { }); it('should invoke loader and return data on cache miss', async () => { - const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const provider = new ServerLoaderRunner({ page: pageLoader }); const result = await provider.resolve({ loaderId: 'page', url: '/about', @@ -59,7 +59,7 @@ describe('ServerLoaderDataProvider', () => { getConfig: vi.fn(), }; - const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); const result = await provider.resolve({ loaderId: 'page', url: '/cached', @@ -76,7 +76,7 @@ describe('ServerLoaderDataProvider', () => { loaderRedirectTarget: '/other', status: 302, }); - const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const provider = new ServerLoaderRunner({ page: pageLoader }); const result = await provider.resolve({ loaderId: 'page', url: '/redirect', @@ -93,7 +93,7 @@ describe('ServerLoaderDataProvider', () => { it('should return error with cause when loader throws', async () => { const err = new Error('Loader failed'); vi.mocked(pageLoader).mockRejectedValueOnce(err); - const provider = new ServerLoaderDataProvider({ page: pageLoader }); + const provider = new ServerLoaderRunner({ page: pageLoader }); const result = await provider.resolve({ loaderId: 'page', url: '/fail', @@ -121,7 +121,7 @@ describe('ServerLoaderDataProvider', () => { getConfig: vi.fn(), }; - const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); await provider.resolve({ loaderId: 'page', url: '/store', @@ -145,7 +145,7 @@ describe('ServerLoaderDataProvider', () => { getConfig: vi.fn(), }; - const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); await provider.resolve({ loaderId: 'page', url: '/live', @@ -166,7 +166,7 @@ describe('ServerLoaderDataProvider', () => { it('should use the cache for a route that opts in even when global caching is disabled', async () => { const cache = createLoaderCache({ enabled: false, revalidate: 300 }); - const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); const request = { loaderId: 'page', url: '/featured', @@ -181,6 +181,52 @@ describe('ServerLoaderDataProvider', () => { expect(pageLoader).toHaveBeenCalledTimes(1); }); + it('should pass cacheOptions.revalidate as TTL to cache.set', async () => { + const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/ttl-override', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, revalidate: 60 }, + }; + + await provider.resolve(request); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + expect.any(String), + { title: 'Page' }, + 60, + expect.any(Array) + ); + setSpy.mockRestore(); + }); + + it('should merge cacheOptions.tags into tags passed to cache.set', async () => { + const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/tagged', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, tags: ['featured', 'campaign-x'] }, + }; + + await provider.resolve(request); + + expect(setSpy).toHaveBeenCalledTimes(1); + const tags = setSpy.mock.calls[0][3] as string[]; + expect(tags).toContain('featured'); + expect(tags).toContain('campaign-x'); + expect(tags).toContain('sc:site:demo'); + setSpy.mockRestore(); + }); + it('should not cache redirect responses', async () => { vi.mocked(pageLoader).mockResolvedValueOnce({ loaderRedirectTarget: '/login', @@ -198,7 +244,7 @@ describe('ServerLoaderDataProvider', () => { getConfig: vi.fn(), }; - const provider = new ServerLoaderDataProvider({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); const result = await provider.resolve({ loaderId: 'page', url: '/protected', @@ -214,7 +260,7 @@ describe('ServerLoaderDataProvider', () => { let version = 1; const loader = vi.fn(async () => ({ title: `v${version++}` })); const cache = createLoaderCache({ revalidate: 300 }); - const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const provider = new ServerLoaderRunner({ page: loader }, cache); const request = { loaderId: 'page', url: '/about', @@ -243,7 +289,7 @@ describe('ServerLoaderDataProvider', () => { let version = 1; const loader = vi.fn(async () => ({ title: `v${version++}` })); const cache = createLoaderCache({ revalidate: 300 }); - const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const provider = new ServerLoaderRunner({ page: loader }, cache); const request = { loaderId: 'page', url: '/coalesce', @@ -280,7 +326,7 @@ describe('ServerLoaderDataProvider', () => { getConfig: vi.fn(), }; - const provider = new ServerLoaderDataProvider({ page: loader }, cache); + const provider = new ServerLoaderRunner({ page: loader }, cache); const result = await provider.resolve({ loaderId: 'page', url: '/warn', diff --git a/packages/angular/src/server/loader-data.provider.ts b/packages/angular/src/server/server-loader-runner.ts similarity index 76% rename from packages/angular/src/server/loader-data.provider.ts rename to packages/angular/src/server/server-loader-runner.ts index 8f5b86ba05..f722157642 100644 --- a/packages/angular/src/server/loader-data.provider.ts +++ b/packages/angular/src/server/server-loader-runner.ts @@ -10,7 +10,8 @@ import { buildCacheKey } from './cache/cache-key'; import { buildLoaderCacheTags } from './cache/cache-tags'; /** - * Server-side loader data provider with stale-while-revalidate cache reads (Phase 3). + * Server-side cache aware loader data resolver. + * {@link LoaderResolver} is exposed to both server and browser. This layer ensures browser safety and acts as connecting layer to cache. * * Resolution order when a {@link LoaderCache} is attached: * 1. **hit** — return cached value immediately. @@ -23,20 +24,20 @@ import { buildLoaderCacheTags } from './cache/cache-tags'; * the global cache is disabled. * @public */ -export class ServerLoaderDataProvider { +export class ServerLoaderRunner { /** Process-wide coalescing for stale-while-revalidate background refreshes. */ private static readonly pendingCacheOps = new Set<string>(); /** - * @param registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware. - * @param cache - Optional cache instance from {@link createLoaderCache}. + * @param {LoaderRegistry} registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware. + * @param {LoaderCache | undefined} cache - Optional cache instance from {@link createLoaderCache}. */ constructor(private readonly registry: LoaderRegistry, private readonly cache?: LoaderCache) {} /** * Resolve loader data with optional cache read-through and SWR refresh. - * @param request - Loader id, URL, params, optional request context and cache overrides. - * @returns Data, redirect, or error result for the middleware / SSR resolver. + * @param {LoaderApiRequest} request - Loader id, URL, params, optional request context and cache overrides. + * @returns {Promise<LoaderDataResult>} Data, redirect, or error result for the middleware / SSR resolver. */ async resolve(request: LoaderApiRequest): Promise<LoaderDataResult> { const { loaderId, url, params, query, angularRequestContext, cacheOptions } = request; @@ -63,20 +64,26 @@ export class ServerLoaderDataProvider { } } - return this.runLoader({ request, ctx, cacheable: !!cacheable }); + return this.runLoader({ request, ctx, cacheable: !!cacheable, cacheOptions }); } - /** Fire-and-forget SWR refresh; skipped when a refresh is already in flight for the key. */ + /** + * Fire-and-forget SWR refresh; skipped when a refresh is already in flight for the key. + * @param {LoaderApiRequest} request - The loader request + * @param {LoaderContext} ctx - The loader context + * @param {string} cacheKey - The cache key + * @param {LoaderApiRequest['cacheOptions']} cacheOptions - The cache options + */ private scheduleBackgroundRefresh( request: LoaderApiRequest, ctx: LoaderContext, cacheKey: string, cacheOptions: LoaderApiRequest['cacheOptions'] ): void { - if (ServerLoaderDataProvider.pendingCacheOps.has(cacheKey)) { + if (ServerLoaderRunner.pendingCacheOps.has(cacheKey)) { return; } - ServerLoaderDataProvider.pendingCacheOps.add(cacheKey); + ServerLoaderRunner.pendingCacheOps.add(cacheKey); void this.runLoader({ request, ctx, @@ -85,10 +92,10 @@ export class ServerLoaderDataProvider { knownCacheKey: cacheKey, }).then( () => { - ServerLoaderDataProvider.pendingCacheOps.delete(cacheKey); + ServerLoaderRunner.pendingCacheOps.delete(cacheKey); }, () => { - ServerLoaderDataProvider.pendingCacheOps.delete(cacheKey); + ServerLoaderRunner.pendingCacheOps.delete(cacheKey); } ); } diff --git a/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs index ff91826627..3b8587411e 100644 --- a/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs +++ b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs @@ -55,7 +55,6 @@ export default tseslint.config( 'no-underscore-dangle': 'off', // Sitecore field directives populate host element content at runtime '@angular-eslint/template/elements-content': 'off', - // Navigation matches kit-nextjs-skate-park (click on title row / delegated nav close) '@angular-eslint/template/click-events-have-key-events': 'off', '@angular-eslint/template/interactive-supports-focus': 'off', }, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts index 6297181b82..67fb56b97b 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts @@ -2,10 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; -import { provideServerLoaderDataProvider } from '@sitecore-content-sdk/angular'; +import { provideServerLoaderRunner } from '@sitecore-content-sdk/angular'; const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(withRoutes(serverRoutes)), provideServerLoaderDataProvider()], + providers: [provideServerRendering(withRoutes(serverRoutes)), provideServerLoaderRunner()], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts index 871d2505e3..4088eb46a6 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts @@ -5,7 +5,7 @@ import { provideLoaderRegistry, handleNavigationError, provideSitecoreAngular, - PreLoaderDataService, + ClientPreLoaderDataService, SITECORE_COMPONENT_MAP, SitecoreTranslateLoader, LocaleUrlSerializer, @@ -35,7 +35,7 @@ export const appConfig: ApplicationConfig = { sitecoreClient: getClient(), }), provideLoaderRegistry(LOADERS), - PreLoaderDataService, + ClientPreLoaderDataService, { provide: SITECORE_COMPONENT_MAP, useValue: componentMap }, { provide: TranslateLoader, useClass: SitecoreTranslateLoader }, // provides locale aware serializer for csdk and angular router links From 72bfb76ea1208b4fa5ef23359df20f856d4e0ff9 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Tue, 2 Jun 2026 16:30:19 -0400 Subject: [PATCH 13/14] PR comments --- packages/angular/src/config/define-config.ts | 14 +- .../src/loaders/client-loader-data.service.ts | 4 - packages/angular/src/loaders/models.ts | 4 +- .../src/server/cache/cache.spec-helpers.ts | 4 +- .../cache/default-in-memory-cache.spec.ts | 81 -------- .../server/cache/default-in-memory-cache.ts | 190 ------------------ .../cache/demo/cache-admin-middleware.ts | 9 +- .../src/server/cache/loader-cache.spec.ts | 11 +- .../angular/src/server/cache/loader-cache.ts | 63 +++--- .../cache/unstorage-loader-cache.spec.ts | 4 +- .../server/cache/unstorage-loader-cache.ts | 14 +- .../angular/src/server/cache/utils.spec.ts | 20 +- packages/angular/src/server/cache/utils.ts | 17 +- .../sitecore-edge-webhook-revalidation.ts | 13 +- .../sitecore-revalidate-middleware.ts | 26 +-- packages/angular/src/server/models.ts | 1 - .../src/server/server-loader-runner.spec.ts | 29 +-- .../src/server/server-loader-runner.ts | 4 +- .../src/templates/angular/src/server.ts | 4 +- 19 files changed, 122 insertions(+), 390 deletions(-) delete mode 100644 packages/angular/src/server/cache/default-in-memory-cache.spec.ts delete mode 100644 packages/angular/src/server/cache/default-in-memory-cache.ts diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index 7c81af6a8b..a8c8451086 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -36,7 +36,7 @@ export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'r * Configuration for the ISR-like cache. Both fields default when omitted * (`enabled: true`, `revalidate: 300`). */ - isrCache?: { + loadersCache?: { /** Whether the cache is enabled. */ enabled?: boolean; /** The global revalidate time in seconds. */ @@ -61,14 +61,14 @@ export interface AngularSitecoreConfig extends Omit<SitecoreConfig, 'redirects'> * Resolved configuration for the ISR-like cache. Defaults are applied by * `defineConfig`: `enabled: true`, `revalidate: 300`. */ - isrCache: { + loadersCache: { enabled: boolean; revalidate: number; }; }; } -/** Defaults applied to `angular.isrCache` when input omits fields. */ +/** Defaults applied to `angular.loadersCache` when input omits fields. */ const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 } as const; /** @@ -117,13 +117,13 @@ export function defineConfig( scConfig.redirects.locales = locales; - const isrCache = { - enabled: angular?.isrCache?.enabled ?? DEFAULT_ISR_CACHE.enabled, - revalidate: angular?.isrCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate, + const loadersCache = { + enabled: angular?.loadersCache?.enabled ?? DEFAULT_ISR_CACHE.enabled, + revalidate: angular?.loadersCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate, }; return { ...scConfig, - angular: { locales, isrCache }, + angular: { locales, loadersCache }, } as AngularSitecoreConfig; } diff --git a/packages/angular/src/loaders/client-loader-data.service.ts b/packages/angular/src/loaders/client-loader-data.service.ts index 7bd09bc5c3..60ddab566a 100644 --- a/packages/angular/src/loaders/client-loader-data.service.ts +++ b/packages/angular/src/loaders/client-loader-data.service.ts @@ -127,7 +127,6 @@ export class ClientLoaderDataService { query: request.query ?? {}, cacheOptions: request.cacheOptions, }; - console.log('DEBUG: ClientLoaderDataService fetchData', endpoint, reqBody); try { const resp = await firstValueFrom( @@ -135,18 +134,15 @@ export class ClientLoaderDataService { ); if (!resp) { const message = `No response from ${endpoint}`; - console.log(`DEBUG: ClientLoaderDataService fetchData: ${message}`); return { kind: 'error', status: 500, message } as LoaderApiResponse; } if (resp.kind === 'data') { - console.log('DEBUG: ClientLoaderDataService fetchData: data', resp.data); this.prefetchedResponses.set(key, resp); } else if (resp.kind === 'redirect') { this.prefetchedResponses.set(key, resp); } return resp; } catch (error) { - console.log('DEBUG: ClientLoaderDataService fetchData: error', error); const message = error instanceof Error ? error.message : 'Fetch failed'; return { kind: 'error', status: 500, message } as LoaderApiResponse; } finally { diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index c1a554a669..a937fd6bec 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -281,9 +281,9 @@ export interface LoaderCache { /** Returns lightweight metadata for admin tooling (values are omitted). */ entries(): Promise<LoaderCacheEntryInfo[]>; /** Global default TTL in seconds from {@link LoaderCacheConfig.revalidate}. */ - resolveTtl(): number; + get ttl(): number; /** Whether caching is enabled globally. Per-route overrides may still opt in. */ enabled(): boolean; /** Resolved configuration (useful for admin UI and diagnostics). */ - getConfig(): Readonly<LoaderCacheConfig>; + get config(): Readonly<LoaderCacheConfig>; } diff --git a/packages/angular/src/server/cache/cache.spec-helpers.ts b/packages/angular/src/server/cache/cache.spec-helpers.ts index e177bfed04..12c9ead165 100644 --- a/packages/angular/src/server/cache/cache.spec-helpers.ts +++ b/packages/angular/src/server/cache/cache.spec-helpers.ts @@ -149,8 +149,8 @@ export function runSharedLoaderCacheContract( it('exposes resolved config and ttl', () => { expect(cache.enabled()).toBe(true); - expect(cache.resolveTtl()).toBe(300); - expect(cache.getConfig()).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); + expect(cache.ttl).toBe(300); + expect(cache.config).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); }); }); } diff --git a/packages/angular/src/server/cache/default-in-memory-cache.spec.ts b/packages/angular/src/server/cache/default-in-memory-cache.spec.ts deleted file mode 100644 index 80ea44820e..0000000000 --- a/packages/angular/src/server/cache/default-in-memory-cache.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -import { describe, it, expect } from 'vitest'; -import { InMemoryLoaderCache, InMemoryTagIndex } from './default-in-memory-cache'; -import { runSharedLoaderCacheContract, sampleKey, sampleTags } from './cache.spec-helpers'; - -describe('InMemoryTagIndex', () => { - it('links keys under multiple tags and resolves the union', () => { - const index = new InMemoryTagIndex(); - index.link('key-a', ['sc:site:demo', 'sc:locale:en']); - index.link('key-b', ['sc:site:demo']); - - expect(index.resolveKeys(['sc:site:demo']).has('key-a')).toBe(true); - expect(index.resolveKeys(['sc:site:demo']).has('key-b')).toBe(true); - expect(index.resolveKeys(['sc:locale:en']).has('key-a')).toBe(true); - expect(index.resolveKeys(['sc:locale:en']).has('key-b')).toBe(false); - }); - - it('unlinks keys and removes empty tag buckets', () => { - const index = new InMemoryTagIndex(); - index.link('key-a', ['sc:site:demo']); - index.unlink('key-a', ['sc:site:demo']); - - expect(index.resolveKeys(['sc:site:demo']).size).toBe(0); - }); - - it('clears all tag buckets', () => { - const index = new InMemoryTagIndex(); - index.link('key-a', ['sc:site:demo', 'sc:locale:en']); - index.clear(); - - expect(index.resolveKeys(['sc:site:demo']).size).toBe(0); - expect(index.resolveKeys(['sc:locale:en']).size).toBe(0); - }); -}); - -describe('InMemoryLoaderCache', () => { - runSharedLoaderCacheContract( - 'InMemoryLoaderCache', - () => new InMemoryLoaderCache({ revalidate: 300, defaultSiteName: 'default' }) - ); - - it('applies config defaults from the constructor', () => { - const cache = new InMemoryLoaderCache({ revalidate: 60, enabled: false }); - expect(cache.resolveTtl()).toBe(60); - expect(cache.enabled()).toBe(false); - expect(cache.getConfig()).toMatchObject({ - revalidate: 60, - enabled: false, - defaultSiteName: 'default', - defaultLocale: 'en', - }); - }); - - it('markStale returns false for missing keys', async () => { - const cache = new InMemoryLoaderCache({ revalidate: 300 }); - expect(await cache.markStale('missing-key')).toBe(false); - }); - - it('markStale returns true for already stale entries without rewriting them', async () => { - const cache = new InMemoryLoaderCache({ revalidate: 300 }); - const key = sampleKey('already-stale'); - await cache.set(key, { value: true }, 300, sampleTags('already-stale')); - await cache.markStale(key); - - const before = await cache.get(key); - expect(await cache.markStale(key)).toBe(true); - const after = await cache.get(key); - expect(after).toEqual(before); - }); - - it('does not leave stale tag pointers after delete', async () => { - const cache = new InMemoryLoaderCache({ revalidate: 300 }); - const key = sampleKey('deleted-tag'); - const tag = 'sc:site:deleted'; - - await cache.set(key, { value: true }, 300, [tag, key]); - await cache.delete(key); - - expect(await cache.invalidate({ tags: [tag] })).toBe(0); - }); -}); diff --git a/packages/angular/src/server/cache/default-in-memory-cache.ts b/packages/angular/src/server/cache/default-in-memory-cache.ts deleted file mode 100644 index 74c017975a..0000000000 --- a/packages/angular/src/server/cache/default-in-memory-cache.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { - InvalidateInput, - LoaderCache, - LoaderCacheConfig, - LoaderCacheEntry, - LoaderCacheEntryInfo, - LoaderCacheReadResult, -} from '../../loaders/models'; -import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; - -/** - * In-process tag index mapping OSR tags to cache keys. - * Maintained alongside {@link InMemoryLoaderCache} entries for O(1) tag invalidation. - * @internal - */ -export class InMemoryTagIndex { - private readonly tagToKeys = new Map<string, Set<string>>(); - - /** - * Registers `cacheKey` under each tag in the index. - * @param {string} cacheKey - Cache entry key. - * @param {string[]} tags - Tags to link. - */ - link(cacheKey: string, tags: string[]): void { - for (const tag of tags) { - if (!this.tagToKeys.has(tag)) { - this.tagToKeys.set(tag, new Set()); - } - this.tagToKeys.get(tag)!.add(cacheKey); - } - } - - /** - * Removes `cacheKey` from each tag bucket; deletes empty buckets. - * @param {string} cacheKey - Cache entry key. - * @param {string[]} tags - Tags to unlink. - */ - unlink(cacheKey: string, tags: string[]): void { - for (const tag of tags) { - const keys = this.tagToKeys.get(tag); - keys?.delete(cacheKey); - if (keys?.size === 0) { - this.tagToKeys.delete(tag); - } - } - } - - /** - * Union of cache keys linked to any of the supplied tags. - * @param {string[]} tags - Tags to resolve. - * @returns {Set<string>} Matching cache keys. - */ - resolveKeys(tags: string[]): Set<string> { - const out = new Set<string>(); - for (const tag of tags) { - for (const key of this.tagToKeys.get(tag) ?? []) { - out.add(key); - } - } - return out; - } - - /** Clears every tag bucket. */ - clear(): void { - this.tagToKeys.clear(); - } -} - -/** - * Default {@link LoaderCache} implementation: in-process `Map` plus {@link InMemoryTagIndex}. - * Suitable for single-process dev and tests. For persistence or multi-instance deploys, - * pass an unstorage driver to {@link createLoaderCache} instead. - * @internal - */ -export class InMemoryLoaderCache implements LoaderCache { - private readonly config: Required<LoaderCacheConfig>; - private readonly store = new Map<string, LoaderCacheEntry>(); - private readonly tagIndex = new InMemoryTagIndex(); - - /** - * @param {LoaderCacheConfig} [config] - Partial cache configuration. - */ - constructor(config: LoaderCacheConfig = {}) { - this.config = applyLoaderCacheConfigDefaults(config); - } - - /** @inheritdoc */ - async get(key: string): Promise<LoaderCacheReadResult> { - const entry = this.store.get(key); - return evaluateCacheRead(key, entry ?? null); - } - - /** @inheritdoc */ - async set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise<void> { - const existing = this.store.get(key); - if (existing) { - this.tagIndex.unlink(key, existing.tags); - } - - const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; - this.store.set(key, { - value, - tags: [...tags], - storedAt: Date.now(), - expiresAt, - stale: false, - }); - this.tagIndex.link(key, tags); - } - - /** @inheritdoc */ - async invalidate(filter: InvalidateInput): Promise<number> { - const tags = filter.tags ?? []; - if (tags.length === 0) { - return 0; - } - const keys = this.tagIndex.resolveKeys(tags); - let marked = 0; - for (const key of keys) { - if (await this.markStale(key)) { - marked++; - } - } - return marked; - } - - /** - * Marks a single entry stale without deleting it (SWR semantics). - * @param {string} key - Cache entry key. - * @returns {boolean} `false` when missing; `true` when the entry exists (including already stale). - */ - async markStale(key: string): Promise<boolean> { - const entry = this.store.get(key); - if (!entry) { - return false; - } - if (entry.stale) { - return true; - } - this.store.set(key, { ...entry, stale: true }); - return true; - } - - /** @inheritdoc */ - async delete(key: string): Promise<boolean> { - const entry = this.store.get(key); - if (!entry) { - return false; - } - this.tagIndex.unlink(key, entry.tags); - this.store.delete(key); - return true; - } - - /** @inheritdoc */ - async flush(): Promise<void> { - this.store.clear(); - this.tagIndex.clear(); - } - - /** @inheritdoc */ - async entries(): Promise<LoaderCacheEntryInfo[]> { - const out: LoaderCacheEntryInfo[] = []; - for (const [key, entry] of this.store) { - out.push({ - key, - tags: [...entry.tags], - storedAt: entry.storedAt, - expiresAt: entry.expiresAt, - stale: entry.stale, - }); - } - return out; - } - - /** @inheritdoc */ - resolveTtl(): number { - return this.config.revalidate; - } - - /** @inheritdoc */ - enabled(): boolean { - return this.config.enabled; - } - - /** @inheritdoc */ - getConfig(): Readonly<LoaderCacheConfig> { - return this.config; - } -} diff --git a/packages/angular/src/server/cache/demo/cache-admin-middleware.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts index 165a6136c2..cc8c1e930c 100644 --- a/packages/angular/src/server/cache/demo/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts @@ -3,7 +3,12 @@ * This middleware is only used for testing and should be removed before release. * TODO: Remove this middleware before release. */ -import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../../models'; +import { + ExpressMiddleware, + ExpressNextFunction, + ExpressRequest, + ExpressResponse, +} from '../../models'; import { InvalidateInput, LoaderCache } from '../../../loaders/models'; /** @@ -59,7 +64,7 @@ export function createCacheAdminMiddleware( } if (action === 'config' && req.method === 'GET') { - res.status(200).json({ ...cache.getConfig() }); + res.status(200).json({ ...cache.config }); return; } diff --git a/packages/angular/src/server/cache/loader-cache.spec.ts b/packages/angular/src/server/cache/loader-cache.spec.ts index eb2bc90660..efe5c7e236 100644 --- a/packages/angular/src/server/cache/loader-cache.spec.ts +++ b/packages/angular/src/server/cache/loader-cache.spec.ts @@ -2,14 +2,19 @@ import { describe, it, expect } from 'vitest'; import memoryDriver from 'unstorage/drivers/memory'; import { createLoaderCache } from './loader-cache'; -import { InMemoryLoaderCache } from './default-in-memory-cache'; import { UnstorageLoaderCache } from './unstorage-loader-cache'; import { sampleKey, sampleTags } from './cache.spec-helpers'; +function getStorage(cache: UnstorageLoaderCache): Storage { + return (cache as unknown as { storage: Storage }).storage; +} + describe('createLoaderCache factory', () => { - it('returns an InMemoryLoaderCache when no driver is supplied', () => { + it('returns a UnstorageLoaderCache with memory driver when no driver is supplied', async () => { const cache = createLoaderCache(); - expect(cache).toBeInstanceOf(InMemoryLoaderCache); + expect(cache).toBeInstanceOf(UnstorageLoaderCache); + const mount = await getStorage(cache as UnstorageLoaderCache).getMount(); + expect(mount?.driver.name).toBe('memory'); }); it('returns an UnstorageLoaderCache when a driver is supplied', () => { diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index 3f25ddfc7d..15a4c2402b 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,33 +1,30 @@ -import { LoaderCache } from '../../loaders/models'; -import { GlobalLoaderCacheConfig } from './models'; -import { InMemoryLoaderCache } from './default-in-memory-cache'; -import { UnstorageLoaderCache } from './unstorage-loader-cache'; -import { resolveConfig } from './utils'; - -/** - * Public factory for the loader cache. Dispatches to the right backend: - * - `config.driver` provided → {@link UnstorageLoaderCache} wrapping the driver in `createStorage({ driver })` - * - otherwise → {@link InMemoryLoaderCache} (plain Map) - * - * Drivers are imported and constructed in the app's `server.ts` and passed here as an instance. - * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. - * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. - * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. - * @example - * ```ts - * const cache = createLoaderCache({ - * revalidate: config.angular.isrCache.revalidate, - * enabled: config.angular.isrCache.enabled, - * defaultSiteName: config.defaultSite, - * driver: fsDriver({ base: './.cache/loaders' }), - * }); - * ``` - * @public - */ -export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { - const resolved = resolveConfig(config); - if (config.driver) { - return new UnstorageLoaderCache(config.driver, resolved); - } - return new InMemoryLoaderCache(resolved); -} +import { LoaderCache } from '../../loaders/models'; +import { GlobalLoaderCacheConfig } from './models'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { resolveConfig } from './utils'; +import memoryDriver from 'unstorage/drivers/memory'; + +/** + * Public factory for the loader cache with unstorage backing. + * Uses the memory driver by default. + * + * Drivers are best imported and constructed in the app's `server.ts` and passed here as an instance. + * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. + * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. + * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. + * @example + * ```ts + * const cache = createLoaderCache({ + * revalidate: config.angular.loadersCache.revalidate, + * enabled: config.angular.loadersCache.enabled, + * defaultSiteName: config.defaultSite, + * driver: fsDriver({ base: './.cache/loaders' }), + * }); + * ``` + * @public + */ +export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { + const resolved = resolveConfig(config); + const driver = config.driver ?? memoryDriver(); + return new UnstorageLoaderCache(driver, resolved); +} diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts index 48a315a96e..b8ea225998 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts @@ -33,9 +33,9 @@ describe('UnstorageLoaderCache', () => { it('applies config defaults from the constructor', () => { const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 120, enabled: false }); - expect(cache.resolveTtl()).toBe(120); + expect(cache.ttl).toBe(120); expect(cache.enabled()).toBe(false); - expect(cache.getConfig()).toMatchObject({ revalidate: 120, enabled: false }); + expect(cache.config).toMatchObject({ revalidate: 120, enabled: false }); }); it('returns false when deleting a missing key', async () => { diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index 93e6b6c712..714c52b880 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -23,7 +23,7 @@ const TAG_INDEX_PREFIX = 'tag:'; */ export class UnstorageLoaderCache implements LoaderCache { private readonly storage: Storage; - private readonly config: Required<LoaderCacheConfig>; + private readonly _config: Required<LoaderCacheConfig>; /** * @param {Driver} driver - Unstorage driver instance from the app (`server.ts`). @@ -31,7 +31,7 @@ export class UnstorageLoaderCache implements LoaderCache { */ constructor(driver: Driver, config: LoaderCacheConfig = {}) { this.storage = createStorage({ driver }); - this.config = applyLoaderCacheConfigDefaults(config); + this._config = applyLoaderCacheConfigDefaults(config); } /** @inheritdoc */ @@ -117,18 +117,18 @@ export class UnstorageLoaderCache implements LoaderCache { } /** @inheritdoc */ - resolveTtl(): number { - return this.config.revalidate; + get ttl(): number { + return this._config.revalidate; } /** @inheritdoc */ enabled(): boolean { - return this.config.enabled; + return this._config.enabled; } /** @inheritdoc */ - getConfig(): Readonly<GlobalLoaderCacheConfig> { - return this.config; + get config(): Readonly<GlobalLoaderCacheConfig> { + return this._config; } /** diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts index 15cb7c7b11..f2404a7507 100644 --- a/packages/angular/src/server/cache/utils.spec.ts +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -1,6 +1,16 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect } from 'vitest'; -import { approxByteSize, dimensionsFromContext, resolveConfig, applyLoaderCacheConfigDefaults, urlToPathKey, evaluateCacheRead, sanitizeSitecoreCacheSegment, normalizeSitecoreItemIdForCacheKey, dedupeCacheStrings } from './utils'; +import { + approxByteSize, + dimensionsFromContext, + resolveConfig, + applyLoaderCacheConfigDefaults, + urlToPathKey, + evaluateCacheRead, + sanitizeSitecoreCacheSegment, + normalizeSitecoreItemIdForCacheKey, + dedupeCacheStrings, +} from './utils'; import { DEFAULT_CACHE_TTL } from './models'; describe('urlToPathKey', () => { @@ -97,7 +107,13 @@ describe('evaluateCacheRead', () => { expect( evaluateCacheRead( 'sc:key', - { value: { old: true }, tags: [], storedAt: now - 120_000, expiresAt: now - 1, stale: false }, + { + value: { old: true }, + tags: [], + storedAt: now - 120_000, + expiresAt: now - 1, + stale: false, + }, now ) ).toEqual({ kind: 'stale', value: { old: true }, cacheKey: 'sc:key' }); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 81407e7839..65d5b5dc14 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -8,9 +8,10 @@ import { GlobalLoaderCacheConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from ' /** * Approximate serialized byte size of a cache value (demo/admin helper). + * Only used for demo purposes. Remove before release. + * TODO: Remove before release. * @param {unknown} value - Value to measure. * @returns {number} JSON string length, or `0` when serialization fails. - * @deprecated Only used for demo purposes. Remove before release. */ export function approxByteSize(value: unknown): number { try { @@ -88,8 +89,7 @@ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): Cac * @internal */ export function resolveConfig(config: GlobalLoaderCacheConfig): LoaderCacheConfig { - const { driver, ...rest } = config; - void driver; + const { driver: _driver, ...rest } = config; return rest; } @@ -164,13 +164,6 @@ export function normalizeSitecoreItemIdForCacheKey(itemId: string): string { * @internal */ export function dedupeCacheStrings(values: string[]): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const value of values) { - if (!seen.has(value)) { - seen.add(value); - out.push(value); - } - } - return out; + const dedupedSet = new Set<string>(values); + return Array.from(dedupedSet); } diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts index 8f3aafeef0..1f4f7d2d97 100644 --- a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts @@ -64,18 +64,17 @@ export function collectSitecoreTagsFromEdgeRevalidateRequestBody( const { defaultLocale } = options; const out: string[] = []; - for (const raw of body?.tags ?? []) { - if (typeof raw !== 'string') { + for (const tag of body?.tags ?? []) { + if (typeof tag !== 'string') { continue; } - const s = raw.trim(); - if (!s) { + if (!tag) { continue; } - if (s.startsWith(FULL_TAG_PREFIX)) { - out.push(s); + if (tag.startsWith(FULL_TAG_PREFIX)) { + out.push(tag); } else { - const id = extractSitecoreEdgeContentId(s); + const id = extractSitecoreEdgeContentId(tag); if (id) { out.push(buildSitecoreItemCacheTag({ itemId: id, locale: defaultLocale })); } diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts index a6aac48f05..c3d7faad4b 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -7,21 +7,11 @@ import { collectSitecoreTagsFromEdgeRevalidateRequestBody, type SitecoreEdgeRevalidateRequestBody, } from './sitecore-edge-webhook-revalidation'; - +import { readProcessEnv } from '../utils'; const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; const DEFAULT_ENDPOINT = '/api/revalidate'; -/** - * Read a process environment variable - * @param {string} name - The name of the environment variable - * @returns {string | undefined} The value of the environment variable - */ -function readProcessEnv(name: string): string | undefined { - const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process; - return proc?.env?.[name]; -} - /** * Returns a non-empty trimmed secret, or `undefined` when unset or whitespace-only. * @param {string | undefined} secretOption - Explicit secret from handler options. @@ -96,14 +86,12 @@ export function createSitecoreRevalidateMiddleware( readProcessEnv(DEFAULT_SECRET_ENV_VAR) ); - if (configuredSecret) { - const headers = req.headers as Record<string, string | string[] | undefined>; - const providedSecret = headers[DEFAULT_SECRET_HEADER]; - const headerValue = Array.isArray(providedSecret) ? providedSecret[0] : providedSecret; - if (headerValue !== configuredSecret) { - res.status(401).json({ error: 'Unauthorized.' }); - return; - } + const headers = req.headers as Record<string, string | string[] | undefined>; + const providedSecret = headers[DEFAULT_SECRET_HEADER]; + const headerValue = Array.isArray(providedSecret) ? providedSecret[0] : providedSecret; + if (headerValue !== configuredSecret) { + res.status(401).json({ error: 'Unauthorized.' }); + return; } const body = req.body; diff --git a/packages/angular/src/server/models.ts b/packages/angular/src/server/models.ts index fa1b84dd25..4e534646ad 100644 --- a/packages/angular/src/server/models.ts +++ b/packages/angular/src/server/models.ts @@ -70,7 +70,6 @@ export type ExpressMiddleware = ( /** * @public - * @deprecated Import {@link LoaderRegistry} from `@sitecore-content-sdk/angular` loader registry exports instead. */ export type { LoaderRegistry } from '../loaders/loader-registry.token'; diff --git a/packages/angular/src/server/server-loader-runner.spec.ts b/packages/angular/src/server/server-loader-runner.spec.ts index 82c38743df..e4861b8202 100644 --- a/packages/angular/src/server/server-loader-runner.spec.ts +++ b/packages/angular/src/server/server-loader-runner.spec.ts @@ -8,7 +8,7 @@ import { buildCacheKey } from './cache/cache-key'; describe('ServerLoaderRunner', () => { const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); - beforeEach(() => { + beforeEach(async () => { vi.mocked(pageLoader).mockClear(); vi.mocked(pageLoader).mockResolvedValue({ title: 'Page' }); }); @@ -54,9 +54,9 @@ describe('ServerLoaderRunner', () => { delete: vi.fn(), flush: vi.fn(), entries: vi.fn(), - resolveTtl: vi.fn().mockReturnValue(300), + ttl: 300, enabled: vi.fn().mockReturnValue(true), - getConfig: vi.fn(), + config: {}, }; const provider = new ServerLoaderRunner({ page: pageLoader }, cache); @@ -116,9 +116,9 @@ describe('ServerLoaderRunner', () => { delete: vi.fn(), flush: vi.fn(), entries: vi.fn(), - resolveTtl: vi.fn().mockReturnValue(300), + ttl: 300, enabled: vi.fn().mockReturnValue(true), - getConfig: vi.fn(), + config: {}, }; const provider = new ServerLoaderRunner({ page: pageLoader }, cache); @@ -140,9 +140,9 @@ describe('ServerLoaderRunner', () => { delete: vi.fn(), flush: vi.fn(), entries: vi.fn(), - resolveTtl: vi.fn().mockReturnValue(300), + ttl: 300, enabled: vi.fn().mockReturnValue(false), - getConfig: vi.fn(), + config: {}, }; const provider = new ServerLoaderRunner({ page: pageLoader }, cache); @@ -239,9 +239,9 @@ describe('ServerLoaderRunner', () => { delete: vi.fn(), flush: vi.fn(), entries: vi.fn(), - resolveTtl: vi.fn().mockReturnValue(300), + ttl: 300, enabled: vi.fn().mockReturnValue(true), - getConfig: vi.fn(), + config: {}, }; const provider = new ServerLoaderRunner({ page: pageLoader }, cache); @@ -279,7 +279,12 @@ describe('ServerLoaderRunner', () => { const staleResult = await provider.resolve(request); expect(staleResult).toEqual({ kind: 'data', data: { title: 'v1' } }); - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)); + await vi.waitFor(async () => { + expect(await cache.get(key)).toEqual( + expect.objectContaining({ kind: 'hit', value: { title: 'v2' } }) + ); + }); + expect(loader).toHaveBeenCalledTimes(2); const freshResult = await provider.resolve(request); expect(freshResult).toEqual({ kind: 'data', data: { title: 'v2' } }); @@ -321,9 +326,9 @@ describe('ServerLoaderRunner', () => { delete: vi.fn(), flush: vi.fn(), entries: vi.fn(), - resolveTtl: vi.fn().mockReturnValue(300), + ttl: 300, enabled: vi.fn().mockReturnValue(true), - getConfig: vi.fn(), + config: {}, }; const provider = new ServerLoaderRunner({ page: loader }, cache); diff --git a/packages/angular/src/server/server-loader-runner.ts b/packages/angular/src/server/server-loader-runner.ts index f722157642..317c069f6c 100644 --- a/packages/angular/src/server/server-loader-runner.ts +++ b/packages/angular/src/server/server-loader-runner.ts @@ -52,7 +52,7 @@ export class ServerLoaderRunner { if (cacheable) { const { key } = buildCacheKey(loaderId, ctx); - const read = await this.cache!.get(key); + const read = await this.cache.get(key); if (read.kind === 'hit') { return { kind: 'data', data: read.value }; @@ -143,7 +143,7 @@ export class ServerLoaderRunner { value, cacheOptions?.tags ?? [] ); - const ttl = cacheOptions?.revalidate ?? this.cache.resolveTtl(); + const ttl = cacheOptions?.revalidate ?? this.cache.ttl; try { await this.cache.set(cacheKey, value, ttl, tags); } catch (err) { diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index e3ec375146..c36879b906 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -41,8 +41,8 @@ const driver = : undefined; const loaderCache = createLoaderCache({ - revalidate: config.angular.isrCache.revalidate, - enabled: config.angular.isrCache.enabled, + revalidate: config.angular.loadersCache.revalidate, + enabled: config.angular.loadersCache.enabled, defaultSiteName: config.defaultSite, ...(driver ? { driver } : {}), }); From f1b5f30cb7d520cbc1fc1c49d1976e1e14b43e73 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko <ala@sitecore.net> Date: Tue, 2 Jun 2026 16:38:10 -0400 Subject: [PATCH 14/14] some linting --- .../angular/src/loaders/loader-resolver.ts | 6 +- packages/angular/src/loaders/models.ts | 8 +-- .../angular/src/server/cache/loader-cache.ts | 60 +++++++++---------- .../server/cache/unstorage-loader-cache.ts | 22 +++---- packages/angular/src/server/cache/utils.ts | 5 +- .../loader-data-service-middleware.spec.ts | 11 ++-- 6 files changed, 58 insertions(+), 54 deletions(-) diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 7c99c8bfe4..d04c09d4e3 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -127,9 +127,9 @@ async function resolveOnBrowser({ /** * Create a loader resolver function that resolver loader data with optional cache options on server or browser. - * @param loaderId - The loader ID - * @param cacheOptions - The cache options - * @returns loader resolver function + * @param {LoaderId} loaderId - The loader ID + * @param {PerRouteLoaderCacheConfig} [cacheOptions] - The cache options + * @returns {ResolveFn<unknown>} loader resolver function */ export const loaderResolver = ( loaderId: LoaderId, diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index a937fd6bec..3fde4cc888 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -255,6 +255,10 @@ export interface InvalidateInput { * @public */ export interface LoaderCache { + /** Global default TTL in seconds from {@link LoaderCacheConfig.revalidate}. */ + get ttl(): number; + /** Resolved configuration (useful for admin UI and diagnostics). */ + get config(): Readonly<LoaderCacheConfig>; /** * Reads a cache entry and classifies it as hit, stale, or miss. * @param key - OSR-aligned cache key (for example `sc:loader:page:demo:en:default:about`). @@ -280,10 +284,6 @@ export interface LoaderCache { flush(): Promise<void>; /** Returns lightweight metadata for admin tooling (values are omitted). */ entries(): Promise<LoaderCacheEntryInfo[]>; - /** Global default TTL in seconds from {@link LoaderCacheConfig.revalidate}. */ - get ttl(): number; /** Whether caching is enabled globally. Per-route overrides may still opt in. */ enabled(): boolean; - /** Resolved configuration (useful for admin UI and diagnostics). */ - get config(): Readonly<LoaderCacheConfig>; } diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index 15a4c2402b..8c3c77fa06 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -1,30 +1,30 @@ -import { LoaderCache } from '../../loaders/models'; -import { GlobalLoaderCacheConfig } from './models'; -import { UnstorageLoaderCache } from './unstorage-loader-cache'; -import { resolveConfig } from './utils'; -import memoryDriver from 'unstorage/drivers/memory'; - -/** - * Public factory for the loader cache with unstorage backing. - * Uses the memory driver by default. - * - * Drivers are best imported and constructed in the app's `server.ts` and passed here as an instance. - * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. - * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. - * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. - * @example - * ```ts - * const cache = createLoaderCache({ - * revalidate: config.angular.loadersCache.revalidate, - * enabled: config.angular.loadersCache.enabled, - * defaultSiteName: config.defaultSite, - * driver: fsDriver({ base: './.cache/loaders' }), - * }); - * ``` - * @public - */ -export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { - const resolved = resolveConfig(config); - const driver = config.driver ?? memoryDriver(); - return new UnstorageLoaderCache(driver, resolved); -} +import { LoaderCache } from '../../loaders/models'; +import { GlobalLoaderCacheConfig } from './models'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { resolveConfig } from './utils'; +import memoryDriver from 'unstorage/drivers/memory'; + +/** + * Public factory for the loader cache with unstorage backing. + * Uses the memory driver by default. + * + * Drivers are best imported and constructed in the app's `server.ts` and passed here as an instance. + * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. + * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. + * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. + * @example + * ```ts + * const cache = createLoaderCache({ + * revalidate: config.angular.loadersCache.revalidate, + * enabled: config.angular.loadersCache.enabled, + * defaultSiteName: config.defaultSite, + * driver: fsDriver({ base: './.cache/loaders' }), + * }); + * ``` + * @public + */ +export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { + const resolved = resolveConfig(config); + const driver = config.driver ?? memoryDriver(); + return new UnstorageLoaderCache(driver, resolved); +} diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts index 714c52b880..58ce965f6d 100644 --- a/packages/angular/src/server/cache/unstorage-loader-cache.ts +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -29,11 +29,21 @@ export class UnstorageLoaderCache implements LoaderCache { * @param {Driver} driver - Unstorage driver instance from the app (`server.ts`). * @param {LoaderCacheConfig} [config] - Resolved cache configuration. */ - constructor(driver: Driver, config: LoaderCacheConfig = {}) { + constructor(driver: Driver, config: GlobalLoaderCacheConfig = {}) { this.storage = createStorage({ driver }); this._config = applyLoaderCacheConfigDefaults(config); } + /** @inheritdoc */ + get ttl(): number { + return this._config.revalidate; + } + + /** @inheritdoc */ + get config(): Readonly<LoaderCacheConfig> { + return this._config; + } + /** @inheritdoc */ async get(cacheKey: string): Promise<LoaderCacheReadResult> { const entry = await this.storage.getItem<LoaderCacheEntry>(this.cacheStorageKey(cacheKey)); @@ -116,21 +126,11 @@ export class UnstorageLoaderCache implements LoaderCache { return out; } - /** @inheritdoc */ - get ttl(): number { - return this._config.revalidate; - } - /** @inheritdoc */ enabled(): boolean { return this._config.enabled; } - /** @inheritdoc */ - get config(): Readonly<GlobalLoaderCacheConfig> { - return this._config; - } - /** * Cache entry storage key (OSR-aligned `sc:loader:…`). * @param {string} cacheKey - Public loader cache key. diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 65d5b5dc14..20c04248dd 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -89,8 +89,9 @@ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): Cac * @internal */ export function resolveConfig(config: GlobalLoaderCacheConfig): LoaderCacheConfig { - const { driver: _driver, ...rest } = config; - return rest; + const clonedConfig = { ...config }; + delete clonedConfig.driver; + return clonedConfig; } /** diff --git a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts index 2ba8253c34..5e4fb5f32d 100644 --- a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts @@ -38,9 +38,7 @@ describe('createLoaderDataServiceMiddleware', () => { }); }); - /** - * @param {{ loaders: import('../models').LoaderRegistry; endpoint?: string; cache?: import('../../loaders/models').LoaderCache }} opts - Middleware factory options - */ + /** eslint-disable-next-line jsdoc/require-jsdoc */ function createMiddleware(opts: { loaders: LoaderRegistry; endpoint?: string; @@ -303,7 +301,12 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/cached-page', params: { site: 'demo', locale: 'en' }, query: {} }, + body: { + loaderId: 'page', + url: '/cached-page', + params: { site: 'demo', locale: 'en' }, + query: {}, + }, query: {}, headers: {}, };