From 608284254bdefd77ae70ac6e059f6efd0f28bb14 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:39:56 +0200 Subject: [PATCH 01/42] =?UTF-8?q?=F0=9F=94=A7=20chore(prettier):=20Stop=20?= =?UTF-8?q?hard-wrapping=20markdown=20prose=20(proseWrap=20preserve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proseWrap: always reflowed every paragraph at printWidth 100, which split source pointers and sentences across lines mid-token. preserve leaves authored line breaks untouched — zero diff on existing files (all lines already ≤100), and keeps a one-line source pointer on one line going forward. Co-Authored-By: Claude Opus 4.8 (1M context) --- .prettierrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.prettierrc b/.prettierrc index 47c515a53..ab7c8cf3b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { "plugins": ["prettier-plugin-organize-imports"], "printWidth": 100, - "proseWrap": "always", + "proseWrap": "preserve", "singleQuote": true, "semi": false } From 13cd12f598011f21cce0329555e9ad650d144e3d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:48:47 +0200 Subject: [PATCH 02/42] =?UTF-8?q?=E2=9C=A8=20feat(docs):=20Add=20symbol-an?= =?UTF-8?q?chored=20source-pointer=20grammar=20+=20knowledge:check=20valid?= =?UTF-8?q?ator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal SDK knowledge base recorded facts with free-text source pointers (e.g. "source: accepted App Router guide" — circular; "OptimizedEntryResolver.ts:148-209" — already drifted). Nothing verified them, so the base rots silently and guides re-derive facts from scratch instead of relying on it. Define a strict, machine-checked pointer grammar in the base README and enforce it with scripts/validate-sdk-knowledge.ts (pnpm knowledge:check): - Grammar tokens: #[#], impl:#, concept:, kb:, extern:. Semicolon-separated. NO line numbers — symbols are the anchor; line ranges drift on every edit. - keys are workspace package directory basenames, auto-discovered from packages/**/package.json, so every SDK family is covered with no hardcoded map. - Symbol resolution uses the TypeScript compiler API (already a dep) to confirm the named identifier is actually declared — top-level decls, interface/type members, and class members/methods. No new dependency, no build, no tsconfig graph. - Also enforces: no pointerless facts, no circular guide pointers, and per-SDK files match _template.md heading-for-heading. - Split into scripts/sdk-knowledge/{markdown,source-symbols}.ts for readability. Migrate the shared/ knowledge files (vocabulary, concepts, consistency-notes) onto the grammar as the reference migration; they now pass knowledge:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/README.md | 49 +- .../internal/sdk-knowledge/shared/concepts.md | 25 +- .../sdk-knowledge/shared/consistency-notes.md | 12 +- .../sdk-knowledge/shared/vocabulary.md | 32 +- package.json | 1 + scripts/sdk-knowledge/markdown.ts | 56 ++ scripts/sdk-knowledge/source-symbols.ts | 69 +++ scripts/validate-sdk-knowledge.ts | 490 ++++++++++++++++++ 8 files changed, 698 insertions(+), 36 deletions(-) create mode 100644 scripts/sdk-knowledge/markdown.ts create mode 100644 scripts/sdk-knowledge/source-symbols.ts create mode 100644 scripts/validate-sdk-knowledge.ts diff --git a/documentation/internal/sdk-knowledge/README.md b/documentation/internal/sdk-knowledge/README.md index 7674036b3..d87dcbb25 100644 --- a/documentation/internal/sdk-knowledge/README.md +++ b/documentation/internal/sdk-knowledge/README.md @@ -41,7 +41,7 @@ Future families get sibling dirs (e.g. `native/`, `node/`). Do not create empty ## Rules -- Every fact carries a `source:` pointer (path, or `path#symbol`, with line where useful). +- Every fact carries a `source:` pointer in the [grammar below](#source-pointer-grammar). - Per-SDK files conform to [`_template.md`](./_template.md) exactly — same sections, same order. If a section has no entries, keep the heading and write `None.` - Per-SDK files link to [`shared/concepts.md`](./shared/concepts.md) and @@ -50,6 +50,53 @@ Future families get sibling dirs (e.g. `native/`, `node/`). Do not create empty - Terse notes, not prose. Not guides. Nothing goes into the skill. Do not git-commit (review owns commits). +## Source pointer grammar + +A pointer is machine-checked by `pnpm knowledge:check` +([`scripts/validate-sdk-knowledge.ts`](../../../scripts/validate-sdk-knowledge.ts)). It must match +this grammar exactly — free-text pointers (e.g. `source: accepted App Router guide`) are rejected, +which is what keeps a fact grounded in source rather than in another doc. + +Where a pointer lives: + +- **Prose fact** — end the fact with its own line: `source: `. Keep it on one line; never + wrap a pointer across lines. +- **Table row** — a section whose `_template.md` shape has a `source` column puts `` in + that column. No bare `source:` prefix inside a table cell. + +`` is one or more pointer tokens separated by `; ` (semicolon-space). Token forms: + +| Form | Resolves to | Checked | +| -------------------------- | ---------------------------------------------------------- | ------------------------------------------- | +| `#` | a file under that package's `src/` | file exists | +| `##` | a declared/exported identifier in that file | file exists **and** symbol is declared | +| `impl:#` | a file under `implementations//` | file exists | +| `concept:` | `documentation/concepts/.md` | file exists | +| `kb:` | another file in this knowledge base (relative to its root) | file exists | +| `extern:` | an out-of-repo fact (e.g. `extern:Next.js convention`) | not checked — use sparingly, state the fact | + +Definitions: + +- `` is a workspace package **directory basename** — the validator discovers these from + `packages/**/package.json`, so every SDK family is covered automatically. Current keys include + `core-sdk`, `web-sdk`, `nextjs-sdk`, `react-web-sdk`, `node-sdk`, `react-native-sdk`, + `api-schemas`, `api-client`, `preview-panel`. +- `` is relative to that package's `src/` (e.g. `CoreBase.ts`, + `resolvers/OptimizedEntryResolver.ts`). +- `` is a single top-level identifier declared in the file (`export`ed or not): a `const`, + `function`, `class`, `interface`, `type`, `enum`, or a named member of an exported + interface/type. One symbol per token; for two symbols, write two `; `-separated tokens. +- **No line numbers.** The symbol is the anchor — line ranges drift on every edit, symbols do not. + A fact about a behavior spanning many lines points at the enclosing symbol. + +Examples: + +- `source: core-sdk#CoreBase.ts#ContentfulConfig` +- `source: core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; nextjs-sdk#cookies.ts` +- `source: react-web-sdk#optimized-entry/optimizedEntryUtils.ts; concept:entry-personalization-and-variant-resolution` +- `source: impl:nextjs-sdk_pages-router#pages/_app.tsx` +- `source: extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` + ## Adding a new SDK Copy `_template.md` into the right family dir, keep every heading, fill each section with verified diff --git a/documentation/internal/sdk-knowledge/shared/concepts.md b/documentation/internal/sdk-knowledge/shared/concepts.md index b9f1919fb..165f33da1 100644 --- a/documentation/internal/sdk-knowledge/shared/concepts.md +++ b/documentation/internal/sdk-knowledge/shared/concepts.md @@ -16,13 +16,12 @@ resolution hand-off: `useOptimizedEntry({ entryId })`). The client is still app-owned; the SDK only calls `getEntry()` on it, merging `contentful.defaultQuery`, the per-call query, an SDK locale fallback, and `include: 10`. Per-instance cache defaults to `{ maxEntries: 100, ttlMs: 300_000 }`; - `cache: false` disables it; `clearContentfulEntryCache()` clears it. source: `core-sdk` - `CoreBase.ts` — `ContentfulConfig` (~:66-79), `ContentfulEntryClient` (~:46-49), `contentful?` - config key, `fetchContentfulEntry`/`fetchOptimizedEntry`/`clearContentfulEntryCache`. + `cache: false` disables it; `clearContentfulEntryCache()` clears it. + source: core-sdk#CoreBase.ts#ContentfulConfig; core-sdk#CoreBase.ts#ContentfulEntryClient; core-sdk#CoreBase.ts#fetchContentfulEntry; core-sdk#CoreBase.ts#clearContentfulEntryCache Either way, the SDK sits at the hand-off where a fetched entry becomes a component and returns the resolved variant (or the baseline entry). Both paths are supported; a guide must not assert the SDK -never fetches. source: `core-sdk` `CoreBase.ts` `contentful?` config. +never fetches. source: core-sdk#CoreBase.ts#ContentfulConfig ## Entry resolution @@ -30,33 +29,35 @@ Fetch with ONE concrete locale and an `include` depth deep enough to cover the p and linked variant entries. All-locale payloads (`withAllLocales` / CDA `locale=*`) use locale-keyed field maps the resolver cannot read ⇒ entries fall back to baseline. The entry wrapper's render prop hands back the resolved entry as a base `contentful` `Entry`; a narrower component type needs a -cast. source: react-web `optimized-entry/optimizedEntryUtils.ts`; entry-personalization concept doc. +cast. +source: react-web-sdk#optimized-entry/optimizedEntryUtils.ts#RenderProp; concept:entry-personalization-and-variant-resolution ## Baseline fallback On denied consent, no matching variant, unresolved links, or an all-locale payload, the render prop receives the baseline (original) entry and the UI does not break. This is why an integration renders -correctly even before any variant is authored. source: react-web `OptimizedEntry`. +correctly even before any variant is authored. +source: react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry ## Consent & persistence Two independent axes: `consent` (may personalize + send events) and `persistenceConsent` (may store the profile-id cookie). Consent policy is app-owned: the app records the choice and the SDK reads it (server-side per request, browser-side via seeded defaults). The consent record/cookie is -reader-owned; the profile-id cookie is SDK-owned. source: factory `defaults`; per-SDK server -consent. +reader-owned; the profile-id cookie is SDK-owned. +source: core-sdk#StatefulDefaults.ts#consent; core-sdk#StatefulDefaults.ts#persistenceConsent; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE ## Live updates Opt-in. Most content is fixed for a request's life, so re-resolution after load is off by default. Turned on app-wide (factory `liveUpdates`) or per-entry; a per-entry value overrides the app-wide -default. Triggers: consent/identity/profile changes in the browser. source: react-web -`LiveUpdatesProvider`, `hooks/useLiveUpdates.ts`. +default. Triggers: consent/identity/profile changes in the browser. +source: react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider; react-web-sdk#hooks/useLiveUpdates.ts#useLiveUpdates ## Page events A page event signals a page/route view. Auto-page trackers emit them on navigation and dedupe consecutive route keys. When the server already reported a consented page view, the browser must skip the duplicate (per-SDK `initialPageEvent` / tracker prop). Interaction events -(view/click/hover) are consent-gated browser activity and use the resolved entry id. source: -react-web `auto-page/*`, `router/*`. +(view/click/hover) are consent-gated browser activity and use the resolved entry id. +source: react-web-sdk#auto-page/useAutoPageEmitter.ts; react-web-sdk#router/next-app.tsx diff --git a/documentation/internal/sdk-knowledge/shared/consistency-notes.md b/documentation/internal/sdk-knowledge/shared/consistency-notes.md index afe2f3db7..5b9afd3d2 100644 --- a/documentation/internal/sdk-knowledge/shared/consistency-notes.md +++ b/documentation/internal/sdk-knowledge/shared/consistency-notes.md @@ -35,7 +35,7 @@ main's final consistency pass. Terse; running log. (no `NEXT_` prefix; non-standard). Guide deliberately diverges from the impl to teach the correct convention. Confirm the React Web / Web guides frame their own preview flag as reader-owned with each framework's real browser-var convention rather than copying the impl's `PUBLIC_` prefix. - source: ref impl `lib/config.ts:6`. + source: impl:nextjs-sdk_pages-router#lib/config.ts ## Open items to reconcile when guides 2 & 3 land @@ -72,8 +72,8 @@ main's final consistency pass. Terse; running log. `selectedOptimization` is `undefined` ONLY when no experience matched; when an experience assigns the CONTROL/baseline variant (`variantIndex: 0`) it is DEFINED while the resolved entry still equals the baseline — so `selectedOptimization === undefined` must not be read as "showing - baseline content." source: `core-sdk/src/resolvers/OptimizedEntryResolver.ts:148-209`. The React - Web, App Router, and Pages Router guides describe the baseline-fallback contract but do NOT - surface this control-variant nuance. Consider whether to backport a one-line note to those guides - (the render helpers already guard on presence, so it is a prose-precision improvement, not a bug). - + baseline content." source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver. + The React Web, App Router, and Pages Router guides describe the baseline-fallback contract but do + NOT surface this control-variant nuance. Consider whether to backport a one-line note to those + guides (the render helpers already guard on presence, so it is a prose-precision improvement, not + a bug). diff --git a/documentation/internal/sdk-knowledge/shared/vocabulary.md b/documentation/internal/sdk-knowledge/shared/vocabulary.md index e3cdaca95..51e4d02d0 100644 --- a/documentation/internal/sdk-knowledge/shared/vocabulary.md +++ b/documentation/internal/sdk-knowledge/shared/vocabulary.md @@ -3,20 +3,18 @@ Canonical term → one-line meaning. Use verbatim across the web-family guides so they do not drift. Facts only; source pointers where a term maps to a concrete symbol. -| Term | One-line meaning | source | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | -| variant | An authored alternative of an entry. | product concept | -| experience | A rule that decides which visitors see which variant. | product concept | -| Experience API | Contentful service that, per request/visitor, picks the variant for each experience. | product concept | -| resolving (an entry) | Swapping a fetched entry for its picked variant (or leaving the original when none applies). | product concept | -| baseline fallback | The original entry the render prop receives when no variant applies, consent is denied, links are unresolved, or the payload is all-locale. | react-web `OptimizedEntry` | -| consent | Permission for the SDK to personalize and send events for this visitor. | factory `defaults.consent` | -| persistenceConsent | Permission for the SDK to store the profile-id cookie so results stay consistent. | factory `defaults.persistenceConsent` | -| profile | The anonymous id the SDK uses to keep the same visitor consistent across requests. | cookie `ctfl-opt-aid` (`core-sdk/constants.ts:38`) | -| page event | A signal that a page/route was viewed, emitted as the visitor navigates. | react-web auto-page trackers | -| browser takeover | After first paint, the browser SDK owns personalization from the server-resolved state. | react-web `OptimizationRoot` | -| live updates | Opt-in re-resolution of entries after load when consent/identity/profile change, without a reload. | react-web `LiveUpdatesProvider` | -| entry-source boundary | The app owns the Contentful client; a fetched entry reaches the SDK either manually (app fetches, passes `baselineEntry`) or managed (SDK fetches by ID through the app's client via `contentful` config). See concepts. | see `concepts.md`; `core-sdk` `CoreBase.ts` `ContentfulConfig` | -| managed entry fetching | Opt-in mode where the SDK calls `getEntry()` on the app's `contentful.js` client for you (by entry ID) instead of you fetching. Enabled by `contentful: { client }`. | `core-sdk` `CoreBase.ts` `fetchContentfulEntry` | - - +| Term | One-line meaning | source | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| variant | An authored alternative of an entry. | extern:Contentful product concept | +| experience | A rule that decides which visitors see which variant. | extern:Contentful product concept | +| Experience API | Contentful service that, per request/visitor, picks the variant for each experience. | extern:Contentful product concept | +| resolving (an entry) | Swapping a fetched entry for its picked variant (or leaving the original when none applies). | core-sdk#resolvers/OptimizedEntryResolver.ts | +| baseline fallback | The original entry the render prop receives when no variant applies, consent is denied, links are unresolved, or the payload is all-locale. | react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry | +| consent | Permission for the SDK to personalize and send events for this visitor. | core-sdk#StatefulDefaults.ts#consent | +| persistenceConsent | Permission for the SDK to store the profile-id cookie so results stay consistent. | core-sdk#StatefulDefaults.ts#persistenceConsent | +| profile | The anonymous id the SDK uses to keep the same visitor consistent across requests. | core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | +| page event | A signal that a page/route was viewed, emitted as the visitor navigates. | react-web-sdk#auto-page/useAutoPageEmitter.ts | +| browser takeover | After first paint, the browser SDK owns personalization from the server-resolved state. | react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot | +| live updates | Opt-in re-resolution of entries after load when consent/identity/profile change, without a reload. | react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider | +| entry-source boundary | The app owns the Contentful client; a fetched entry reaches the SDK either manually (app fetches, passes `baselineEntry`) or managed (SDK fetches by ID through the app's client via `contentful` config). See concepts. | core-sdk#CoreBase.ts#ContentfulConfig | +| managed entry fetching | Opt-in mode where the SDK calls `getEntry()` on the app's `contentful.js` client for you (by entry ID) instead of you fetching. Enabled by `contentful: { client }`. | core-sdk#CoreBase.ts#fetchContentfulEntry | diff --git a/package.json b/package.json index a97673e73..321ee142b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "implementation:nextjs-sdk_app-router": "pnpm run implementation:run -- nextjs-sdk_app-router", "implementation:nextjs-sdk_pages-router": "pnpm run implementation:run -- nextjs-sdk_pages-router", "implementation:web-sdk": "pnpm run implementation:run -- web-sdk", + "knowledge:check": "tsx ./scripts/validate-sdk-knowledge.ts", "implementation:lint": "eslint implementations --ignore-pattern implementations/nextjs-sdk_app-router --ignore-pattern implementations/nextjs-sdk_pages-router && pnpm run implementation:run -- nextjs-sdk_app-router lint && pnpm run implementation:run -- nextjs-sdk_pages-router lint", "implementation:lint:fix": "eslint implementations --ignore-pattern implementations/nextjs-sdk_app-router --ignore-pattern implementations/nextjs-sdk_pages-router --fix && pnpm run implementation:run -- nextjs-sdk_app-router lint && pnpm run implementation:run -- nextjs-sdk_pages-router lint", "implementation:typecheck": "pnpm run implementation:run -- --all -- typecheck", diff --git a/scripts/sdk-knowledge/markdown.ts b/scripts/sdk-knowledge/markdown.ts new file mode 100644 index 000000000..670473aae --- /dev/null +++ b/scripts/sdk-knowledge/markdown.ts @@ -0,0 +1,56 @@ +/** Generic, knowledge-base-agnostic markdown parsing helpers used by the validator. */ + +export interface Heading { + level: number + line: number + text: string +} + +/** ATX headings (`#`..`######`), skipping fenced code blocks so `# comment` lines are not counted. */ +export function headingsOf(lines: string[]): Heading[] { + const headings: Heading[] = [] + let inFence = false + + lines.forEach((rawLine, index) => { + if (/^\s*```/u.test(rawLine)) { + inFence = !inFence + return + } + if (inFence) { + return + } + const match = /^(#{1,6})\s+(.+?)\s*$/u.exec(rawLine) + if (match?.[1] !== undefined && match[2] !== undefined) { + headings.push({ level: match[1].length, line: index + 1, text: match[2] }) + } + }) + + return headings +} + +/** Splits a `| a | b |` table row into trimmed cells, or returns undefined for a non-row line. */ +export function parseTableRow(line: string): string[] | undefined { + const trimmed = line.trim() + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { + return undefined + } + return trimmed + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()) +} + +/** A `| --- | :--: |` divider row separating a table header from its body. */ +export function isTableDivider(columns: string[]): boolean { + return columns.every((cell) => /^:?-+:?$/u.test(cell) || cell === '') +} + +/** An empty-ish table cell that carries no pointer (em dash, hyphen, or "none"). */ +export function isPlaceholderCell(cell: string): boolean { + return cell === '—' || cell === '-' || cell.toLowerCase() === 'none' +} + +/** A bullet (`-`/`*`) or ordered (`1.`) list item. */ +export function isListItem(line: string): boolean { + return /^[-*]\s|^\d+\.\s/u.test(line) +} diff --git a/scripts/sdk-knowledge/source-symbols.ts b/scripts/sdk-knowledge/source-symbols.ts new file mode 100644 index 000000000..675f172f4 --- /dev/null +++ b/scripts/sdk-knowledge/source-symbols.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +/** + * Collects every named declaration in a TypeScript file so a knowledge-base pointer can be resolved + * to a real symbol. Uses the compiler's PARSER only — no type-checking, no tsconfig graph — so it is + * fast and self-contained. Recognizes: + * - top-level declarations: function / class / interface / type / enum, and `const`/`let` names + * - members: enum members, interface/type-literal property & method signatures, and class + * members/methods (so a pointer can name a config key or an SDK method like `fetchContentfulEntry`) + */ +export function collectDeclaredSymbols(filePath: string): Set { + const sourceFile = ts.createSourceFile( + filePath, + readFileSync(filePath, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + scriptKindFor(filePath), + ) + + const symbols = new Set() + const visit = (node: ts.Node): void => { + addDeclaredName(node, symbols) + ts.forEachChild(node, visit) + } + visit(sourceFile) + return symbols +} + +function addDeclaredName(node: ts.Node, symbols: Set): void { + if (isNamedDeclaration(node) && node.name !== undefined && ts.isIdentifier(node.name)) { + symbols.add(node.name.text) + } + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + symbols.add(node.name.text) + } +} + +function isNamedDeclaration(node: ts.Node): node is ts.Declaration & { name: ts.Node | undefined } { + return isTopLevelDeclaration(node) || isMemberDeclaration(node) +} + +/** Top-level named declarations: function / class / interface / type / enum. */ +function isTopLevelDeclaration(node: ts.Node): boolean { + return ( + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) + ) +} + +/** Named members of interfaces, type literals, enums, and classes. */ +function isMemberDeclaration(node: ts.Node): boolean { + return ( + ts.isEnumMember(node) || + ts.isPropertySignature(node) || + ts.isMethodSignature(node) || + ts.isMethodDeclaration(node) || + ts.isPropertyDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) +} + +function scriptKindFor(filePath: string): ts.ScriptKind { + return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS +} diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts new file mode 100644 index 000000000..18062c0fe --- /dev/null +++ b/scripts/validate-sdk-knowledge.ts @@ -0,0 +1,490 @@ +/* eslint-disable no-console -- This CLI prints a machine-readable validation report. */ + +/** + * Validates the internal SDK knowledge base under documentation/internal/sdk-knowledge/. + * + * Enforces the source-pointer grammar documented in that directory's README.md so the base stays a + * store of facts grounded in packages source, not claims grounded in other docs. It checks grammar + * (pointers parse), resolution (symbols resolve against real TypeScript source via the compiler + * API — symbol-anchored, no drift-prone line numbers), coverage (no pointerless facts), and + * template conformance (per-SDK files match _template.md). Usage: tsx scripts/validate-sdk-knowledge.ts + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type Heading, + headingsOf, + isListItem, + isPlaceholderCell, + isTableDivider, + parseTableRow, +} from './sdk-knowledge/markdown' +import { collectDeclaredSymbols } from './sdk-knowledge/source-symbols' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const knowledgeDir = path.join(rootDir, 'documentation/internal/sdk-knowledge') +const packagesDir = path.join(rootDir, 'packages') +const implementationsDir = path.join(rootDir, 'implementations') +const conceptsDir = path.join(rootDir, 'documentation/concepts') +const templatePath = path.join(knowledgeDir, '_template.md') + +/** Files under the knowledge dir that document the format rather than record facts. Not graded. */ +const META_FILES = new Set(['README.md', '_template.md']) +/** Directories under the knowledge dir holding shared, non-per-SDK material. */ +const SHARED_DIRS = new Set(['shared']) + +const HEADING_LEVEL_SECTION = 2 +/** How many continuation lines below a list item may carry that item's pointer. */ +const CONTINUATION_LOOKAHEAD = 3 +const SUMMARY_TRUNCATE = 80 +const LABEL_LEADIN_MAX = 40 +/** A `##` token has this many `#`-separated parts (symbol optional). */ +const TOKEN_PARTS_WITH_SYMBOL = 3 + +interface Problem { + file: string + line: number + message: string +} + +interface Pointer { + line: number + tokens: string[] +} + +const problems: Problem[] = [] +const sdkSrcRoots = discoverSdkSrcRoots() +const symbolCache = new Map>() + +for (const file of listMarkdownFiles(knowledgeDir)) { + validateFile(file) +} + +report() + +// --- per-file validation --------------------------------------------------------------------- + +function validateFile(absPath: string): void { + // README.md and _template.md are meta files: they hold placeholder `source:` guidance and + // illustrative examples, not real facts. Exempt them so the format's own docs are not graded. + if (META_FILES.has(path.basename(absPath))) { + return + } + + const relPath = path.relative(rootDir, absPath) + const lines = readFileSync(absPath, 'utf8').split('\n') + + // Grammar + resolution apply to every fact file: any `source:` pointer must be valid. + for (const pointer of extractPointers(lines)) { + for (const token of pointer.tokens) { + checkToken(token, relPath, pointer.line) + } + } + + // Coverage and template conformance apply only to per-SDK files, whose _template.md shape makes + // "every bullet/row is a sourced fact" a firm rule. shared/ files mix normative prose with facts. + if (isPerSdkFile(absPath)) { + checkPointerCoverage(lines, relPath) + checkTemplateConformance(lines, relPath) + } +} + +// --- pointer extraction ---------------------------------------------------------------------- + +/** + * Pulls source pointers out of a fact file. Two carriers, per the grammar: + * - a prose line `source: ` (the pointer list runs to end of line) + * - a table row whose `source` column holds `` (headed by a `| … | source |` row) + */ +function extractPointers(lines: string[]): Pointer[] { + const pointers: Pointer[] = [] + let sourceColumnIndex: number | undefined = undefined + + lines.forEach((rawLine, index) => { + const line = rawLine.trimEnd() + const lineNumber = index + 1 + const columns = parseTableRow(line) + + if (columns !== undefined) { + sourceColumnIndex = handleTableRow(columns, sourceColumnIndex, lineNumber, pointers) + return + } + + // A blank or non-table line ends the current table's column mapping. + if (line === '' || !line.includes('|')) { + sourceColumnIndex = undefined + } + + const prosePointer = matchProseSource(line) + if (prosePointer !== undefined) { + pointers.push({ line: lineNumber, tokens: splitTokens(prosePointer) }) + } + }) + + return pointers +} + +/** Updates the tracked source-column index and records a row's pointers. Returns the new index. */ +function handleTableRow( + columns: string[], + sourceColumnIndex: number | undefined, + lineNumber: number, + pointers: Pointer[], +): number | undefined { + const headerIndex = columns.findIndex((cell) => cell.toLowerCase() === 'source') + if (headerIndex !== -1) { + return headerIndex + } + if (isTableDivider(columns) || sourceColumnIndex === undefined) { + return sourceColumnIndex + } + + const { [sourceColumnIndex]: cell } = columns + if (cell !== undefined && cell !== '' && !isPlaceholderCell(cell)) { + pointers.push({ line: lineNumber, tokens: splitTokens(cell) }) + } + return sourceColumnIndex +} + +/** Returns the pointer text of a `source: …` prose line, or undefined if the line is not one. */ +function matchProseSource(line: string): string | undefined { + const match = /(?:^|\s)source:\s*(.+)$/u.exec(line) + if (match?.[1] === undefined) { + return undefined + } + // Strip a trailing sentence period that terminates the fact, not a pointer. + return match[1].trim().replace(/\.$/u, '') +} + +function splitTokens(pointerText: string): string[] { + return pointerText + .split(';') + .map((token) => token.trim().replace(/^`|`$/gu, '').trim()) + .filter((token) => token !== '') +} + +// --- token checking -------------------------------------------------------------------------- + +function checkToken(token: string, file: string, line: number): void { + const prefixedCheck = checkPrefixedToken(token, file, line) + if (prefixedCheck) { + return + } + checkSourceToken(token, file, line) +} + +/** Handles the `extern:` / `concept:` / `kb:` / `impl:` forms. Returns true if one matched. */ +function checkPrefixedToken(token: string, file: string, line: number): boolean { + if (token.startsWith('extern:')) { + if (token.slice('extern:'.length).trim() === '') { + addProblem(file, line, `extern: pointer has no text: "${token}"`) + } + return true + } + + if (token.startsWith('concept:')) { + const slug = token.slice('concept:'.length).trim() + checkFile(path.join(conceptsDir, `${slug}.md`), `concept:${slug}`, file, line) + return true + } + + if (token.startsWith('kb:')) { + const relPath = token.slice('kb:'.length).trim() + checkFile(path.join(knowledgeDir, relPath), `kb:${relPath}`, file, line) + return true + } + + if (token.startsWith('impl:')) { + checkImplToken(token, file, line) + return true + } + + return false +} + +function checkImplToken(token: string, file: string, line: number): void { + const [name, relPath] = splitOnce(token.slice('impl:'.length), '#') + if (name === undefined || relPath === undefined) { + addProblem(file, line, `impl: pointer must be impl:#: "${token}"`) + return + } + checkFile(path.join(implementationsDir, name, relPath), token, file, line) +} + +/** Handles the `#[#]` form. */ +function checkSourceToken(token: string, file: string, line: number): void { + const parts = token.split('#') + const [sdk, relPath, symbol] = parts + const hasValidArity = parts.length === 2 || parts.length === TOKEN_PARTS_WITH_SYMBOL + + if (!hasValidArity || sdk === undefined || relPath === undefined) { + addProblem( + file, + line, + `unrecognized source pointer "${token}" — expected #[#], ` + + `impl:#, concept:, kb:, or extern:`, + ) + return + } + + const srcRoot = sdkSrcRoots.get(sdk) + if (srcRoot === undefined) { + const known = [...sdkSrcRoots.keys()].sort().join(', ') + addProblem(file, line, `unknown SDK key "${sdk}" in "${token}" — known keys: ${known}`) + return + } + + const filePath = path.join(srcRoot, relPath) + if (!fileExists(filePath)) { + addProblem(file, line, `${token} → no such source file (${rel(filePath)})`) + return + } + + if (symbol !== undefined && !fileDeclaresSymbol(filePath, symbol)) { + addProblem(file, line, `${token} → file exists but declares no symbol "${symbol}"`) + } +} + +function checkFile(candidate: string, label: string, file: string, line: number): void { + if (!fileExists(candidate)) { + addProblem(file, line, `${label} → no such file (${rel(candidate)})`) + } +} + +// --- coverage -------------------------------------------------------------------------------- + +/** + * Flags a list/numbered fact line that states an SDK fact (references a concrete symbol in inline + * code) but carries no pointer — neither on the line nor on an immediate continuation line. + */ +function checkPointerCoverage(lines: string[], file: string): void { + lines.forEach((rawLine, index) => { + const line = rawLine.trim() + if (!isListItem(line) || !line.endsWith('.') || line.endsWith('None.')) { + return + } + if (line.includes('source:') || itemHasPointerNearby(lines, index)) { + return + } + if (looksLikeFact(line)) { + addProblem(file, index + 1, `fact has no source: pointer — "${truncate(line)}"`) + } + }) +} + +function itemHasPointerNearby(lines: string[], index: number): boolean { + for (let offset = 1; offset <= CONTINUATION_LOOKAHEAD; offset += 1) { + const { [index + offset]: next } = lines + if (next === undefined) { + return false + } + const trimmed = next.trim() + if (trimmed === '' || isListItem(trimmed)) { + return false + } + if (trimmed.includes('source:')) { + return true + } + } + return false +} + +/** + * A heuristic for "this list item is an SDK fact that must be sourced" vs prose guidance. Facts + * reference a concrete symbol/config key/path/identifier as inline code. A bullet that only + * delegates to another doc ("Model: see ../shared/concepts.md#…") is a cross-reference, not a fact. + */ +function looksLikeFact(line: string): boolean { + if (!/`[^`]+`/u.test(line)) { + return false + } + return !isCrossReference(line) +} + +function isCrossReference(line: string): boolean { + if (!/\bsee\b/iu.test(line)) { + return false + } + const body = line + .replace(/^[-*]\s+|^\d+\.\s+/u, '') + .replace(new RegExp(`^[^:]{0,${LABEL_LEADIN_MAX}}:\\s*`, 'u'), '') + .replace(/^see\s+/iu, '') + const withoutLinks = body.replace(/\[[^\]]*\]\([^)]*\)/gu, '').replace(/`[^`]*`/gu, '') + return withoutLinks.replace(/[\s.,;]/gu, '') === '' +} + +// --- template conformance -------------------------------------------------------------------- + +function checkTemplateConformance(lines: string[], file: string): void { + const expected = sectionHeadings(readFileSync(templatePath, 'utf8').split('\n')) + const fileHeadings = sectionLevel(headingsOf(lines)) + const actual = fileHeadings.map((heading) => heading.text) + + if (!arraysEqual(expected, actual)) { + addProblem( + file, + fileHeadings[0]?.line ?? 1, + `## headings must match _template.md exactly and in order.\n` + + ` expected: ${expected.join(' | ')}\n` + + ` actual: ${actual.join(' | ')}`, + ) + } +} + +function sectionHeadings(lines: string[]): string[] { + return sectionLevel(headingsOf(lines)).map((heading) => heading.text) +} + +function sectionLevel(headings: Heading[]): Heading[] { + return headings.filter((heading) => heading.level === HEADING_LEVEL_SECTION) +} + +// --- source resolution (TypeScript compiler API) --------------------------------------------- + +/** Resolves and caches the set of symbols a source file declares (see source-symbols.ts). */ +function fileDeclaresSymbol(filePath: string, symbol: string): boolean { + let symbols = symbolCache.get(filePath) + if (symbols === undefined) { + symbols = collectDeclaredSymbols(filePath) + symbolCache.set(filePath, symbols) + } + return symbols.has(symbol) +} + +// --- workspace discovery --------------------------------------------------------------------- + +/** Maps each package directory basename (the `` grammar key) to its `src/` root. */ +function discoverSdkSrcRoots(): Map { + const roots = new Map() + + for (const manifestPath of findPackageManifests(packagesDir)) { + const packageDir = path.dirname(manifestPath) + const srcDir = path.join(packageDir, 'src') + if (!directoryExists(srcDir)) { + continue + } + const key = path.basename(packageDir) + const existing = roots.get(key) + if (existing !== undefined && existing !== srcDir) { + throw new Error( + `Ambiguous SDK key "${key}": ${rel(existing)} and ${rel(srcDir)}. ` + + `Package directory basenames must be unique to serve as source-pointer keys.`, + ) + } + roots.set(key, srcDir) + } + + if (roots.size === 0) { + throw new Error(`No package src roots discovered under ${rel(packagesDir)}.`) + } + + return roots +} + +function findPackageManifests(dir: string): string[] { + const manifests: string[] = [] + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) { + continue + } + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + walk(entryPath) + } else if (entry.name === 'package.json') { + manifests.push(entryPath) + } + } + } + walk(dir) + return manifests +} + +// --- file helpers ---------------------------------------------------------------------------- + +function listMarkdownFiles(dir: string): string[] { + const files: string[] = [] + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + walk(entryPath) + } else if (entry.name.endsWith('.md')) { + files.push(entryPath) + } + } + } + walk(dir) + return files.sort((left, right) => left.localeCompare(right)) +} + +function isPerSdkFile(absPath: string): boolean { + const relInside = path.relative(knowledgeDir, absPath) + const [firstSegment] = relInside.split(path.sep) + if (firstSegment !== undefined && SHARED_DIRS.has(firstSegment)) { + return false + } + return !META_FILES.has(path.basename(absPath)) +} + +function fileExists(candidate: string): boolean { + try { + return statSync(candidate).isFile() + } catch { + return false + } +} + +function directoryExists(candidate: string): boolean { + try { + return statSync(candidate).isDirectory() + } catch { + return false + } +} + +function splitOnce(value: string, separator: string): [string | undefined, string | undefined] { + const index = value.indexOf(separator) + if (index === -1) { + return [value === '' ? undefined : value, undefined] + } + return [value.slice(0, index), value.slice(index + separator.length)] +} + +function arraysEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function rel(absPath: string): string { + return path.relative(rootDir, absPath) +} + +function truncate(value: string, max = SUMMARY_TRUNCATE): string { + return value.length > max ? `${value.slice(0, max - 1)}…` : value +} + +// --- reporting ------------------------------------------------------------------------------- + +function addProblem(file: string, line: number, message: string): void { + problems.push({ file, line, message }) +} + +function report(): void { + if (problems.length === 0) { + console.log('✓ SDK knowledge base: all source pointers resolve and templates conform.') + return + } + + problems.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line) + + console.error(`✗ SDK knowledge base: ${problems.length} problem(s) found.\n`) + for (const problem of problems) { + console.error(` ${problem.file}:${problem.line} ${problem.message}`) + } + console.error( + `\nSee documentation/internal/sdk-knowledge/README.md#source-pointer-grammar for the grammar.`, + ) + process.exit(1) +} From 31e3cde3da23f85e763ae26bbef495d1cf8723c4 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:49:10 +0200 Subject: [PATCH 03/42] =?UTF-8?q?=F0=9F=91=B7=20ci:=20Run=20knowledge:chec?= =?UTF-8?q?k=20on=20KB=20and=20package-source=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a knowledge-check job gated on a new `knowledge` paths-filter that fires when the SDK knowledge base OR any packages/**/src changes — deliberately NOT markdown-excluded, since the KB is markdown and a source refactor can invalidate a symbol pointer without touching a fact. This is the change-triggered propagation edge: a rename that orphans a recorded pointer fails on the same PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main-pipeline.yaml | 61 ++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index cbb57028c..ea66f77da 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -34,6 +34,7 @@ jobs: e2e_ios: ${{ steps.filter.outputs.e2e_ios }} swift_package: ${{ steps.filter.outputs.swift_package }} android_library: ${{ steps.filter.outputs.android_library }} + knowledge: ${{ steps.filter.outputs.knowledge }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 @@ -122,6 +123,16 @@ jobs: - '{packages/android/**,packages/universal/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' - '!**/*.@(md|mdx|markdown)' - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' + # SDK knowledge-base scope. Change-triggered on BOTH sides of the source→KB edge: + # the knowledge base itself, and any package source whose symbols its pointers name. + # Deliberately NOT markdown-excluded — the KB is markdown, and a source change can + # invalidate a symbol pointer without touching a fact. + knowledge: + - 'documentation/internal/sdk-knowledge/**' + - 'packages/**/src/**' + - 'implementations/**' + - 'scripts/validate-sdk-knowledge.ts' + - '.github/workflows/main-pipeline.yaml' setup: name: 🛠️ pnpm install @@ -197,6 +208,32 @@ jobs: - run: pnpm install --prefer-offline --frozen-lockfile - run: pnpm format:check + knowledge-check: + name: 📚 Knowledge Base Check + runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal + needs: [setup, changes] + if: needs.changes.outputs.knowledge == 'true' + timeout-minutes: 15 + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: pnpm + + - run: pnpm install --prefer-offline --frozen-lockfile + # Validates that every knowledge-base source pointer still resolves to a real file and symbol + # in packages/**/src. Runs when the KB OR any package source changes, so a source refactor + # that renames or moves a symbol a pointer names fails here on the same PR. + - run: pnpm knowledge:check + build: name: 📦 Build runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal @@ -453,10 +490,8 @@ jobs: name: sdk-package-tarballs path: pkgs - run: pnpm store prune - - run: - pnpm run implementation:node-sdk+web-sdk -- implementation:install -- --no-frozen-lockfile - - run: - pnpm run implementation:node-sdk+web-sdk -- implementation:playwright:install -- + - run: pnpm run implementation:node-sdk+web-sdk -- implementation:install -- --no-frozen-lockfile + - run: pnpm run implementation:node-sdk+web-sdk -- implementation:playwright:install -- --with-deps - run: pnpm run implementation:node-sdk+web-sdk -- implementation:test:e2e:run @@ -596,8 +631,7 @@ jobs: name: sdk-package-tarballs path: pkgs - run: pnpm store prune - - run: - pnpm run implementation:web-sdk_angular -- implementation:install -- --no-frozen-lockfile + - run: pnpm run implementation:web-sdk_angular -- implementation:install -- --no-frozen-lockfile - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile - run: pnpm --dir lib/e2e-web run setup:e2e - run: pnpm run implementation:web-sdk_angular -- implementation:test:e2e:run @@ -668,8 +702,7 @@ jobs: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 - name: Create .env from .env.example - run: - cp implementations/nextjs-sdk_app-router/.env.example + run: cp implementations/nextjs-sdk_app-router/.env.example implementations/nextjs-sdk_app-router/.env - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -693,8 +726,7 @@ jobs: name: sdk-package-tarballs path: pkgs - run: pnpm store prune - - run: - pnpm run implementation:nextjs-sdk_app-router -- implementation:install -- + - run: pnpm run implementation:nextjs-sdk_app-router -- implementation:install -- --no-frozen-lockfile - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile - run: pnpm --dir lib/e2e-web run setup:e2e @@ -719,8 +751,7 @@ jobs: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 - name: Create .env from .env.example - run: - cp implementations/nextjs-sdk_pages-router/.env.example + run: cp implementations/nextjs-sdk_pages-router/.env.example implementations/nextjs-sdk_pages-router/.env - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -744,8 +775,7 @@ jobs: name: sdk-package-tarballs path: pkgs - run: pnpm store prune - - run: - pnpm run implementation:nextjs-sdk_pages-router -- implementation:install -- + - run: pnpm run implementation:nextjs-sdk_pages-router -- implementation:install -- --no-frozen-lockfile - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile - run: pnpm --dir lib/e2e-web run setup:e2e @@ -935,8 +965,7 @@ jobs: ARCH: x86_64 TARGET: aosp_atd PROFILE: pixel_7 - EMULATOR_OPTIONS: - -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 6b499297e3b1164e87da85304bb15f3147e0d654 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:49:12 +0200 Subject: [PATCH 04/42] =?UTF-8?q?=F0=9F=93=9D=20docs(skills):=20Point=20kn?= =?UTF-8?q?owledge-maintenance=20skill=20at=20the=20grammar=20+=20knowledg?= =?UTF-8?q?e:check=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the sdk-knowledge-maintenance skill so its source-pointer rule references the machine-checked grammar (symbol-anchored, never line numbers, never a guide), and make `pnpm knowledge:check` the explicit finish gate. Note that CI runs it on packages/**/src changes, so a rename that orphans a pointer must be fixed on the same PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/sdk-knowledge-maintenance/SKILL.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/skills/sdk-knowledge-maintenance/SKILL.md b/skills/sdk-knowledge-maintenance/SKILL.md index e08dc34ac..8f4b9a2bd 100644 --- a/skills/sdk-knowledge-maintenance/SKILL.md +++ b/skills/sdk-knowledge-maintenance/SKILL.md @@ -62,18 +62,24 @@ These are the transferable behaviors this skill exists to preserve. in present tense. (Host-framework version facts the reader must act on today — e.g. a framework that resolves a handler differently across its own major versions — are present-state environment facts and are fine.) Revisit at the SDK's first major. -- **Every fact carries a `source:` pointer.** A path, `path#symbol`, or `path:line` into - `packages/**/src`. A fact without a source pointer is a claim, not knowledge — do not add it. +- **Every fact carries a `source:` pointer in the grammar.** Pointers are machine-checked by + `pnpm knowledge:check`; the grammar is defined in the base's own `README.md#source-pointer-grammar` + and is symbol-anchored — `##` (plus `impl:`, `concept:`, `kb:`, `extern:`), + **never line numbers** (they drift). A fact without a resolvable pointer is a claim, not knowledge + — do not add it. Never point a fact at a guide or "the accepted guide" to satisfy the rule; that is + circular and the checker rejects it. Anchor on the symbol the fact is actually about. - **Capture once, as a byproduct of verification.** When you verify an SDK API against source while doing other work (writing a guide, fixing a bug, answering a question), record what you confirmed - here before the context is lost. Do not run net-new verification passes just to fill the base in; - do not re-derive a fact the base already holds. + here before the context is lost, with its grammar pointer. Do not run net-new verification passes + just to fill the base in; do not re-derive a fact the base already holds. - **Read the base before re-grepping the SDK.** It exists so authors and future regeneration reuse verified facts instead of re-searching `packages/**/src`. Check here first; only grep to confirm or extend. - **Keep it in sync when the SDK changes.** If a symbol, prop, cookie, config key, export path, or return shape you touched is recorded here, update the entry and its `source:` pointer in the same - change. Stale facts are worse than missing ones. + change. Stale facts are worse than missing ones. `pnpm knowledge:check` runs in CI on every change + to `packages/**/src`, so a rename that orphans a pointer fails the build on your PR — fix it here, + do not wait for a follow-up. - **Capture shared facts once, in `shared/`.** SDK-neutral concepts go in `shared/concepts.md`; canonical terms in `shared/vocabulary.md`. Per-SDK files link to them instead of restating them. - **Log cross-guide drift in `shared/consistency-notes.md`.** When you spot language, an API name, @@ -101,7 +107,12 @@ These are the transferable behaviors this skill exists to preserve. ## Before you finish -- Every new or changed fact has a `source:` pointer into `packages/**/src`. +- **`pnpm knowledge:check` passes.** This is the gate: it resolves every `source:` pointer against + real files and symbols and enforces the grammar and template. Run it and fix every problem before + finishing — do not leave the base in a state that fails the checker. If you touched `packages/**/src` + in the same session, this also catches any recorded fact your change orphaned. +- Every new or changed fact has a resolvable `source:` pointer in the grammar (symbol-anchored, no + line numbers, never pointing at a guide). - Every entry reads as present-tense current state, with no change-ledger language ("no longer", "now", "was removed", "used to", PR/issue numbers, version-bump framing). Changes were edited in place, not appended as history. From 6089ea822b7a051226c54527591818f39f57accc Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:51:34 +0200 Subject: [PATCH 05/42] =?UTF-8?q?=F0=9F=91=B7=20chore(hooks):=20Add=20non-?= =?UTF-8?q?blocking=20Stop=20hook=20that=20surfaces=20knowledge-base=20dri?= =?UTF-8?q?ft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team-shared .claude/settings.json Stop hook runs .claude/hooks/knowledge-check.sh: when the working tree touched the KB or packages/**/src this session and knowledge:check is failing, it feeds the (capped) report back as additionalContext so drift gets fixed in the same turn. Advisory only — always exits 0, never traps the agent in a stop loop. CI remains the hard gate; this is the in-session nudge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/knowledge-check.sh | 53 ++++++++++++++++++++++++++++++++ .claude/settings.json | 16 ++++++++++ 2 files changed, 69 insertions(+) create mode 100755 .claude/hooks/knowledge-check.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/knowledge-check.sh b/.claude/hooks/knowledge-check.sh new file mode 100755 index 000000000..2849dad62 --- /dev/null +++ b/.claude/hooks/knowledge-check.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Stop hook: nudge (never block) when the SDK knowledge base is currently failing knowledge:check. +# +# This is the in-session half of the source→KB sync loop. CI is the hard gate; this hook just +# surfaces drift in the same turn it was introduced, so an agent can fix it before finishing rather +# than discovering it on the PR. It is intentionally advisory: it emits additionalContext and always +# exits 0, so it can never trap the agent in a stop loop. +set -uo pipefail + +# The hook receives the Stop event JSON on stdin; cwd tells us where the session is running. +input="$(cat)" +cwd="$(printf '%s' "$input" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" +[ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true + +# Only spend time when the knowledge base or package source changed in this working tree — a session +# that never touched either cannot have introduced KB drift. +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + changed="$(git status --porcelain -- documentation/internal/sdk-knowledge 'packages/**/src' 2>/dev/null)" + [ -z "$changed" ] && exit 0 +fi + +# Run the deterministic validator. On success, stay silent and let the turn end. +if output="$(pnpm --silent knowledge:check 2>&1)"; then + exit 0 +fi + +# On failure, feed the report back to the model as context (not a block). Cap the report so a large +# failure cannot flood the context window; the agent can always re-run knowledge:check for the rest. +max_lines=40 +capped="$(printf '%s\n' "$output" | head -n "$max_lines")" +[ "$(printf '%s\n' "$output" | wc -l)" -gt "$max_lines" ] && + capped="${capped} +… (truncated; run \`pnpm knowledge:check\` to see all problems)" + +# jq if present, else a minimal hand-built JSON payload — the hook must not depend on jq. +reason="Knowledge base check (pnpm knowledge:check) is failing. Fix these before finishing: + +${capped}" + +if command -v jq >/dev/null 2>&1; then + jq -n --arg ctx "$reason" \ + '{hookSpecificOutput: {hookEventName: "Stop", additionalContext: $ctx}}' +else + escaped="$(printf '%s' "$reason" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)" + if [ -n "$escaped" ]; then + printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":%s}}\n' "$escaped" + else + # Last resort: print to stderr so the user at least sees it. Still non-blocking. + printf '%s\n' "$reason" >&2 + fi +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..bcab6211c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/knowledge-check.sh" + } + ] + } + ] + } +} From 751f031c617840146f4f7dfa81f53dc43e64a536 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:52:13 +0200 Subject: [PATCH 06/42] =?UTF-8?q?=F0=9F=93=9D=20docs(skills):=20Close=20th?= =?UTF-8?q?e=20KB=E2=86=92guide=20loop=20in=20the=20guide-authoring=20skil?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guide-authoring step 5 (verify APIs against source) now tells authors to check the internal knowledge base FIRST — reusing facts already verified against source instead of re-grepping — and to record newly verified facts back into it as a byproduct. This is the propagation edge that lets each guide rely on the KB rather than re-deriving everything from scratch. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/optimization-guide-authoring/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index 039e62e13..e7f548979 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -155,6 +155,11 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. under `packages/**/src`, not only in the reference impl. The reference impl proves one path works but can hide nuance (a factory field it does not use, a provider the bound component renders internally), and a plausible-looking name is not proof it exists. + **Check the internal knowledge base first** (`documentation/internal/sdk-knowledge/`): it records + SDK facts already verified against source, each with a resolvable pointer, so you reuse them + instead of re-grepping. When you verify a new fact the base does not yet hold, record it there + (with its grammar pointer) as a byproduct — see the `sdk-knowledge-maintenance` skill. That is how + the next guide avoids re-deriving what this one established. 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. 7. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). From ef68064aa988d6be1c64be097f1eeb740f8ac7a6 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 10:55:36 +0200 Subject: [PATCH 07/42] =?UTF-8?q?=E2=9C=A8=20feat(skills):=20Encode=20the?= =?UTF-8?q?=20three-role=20guide=20authoring/review=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the docs writer → newcomer reviewer → technical-foundation reviewer loop as portable, standards-based artifacts (plain SKILL.md; no MCP/skill-kit dependency): - skills/guide-newcomer-review — read a guide cold as an average developer with no personalization background; report undefined jargon, skim-mode, unperformable steps, dishonest labels. Reader-experience only. - skills/guide-source-verification — prove every load-bearing SDK claim against packages/**/src with file:symbol evidence, reuse KB facts already verified, and record newly verified facts back into the knowledge base. This closes the capture edge: the verifier's output IS the KB delta. Add thin .claude/agents/{guide-writer,guide-newcomer-reviewer,guide-source-verifier}.md wrappers (each points at its skill — the portable source of truth) and a .claude/commands/review-guide.md that chains the roles and funnels durable learnings back into the authoring skill (principles) or the knowledge base (facts). Skills are discoverable via the existing skills/ → .claude/skills and .agents/skills directory symlinks, so they work in Claude Code and any agentskills.io-compatible tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-newcomer-reviewer.md | 20 +++++ .claude/agents/guide-source-verifier.md | 21 +++++ .claude/agents/guide-writer.md | 20 +++++ .claude/commands/review-guide.md | 38 ++++++++ skills/guide-newcomer-review/SKILL.md | 88 +++++++++++++++++++ skills/guide-newcomer-review/package.json | 9 ++ skills/guide-source-verification/SKILL.md | 71 +++++++++++++++ skills/guide-source-verification/package.json | 9 ++ 8 files changed, 276 insertions(+) create mode 100644 .claude/agents/guide-newcomer-reviewer.md create mode 100644 .claude/agents/guide-source-verifier.md create mode 100644 .claude/agents/guide-writer.md create mode 100644 .claude/commands/review-guide.md create mode 100644 skills/guide-newcomer-review/SKILL.md create mode 100644 skills/guide-newcomer-review/package.json create mode 100644 skills/guide-source-verification/SKILL.md create mode 100644 skills/guide-source-verification/package.json diff --git a/.claude/agents/guide-newcomer-reviewer.md b/.claude/agents/guide-newcomer-reviewer.md new file mode 100644 index 000000000..bf7716b68 --- /dev/null +++ b/.claude/agents/guide-newcomer-reviewer.md @@ -0,0 +1,20 @@ +--- +name: guide-newcomer-reviewer +description: >- + Review a documentation guide as an average developer with little or no personalization background — + the second authoring role. Catches undefined jargon, skim-mode triggers, unperformable steps, and + dishonest example labels. Use after a guide is drafted, before it ships. Reports findings; does not + verify SDK facts or rewrite prose. +tools: Read, Grep, Glob +--- + +You are the newcomer reviewer for Optimization SDK guides. Follow the **`guide-newcomer-review`** +skill exactly: read the guide cold, as its target reader (an average developer with no +personalization background), and report every place that reader gets stuck, confused, or silently +misled. + +Report findings with location, what a newcomer hits, severity (blocker/friction/polish), and an +optional one-line suggested direction — you do not rewrite prose (the writer owns that) and you do not +confirm whether APIs are real (flag suspected-wrong claims as "verify" for the +`guide-source-verification` role). Call out any finding that should become a rule in the +`optimization-guide-authoring` skill so future guides avoid it. Read-only: do not edit files. diff --git a/.claude/agents/guide-source-verifier.md b/.claude/agents/guide-source-verifier.md new file mode 100644 index 000000000..daa4aa13a --- /dev/null +++ b/.claude/agents/guide-source-verifier.md @@ -0,0 +1,21 @@ +--- +name: guide-source-verifier +description: >- + Verify every load-bearing SDK claim in a documentation guide against packages source, then record + verified facts into the internal knowledge base — the third authoring role. Use after a guide is + drafted and newcomer-reviewed, before it ships, or to fact-check a specific claim. +tools: Read, Grep, Glob, Edit, Bash +--- + +You are the technical-foundation reviewer for Optimization SDK guides. Follow the +**`guide-source-verification`** skill: prove every load-bearing claim (hook, prop, config key, +factory field, context field, return shape, cookie, event name, behavioral assertion) true against +`packages/**/src`, with `file:symbol` evidence. + +Check the internal knowledge base (`documentation/internal/sdk-knowledge/`) first and rely on facts +it already holds whose pointers resolve; verify the rest against source, reading implementation for +behavior, not just names. Record each newly verified fact into the knowledge base using the pointer +grammar (following `sdk-knowledge-maintenance`), and confirm `pnpm knowledge:check` passes before you +finish. Return a per-claim verdict (confirmed/wrong/imprecise/unverifiable) with evidence and the KB +action taken; hand wrong/imprecise claims to the writer with what the source actually does. Do not +rewrite the guide. diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md new file mode 100644 index 000000000..ebed1c49d --- /dev/null +++ b/.claude/agents/guide-writer.md @@ -0,0 +1,20 @@ +--- +name: guide-writer +description: >- + Draft or revise a documentation guide under documentation/guides/ for the Optimization SDK Suite. + The first authoring role. Use when writing a new integration/decision/recipe guide or rewriting an + existing one, before newcomer and technical-foundation review. +tools: Read, Edit, Write, Grep, Glob, Bash +--- + +You are the docs writer for the Optimization SDK Suite. Author or revise the requested guide under +`documentation/guides/` following the **`optimization-guide-authoring`** skill — its archetypes, +teach-first quick-start-then-deepen structure, copy-vs-adapt example labels, `## Before you start` +block, and self-review checklist are your source of truth. Ground every example in the matching +reference implementation under `implementations/`, and check the internal knowledge base +(`documentation/internal/sdk-knowledge/`) for facts already verified against source before re-grepping. + +You draft; you do not sign off. After your pass the guide goes to the `guide-newcomer-review` and +`guide-source-verification` roles. When they hand back findings, apply the fixes and fold any durable +lesson back into the authoring skill (principles only — never SDK facts, which belong in the guide or +the knowledge base). Return the edited guide path and a short summary of what you changed and why. diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md new file mode 100644 index 000000000..d6fade1a8 --- /dev/null +++ b/.claude/commands/review-guide.md @@ -0,0 +1,38 @@ +--- +description: Run the three-role authoring review on a documentation guide (newcomer + technical-foundation, then funnel learnings back) +argument-hint: '[guide file under documentation/guides/]' +--- + +Run the full authoring review loop on the guide: `$ARGUMENTS` (if empty, ask which guide under +`documentation/guides/`). + +Do this in order: + +1. **Newcomer review.** Launch the `guide-newcomer-reviewer` agent on the guide. It reads the guide + cold as an average developer and returns reader-experience findings (undefined jargon, skim-mode, + unperformable steps, dishonest labels), each with severity. + +2. **Technical-foundation review.** Launch the `guide-source-verifier` agent on the guide. It proves + every load-bearing SDK claim against `packages/**/src` with `file:symbol` evidence, relies on the + internal knowledge base for facts already verified, and records newly verified facts back into the + KB. It returns a per-claim verdict. + + Run these two reviews concurrently — they are independent (one reads for the reader, one reads the + source). + +3. **Consolidate and fix.** Collect both agents' findings. Apply the guide fixes via the + `guide-writer` agent (or directly, following `optimization-guide-authoring`): reader-experience + fixes from the newcomer pass, and correctness fixes for every claim the verifier marked wrong or + imprecise (using what the source actually does). + +4. **Funnel learnings back.** For each finding that reflects a durable rule — not a one-off — fold it + into the right artifact: + - a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles + only, never SDK facts), + - a newly verified SDK fact → the internal knowledge base via `sdk-knowledge-maintenance`. + +5. **Validate.** Run `pnpm knowledge:check` (KB must pass) and `pnpm format:fix` on the touched files, + and confirm the guide's TOC anchors resolve. + +Report: the findings from each role, what you changed in the guide, what you funneled back into the +skill vs. the knowledge base, and the validation result. diff --git a/skills/guide-newcomer-review/SKILL.md b/skills/guide-newcomer-review/SKILL.md new file mode 100644 index 000000000..19396c37a --- /dev/null +++ b/skills/guide-newcomer-review/SKILL.md @@ -0,0 +1,88 @@ +--- +name: guide-newcomer-review +description: >- + Review a documentation guide under documentation/guides/ as an average developer with little or no + personalization background — the guide's actual target reader. Catches undefined jargon, skim-mode + triggers, unperformable verify steps, and forward references that assume knowledge the reader does + not have yet. Reports reader-experience findings; it does not verify SDK facts against source (that + is guide-source-verification) and does not restructure prose (that is optimization-guide-authoring). + Use when reviewing a new or rewritten guide before it ships, as the second of the three authoring + roles (writer → newcomer reviewer → technical-foundation reviewer). Triggers on "newcomer review", + "review this guide", "read it cold", "average developer review", "docs reviewer". +argument-hint: '[guide file to review]' +paths: documentation/guides/** +--- + +# Reviewing a guide as a newcomer + +Read the guide the way its target reader does: **an average developer with little or no +personalization background**, opening it cold, wanting a working result fast. Your job is to find +every place that reader gets stuck, confused, or silently misled — not to fix the prose (the writer +owns that) and not to check whether the APIs are real (the technical-foundation reviewer owns that). +You report what breaks the reading experience; the writer decides how to fix it, and any rule worth +keeping goes back into the `optimization-guide-authoring` skill. + +## The mindset + +- **You do not know the domain.** You have not read the concept docs. If a term is used before it is + defined in plain language, that is a finding — even if it is "obvious" to someone who knows the SDK. +- **You skim.** If the guide re-walks the happy path you already saw, you will skim past it and miss + the part that matters. Flag anything that invites skim mode. +- **You act on each step as written.** If a step says "load as a qualifying visitor" without telling + you how to become one, you cannot do it. If the instruction you need is in a later section, you are + already stuck. Flag it. +- **You take labels literally.** A `**Copy this:**` block that does not actually work with simple + value substitution has lied to you. A magic-looking identifier you cannot tell is yours-to-invent + vs. must-match-exactly will cost you an hour. + +## What to look for + +Walk the guide top to bottom and record a finding wherever the reader would stumble: + +- **Undefined term at first use.** Any SDK or domain term (variant, experience, resolving, baseline + fallback, consent, a config key, an identifier, an event name) used before a plain-language + definition in prose — not only inside a code comment. +- **Jargon-stacked opener.** The first sentence, or a section opener, that stacks unexplained terms. +- **Skim-mode trigger.** A section that re-teaches the quick start's happy path before reaching new + material, with no bridge telling the reader what is genuinely new below. +- **Unperformable step.** A step (especially the verify step) that cannot be completed with only the + information present where the reader hits it — including forward references ("see the section + below") for an instruction the reader needs _now_. +- **Dishonest example label.** A `**Copy this:**` that needs relocation or structural change; a + snippet whose env-var names / endpoints / paths would not actually work; a placeholder-only block + labeled copyable. +- **Ambiguous ownership.** A cookie, env var, header, storage key, or config string where the reader + cannot tell whether they must match an SDK-defined name or may choose their own. +- **Filler / hype** that reads oddly in a reference doc ("this is the payoff", "boom", gratuitous + "just"). +- **Missing before-you-start prerequisite.** Something the reader must have gathered from outside the + guide (credentials, runtime setup, and especially authored Contentful data such as a variant on an + all-visitors experience) that is not called out, so the reader cannot tell personalization from a + bug. + +The `optimization-guide-authoring` skill's `references/authoring-checklist.md` (Group A + the +integration-guide group) is the mechanical backing for most of these — use it as your itemized pass. + +## How to report + +Return findings, not rewrites. For each: + +- **Location** — heading + the line or quoted phrase. +- **What a newcomer hits** — the concrete stumble ("`OptimizedEntry` render prop used here; `render +prop` is never defined, and I don't know personalization terms"). +- **Severity** — blocker (cannot proceed) / friction (proceeds but confused or misled) / polish. +- **Suggested direction** — optional, one line; the writer owns the actual fix. + +Then note whether any finding reflects a **rule the authoring skill should encode** so future guides +avoid it (e.g. "every render-prop term defined before first use"). Route those to the writer to fold +into `optimization-guide-authoring` — that is how the review loop improves the skill, not just this +guide. + +## Not in scope + +- **Verifying SDK facts against source** — whether an API, prop, or return shape is real is the + `guide-source-verification` role. If you _suspect_ a claim is wrong, flag it as "verify" and hand + it to that reviewer; do not confirm it yourself. +- **Rewriting prose or restructuring sections** — that is the writer with + `optimization-guide-authoring`. +- **Concept docs, package READMEs, generated TypeDoc.** diff --git a/skills/guide-newcomer-review/package.json b/skills/guide-newcomer-review/package.json new file mode 100644 index 000000000..17953d30e --- /dev/null +++ b/skills/guide-newcomer-review/package.json @@ -0,0 +1,9 @@ +{ + "name": "@contentful/skill-guide-newcomer-review", + "version": "0.1.0", + "description": "Review Optimization SDK guides as an average developer with no personalization background", + "license": "MIT", + "files": [ + "SKILL.md" + ] +} diff --git a/skills/guide-source-verification/SKILL.md b/skills/guide-source-verification/SKILL.md new file mode 100644 index 000000000..017c55520 --- /dev/null +++ b/skills/guide-source-verification/SKILL.md @@ -0,0 +1,71 @@ +--- +name: guide-source-verification +description: >- + Verify that every load-bearing SDK claim in a documentation guide is true against the SDK source + under packages/**/src — the technical-foundation review role. Confirms each hook, prop, config key, + context field, return shape, cookie, and event name a guide asserts actually exists and behaves as + described, with file:symbol evidence, then records what it verified into the internal knowledge base + so the next guide reuses it. Use as the third authoring role (writer → newcomer reviewer → + technical-foundation reviewer), after a guide is drafted or rewritten, or to fact-check a specific + claim. Triggers on "technical review", "verify against source", "fact-check the guide", "is this API + real", "foundation review". Not reader-experience review (guide-newcomer-review) and not prose + authoring (optimization-guide-authoring). +argument-hint: '[guide file or claim to verify]' +paths: documentation/guides/** +--- + +# Verifying a guide against source + +Every claim a guide makes about the SDK must be true against `packages/**/src`. Your job is to prove +it — API by API — and to leave behind verified facts the next guide can reuse instead of re-deriving. +You are the last line before a guide ships: a plausible-looking name is not proof it exists, and the +reference implementation proving one path works can still hide nuance. + +## Method + +1. **List the load-bearing claims.** Every hook, component, prop, config key, factory field, context + field, return shape, cookie/identifier, event name, and behavioral assertion ("denied consent + falls back to baseline", "server rendering forces dynamic") the guide states or shows in a snippet. +2. **Check the knowledge base first.** `documentation/internal/sdk-knowledge/` records facts already + verified against source, each with a resolvable `source:` pointer. If a claim is already recorded + and its pointer still resolves (`pnpm knowledge:check` passes), you can rely on it — do not + re-grep. This is the point of the base: verification is not repeated. +3. **Verify the rest against source.** For each remaining claim, find the symbol in `packages/**/src` + and confirm it exists and behaves as the guide says. Grep the export/type; read the implementation + for behavior, not just the name. The matching reference implementation under `implementations/` + shows one working path but can hide a factory field it does not use or a provider a bound component + renders internally — the source is the authority. +4. **Resolve source-vs-impl disagreements toward source.** When the reference impl and the source + seem to disagree, read the source and reconcile; the guide must match reality, and note the nuance. +5. **Record what you newly verified into the knowledge base.** As a byproduct — not a separate + project — add each newly confirmed fact to the right per-SDK file (or `shared/`) using the pointer + grammar, following the `sdk-knowledge-maintenance` skill. This is what closes the loop: the fact + you verified for this guide is now reusable, and `pnpm knowledge:check` guards it against drift. + +## How to report + +Return a verdict per load-bearing claim: + +- **Claim** — the exact assertion, with the guide location. +- **Verdict** — confirmed / wrong / imprecise / unverifiable. +- **Evidence** — `file:symbol` in `packages/**/src` (or the KB entry you relied on). For a wrong or + imprecise claim, state what the source actually does. +- **KB action** — recorded new fact `X` in `web/.md` (with pointer) / already held / n/a. + +Flag wrong and imprecise claims to the writer to fix in the guide. Do not rewrite the guide yourself +— hand corrections back to `optimization-guide-authoring`. + +## Before you finish + +- Every load-bearing claim has a verdict backed by `file:symbol` evidence or a KB entry. +- Every fact you newly verified is recorded in the knowledge base with a grammar pointer, and + `pnpm knowledge:check` passes. +- Wrong/imprecise claims are handed to the writer with what the source actually does. + +## Not in scope + +- **Reader-experience review** (undefined jargon, skim mode, performable steps) — that is + `guide-newcomer-review`. +- **Prose authoring and structure** — that is `optimization-guide-authoring`. +- **Maintaining the knowledge base's format rules** — that is `sdk-knowledge-maintenance`; this skill + uses it, and records facts, but does not own the grammar. diff --git a/skills/guide-source-verification/package.json b/skills/guide-source-verification/package.json new file mode 100644 index 000000000..c3980cad7 --- /dev/null +++ b/skills/guide-source-verification/package.json @@ -0,0 +1,9 @@ +{ + "name": "@contentful/skill-guide-source-verification", + "version": "0.1.0", + "description": "Verify Optimization SDK guide claims against packages source and record verified facts", + "license": "MIT", + "files": [ + "SKILL.md" + ] +} From 4fbe90d63f887a9a7d3ef8b54fd948bdaf2911f1 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:05:43 +0200 Subject: [PATCH 08/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Mig?= =?UTF-8?q?rate=20App=20Router=20KB=20pointers=20to=20the=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite every source pointer in web/nextjs-app-router.md to the machine-checked grammar, replacing the circular "source: accepted App Router guide" references (~6) with real symbols verified against packages/web/frameworks/nextjs-sdk/src (createNextjsAppRouterOptimization, OptimizedEntry, NextAppAutoPageTracker, the bound-component-types, request-handler, cookies helpers) and cross-SDK symbols in core-sdk / react-web-sdk / api-schemas. Drops all line-number anchors and the stray artifact. pnpm knowledge:check passes for this file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk-knowledge/web/nextjs-app-router.md | 87 +++++++++++-------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md index c8b16e257..067d8c909 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -6,25 +6,27 @@ Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only App-Router specifics. Pages Router surface: see [`nextjs-pages-router.md`](./nextjs-pages-router.md). Facts here are -sourced from the accepted App Router guide plus `src/app-router-*`; deepen with file:line evidence -when this SDK's guide is next revised. +verified against `packages/web/frameworks/nextjs-sdk/src` and its `react-web-sdk`/`core-sdk` +dependencies; each carries a symbol-anchored source pointer. ## Package & entry points -| Import path | Purpose | source | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------- | -| `@contentful/optimization-nextjs/app-router` | Factory → bound `OptimizationRoot`, `OptimizedEntry`, `NextAppAutoPageTracker`, `proxy` | `src/app-router-server.tsx`; `package.json` exports | -| `@contentful/optimization-nextjs/client` | Browser-only hooks + per-entry live-update controls (`'use client'`) | `src/client.ts`; `package.json` exports | -| `@contentful/optimization-nextjs/server` | Manual server SDK control (advanced routes) | `src/server.tsx`; `package.json` exports | -| `@contentful/optimization-nextjs/api-schemas` | Type guards `isMergeTagEntry`, `isResolvedContentfulEntry` | `src/api-schemas.ts` | +| Import path | Purpose | source | +| --------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/app-router` | Factory → bound `OptimizationRoot`, `OptimizedEntry`, `NextAppAutoPageTracker`, `proxy` | `nextjs-sdk#app-router-server.tsx#createNextjsAppRouterOptimization`; `nextjs-sdk#app-router-server.tsx#NextjsOptimizationComponents` | +| `@contentful/optimization-nextjs/client` | Browser-only hooks + per-entry live-update controls (`'use client'`) | `nextjs-sdk#client.ts` | +| `@contentful/optimization-nextjs/server` | Manual server SDK control (advanced routes) | `nextjs-sdk#server.tsx#createNextjsOptimization` | +| `@contentful/optimization-nextjs/api-schemas` | Type guards `isMergeTagEntry`, `isResolvedContentfulEntry` | `nextjs-sdk#api-schemas.ts`; `api-schemas#contentful/typeGuards.ts#isMergeTagEntry`; `api-schemas#contentful/typeGuards.ts#isResolvedContentfulEntry` | Note: `/app-router` has a `react-server` export condition (server build) distinct from its browser -build. source: `package.json` exports. +build — the two builds are separate source entries. +source: `nextjs-sdk#app-router-server.tsx`; `nextjs-sdk#app-router-client.ts` The package root (`@contentful/optimization-nextjs`) is not an import path — the `package.json` exports map starts at `./app-router`, with no `.` entry. React Web hooks/providers import from `/client`; the bound `OptimizationRoot` / `OptimizedEntry` / `NextAppAutoPageTracker` import from -`/app-router`. source: `nextjs-sdk/package.json` exports (no `.` entry). +`/app-router`. +source: `nextjs-sdk#package.test.ts`; `nextjs-sdk#app-router-server.tsx#NextjsOptimizationComponents` ## Setup / factory @@ -33,73 +35,84 @@ exports map starts at `./app-router`, with no `.` entry. React Web hooks/provide Config keys: `clientId`, `environment`, `locale`, `api?`, `defaults` (`consent`, `persistenceConsent`), `server` (`enabled`, `consent` — value or `({ cookies }) => consent`), `app` (`name`, `version`), plus `liveUpdates?`, `trackEntryInteraction?`, `onStatesReady?`, - `allowedEventTypes?`. Create bound components once. source: `src/app-router-server.tsx:66-74,88` - (5-member interface + factory). + `allowedEventTypes?`. Create bound components once. + source: `nextjs-sdk#app-router-server.tsx#createNextjsAppRouterOptimization`; `nextjs-sdk#app-router-server.tsx#NextjsOptimizationComponents`; `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerOptions` - **`contentful?: ContentfulConfig` (managed fetching):** via the core `contentful` config. Lets the bound server `OptimizedEntry` fetch by `entryId` (via `sdk.fetchOptimizedEntry(entryId, { query })`) as an alternative to a manual `baselineEntry`. The factory strips `contentful` before building the CLIENT provider config (`toClientProviderConfig` - destructures `contentful: _contentful`); managed fetching runs on the server side. source: - `src/app-router-server.tsx:145-223,284`; `core-sdk` `CoreBase.ts:173`. + destructures `contentful: _contentful`); managed fetching runs on the server side. + source: `nextjs-sdk#app-router-server.tsx#resolveManagedServerOptimizedEntry`; `nextjs-sdk#app-router-server.tsx#toClientProviderConfig`; `core-sdk#CoreBase.ts#ContentfulConfig`; `core-sdk#CoreBase.ts#fetchOptimizedEntry` ## Components & hooks -| Name | Kind | Import path | Key props/args | Returns | source | -| ---------------------------------------------------------------------------------------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------- | -| `OptimizationRoot` | component | `/app-router` (bound) | `children` (takes no per-render config) | element | accepted App Router guide | -| `OptimizedEntry` | component | `/app-router` (bound) | discriminated union `baselineEntry` (manual) XOR `entryId` (+`entryQuery?`, managed server fetch); render-prop child. Omits per-render `liveUpdates`/`loadingFallback` | element / `null` | `src/app-router-server.tsx:57-63,145-223` | -| `OptimizedEntry` | component | `/client` | adds per-entry `liveUpdates`, `loadingFallback` (+ `errorFallback`/`onEntryError` for managed) | element / `null` | react-web `OptimizedEntry.tsx:81-90,118-133` | -| `NextAppAutoPageTracker` | component | `/app-router` | `initialPageEvent`, `getPagePayload`; needs `Suspense` (reads `useSearchParams`) | element | react-web `router/next-app.tsx:3, 51` | -| `useOptimizationActions` | hook | `/client` | — | `{ setConsent, identifyUser, resetUser }` | accepted App Router guide | -| `useConsentState` / `useProfileState` / `useOptimizationContext` / `useMergeTagResolver` | hook | `/client` | — | state / `{ sdk }` / resolver | accepted App Router guide | +| Name | Kind | Import path | Key props/args | Returns | source | +| ---------------------------------------------------------------------------------------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OptimizationRoot` | component | `/app-router` (bound) | `children` (takes no per-render config) | element | `nextjs-sdk#app-router-server.tsx#OptimizationRoot`; `nextjs-sdk#bound-component-types.ts#BoundNextjsOptimizationRootProps` | +| `OptimizedEntry` | component | `/app-router` (bound) | discriminated union `baselineEntry` (manual) XOR `entryId` (+`entryQuery?`, managed server fetch); render-prop child. Omits per-render `liveUpdates`/`loadingFallback` | element / `null` | `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#bound-component-types.ts#NextjsBoundOptimizedEntryProps` | +| `OptimizedEntry` | component | `/client` | adds per-entry `liveUpdates`, `loadingFallback` (+ `errorFallback`/`onEntryError` for managed) | element / `null` | `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry` | +| `NextAppAutoPageTracker` | component | `/app-router` | `initialPageEvent`, `getPagePayload`; needs `Suspense` (reads `useSearchParams`) | element | `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker` | +| `useOptimizationActions` | hook | `/client` | — | `{ setConsent, identifyUser, resetUser }` | `react-web-sdk#hooks/useOptimizationActions.ts#useOptimizationActions`; `react-web-sdk#hooks/useOptimizationActions.ts#UseOptimizationActionsResult` | +| `useConsentState` / `useProfileState` / `useOptimizationContext` / `useMergeTagResolver` | hook | `/client` | — | state / `{ sdk }` / resolver | `react-web-sdk#hooks/useOptimizationState.ts#useConsentState`; `react-web-sdk#hooks/useOptimizationState.ts#useProfileState`; `react-web-sdk#hooks/useOptimization.ts#useOptimizationContext`; `react-web-sdk#hooks/useMergeTagResolver.ts#useMergeTagResolver` | Note: bound `/app-router` `OptimizedEntry` omits per-entry `liveUpdates`/`loadingFallback` so the same import type-checks in Server + Client Components; use `/client` `OptimizedEntry` for per-entry -control. source: accepted App Router guide. +control. +source: `nextjs-sdk#bound-component-types.ts#NextjsBoundOptimizedEntryProps`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntryProps` ## Render / entry resolution - Render prop hands back a base `contentful` `Entry`; cast `resolved as YourType` (`as unknown as YourType` only for genuinely disjoint types). Baseline-fallback contract: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). Double-wrapping the same - baseline id renders `null` + dev warning. source: accepted App Router guide. + baseline id renders `null` + dev warning. source: + `react-web-sdk#optimized-entry/optimizedEntryUtils.ts#OptimizedEntryRenderContext`; + `react-web-sdk#optimized-entry/OptimizedEntry.tsx#useDuplicateBaselineGuard`. ## Identifier ownership -| Identifier | Owner | Notes | source | -| --------------------------------------- | ------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by `proxy`; must NOT be `HttpOnly` (browser reads it) | `core-sdk/constants.ts:38`; `server.tsx:17`; not-HttpOnly in `cookies.ts:39-67` | -| app consent cookie | reader | Reader names/writes/reads; SDK only calls `server.consent` | accepted App Router guide | -| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to browser | Next.js convention | +| Identifier | Owner | Notes | source | +| --------------------------------------- | ------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by `proxy`; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#persistNextjsAnonymousId`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader` | +| app consent cookie | reader | Reader names/writes/reads; SDK only calls `server.consent` | `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentResolver`; `nextjs-sdk#request-handler.ts#resolveServerConsent` | +| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | ## Events & tracking - `NextAppAutoPageTracker` must stay inside `Suspense` (reads `useSearchParams`). Duplicate-page-event control: `initialPageEvent="skip"` when the server already reported the view, - `"emit"` for browser-owned routes. source: accepted App Router guide; react-web - `router/next-app.tsx:3, 51`. + `"emit"` for browser-owned routes. source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; + `react-web-sdk#auto-page/useAutoPageEmitter.ts#InitialAutoPageEvent`. - Interaction tracking on by default with `OptimizedEntry`; opt out via factory - `trackEntryInteraction`; uses resolved entry id. source: accepted App Router guide. + `trackEntryInteraction`; uses resolved entry id. source: + `react-web-sdk#provider/OptimizationProvider.tsx#TrackEntryInteractionOptions`; + concept:interaction-tracking-in-web-sdks. ## Consent & persistence - Model: see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). Per-request `server.consent` reads request `cookies.get(NAME)`; browser seeded via `defaults`. source: - accepted App Router guide. + `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentContext`; + `nextjs-sdk#app-router-server.tsx#resolveClientDefaults`. ## Version / runtime quirks - **Request-handler filename/export is Next.js-version-specific:** Next.js 16 loads a `proxy` export from `proxy.ts`; Next.js 15 loads a `middleware` export from `middleware.ts` (alias `export { proxy as middleware }`). Wrong filename/export ⇒ handler silently never runs, and with - `server.enabled: true` the bound root THROWS (not baseline). source: accepted App Router guide. + `server.enabled: true` the bound root THROWS (not baseline). source: + `nextjs-sdk#app-router-server.tsx#loadServerData`; + `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; + `extern:Next.js 16 loads a proxy export from proxy.ts; Next.js 15 loads a middleware export from middleware.ts`. - **Server personalization forces dynamic rendering:** bound server components read `headers()` ⇒ - route can no longer use `revalidate` / `generateStaticParams` (ISR/SSG). source: accepted App - Router guide. + route can no longer use `revalidate` / `generateStaticParams` (ISR/SSG). source: + `nextjs-sdk#app-router-server.tsx#loadServerData`; + `extern:calling next/headers headers() opts a route into dynamic rendering, disabling revalidate and generateStaticParams`. ## Failure & fallback behavior - Baseline fallback on denied consent / no variant / unresolved links / all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). With `server.enabled: true`, a missing/misnamed request handler makes the bound root throw rather than fall back. source: - accepted App Router guide. + `nextjs-sdk#app-router-server.tsx#loadServerData`; + `nextjs-sdk#app-router-server.tsx#isAutomaticServerOptimizationData`. From d702b8a0c7f61fbd939c115996a10248584a948e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:11:38 +0200 Subject: [PATCH 09/42] =?UTF-8?q?=F0=9F=90=9E=20fix(docs):=20Catch=20wrapp?= =?UTF-8?q?ed=20source-pointer=20lines=20in=20knowledge:check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose `source:` matcher only reads tokens on the same physical line, so a pointer wrapped onto a continuation line silently skipped validation (a fake symbol on a wrapped line was not flagged). Add checkDanglingPointerLines: a line consisting only of grammar tokens (each segment carries a `#` or known prefix) is flagged as a wrapped pointer, enforcing the grammar's "one line per source pointer" rule. Prose continuations ("The React Web guides…") have plain words and are not flagged. Surfaced by the App Router KB migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/validate-sdk-knowledge.ts | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index 18062c0fe..c229f6d44 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -83,6 +83,11 @@ function validateFile(absPath: string): void { } } + // A `source:` line must not wrap (its tokens run to end of line). Catch a continuation line that + // is nothing but pointer tokens — those tokens would otherwise be parsed by no one and skip + // validation entirely. Prose continuations ("The React Web guides…") have no such shape. + checkDanglingPointerLines(lines, relPath) + // Coverage and template conformance apply only to per-SDK files, whose _template.md shape makes // "every bullet/row is a sourced fact" a firm rule. shared/ files mix normative prose with facts. if (isPerSdkFile(absPath)) { @@ -148,6 +153,42 @@ function handleTableRow( return sourceColumnIndex } +/** + * Flags a line that is a wrapped continuation of a `source:` line — a line consisting only of + * grammar tokens (each segment carries a `#` or a known prefix), which the extractor above does not + * read, so its tokens would silently skip validation. The grammar requires a source pointer to sit + * on one line; this enforces it. A prose continuation ("The React Web guides describe…") has plain + * words and is not flagged. + */ +function checkDanglingPointerLines(lines: string[], file: string): void { + lines.forEach((rawLine, index) => { + const line = rawLine.trim() + if (line === '' || line.includes('source:') || line.startsWith('|') || line.startsWith('#')) { + return + } + const segments = line.split(';').map((segment) => segment.trim().replace(/^`|`$|\.$/gu, '')) + if (segments.length > 0 && segments.every(looksLikePointerToken)) { + addProblem( + file, + index + 1, + `line looks like wrapped source pointers ("${truncate(line)}") — a source: pointer must ` + + `stay on its own single line so every token is validated`, + ) + } + }) +} + +/** A bare token that could only be a grammar pointer: has a known prefix or an `#…` shape. */ +function looksLikePointerToken(segment: string): boolean { + if (segment === '') { + return false + } + if (/^(impl|concept|kb|extern):/u.test(segment)) { + return true + } + return /^[a-z0-9-]+#[^\s]/u.test(segment) +} + /** Returns the pointer text of a `source: …` prose line, or undefined if the line is not one. */ function matchProseSource(line: string): string | undefined { const match = /(?:^|\s)source:\s*(.+)$/u.exec(line) From 783aee1c2eee02e08000a45ebeb9cbb52f5cfcff Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:11:45 +0200 Subject: [PATCH 10/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Col?= =?UTF-8?q?lapse=20wrapped=20App=20Router=20source=20pointers=20onto=20sin?= =?UTF-8?q?gle=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the App Router migration: put every prose `source:` pointer on its own single line (safe under proseWrap preserve) so all tokens are validated, and reword two extern: texts that contained the literal token separator. knowledge:check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk-knowledge/web/nextjs-app-router.md | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md index 067d8c909..59787c624 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -65,9 +65,8 @@ source: `nextjs-sdk#bound-component-types.ts#NextjsBoundOptimizedEntryProps`; `r - Render prop hands back a base `contentful` `Entry`; cast `resolved as YourType` (`as unknown as YourType` only for genuinely disjoint types). Baseline-fallback contract: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). Double-wrapping the same - baseline id renders `null` + dev warning. source: - `react-web-sdk#optimized-entry/optimizedEntryUtils.ts#OptimizedEntryRenderContext`; - `react-web-sdk#optimized-entry/OptimizedEntry.tsx#useDuplicateBaselineGuard`. + baseline id renders `null` + dev warning. + source: `react-web-sdk#optimized-entry/optimizedEntryUtils.ts#OptimizedEntryRenderContext`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#useDuplicateBaselineGuard` ## Identifier ownership @@ -81,38 +80,32 @@ source: `nextjs-sdk#bound-component-types.ts#NextjsBoundOptimizedEntryProps`; `r - `NextAppAutoPageTracker` must stay inside `Suspense` (reads `useSearchParams`). Duplicate-page-event control: `initialPageEvent="skip"` when the server already reported the view, - `"emit"` for browser-owned routes. source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; - `react-web-sdk#auto-page/useAutoPageEmitter.ts#InitialAutoPageEvent`. + `"emit"` for browser-owned routes. + source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#InitialAutoPageEvent` - Interaction tracking on by default with `OptimizedEntry`; opt out via factory - `trackEntryInteraction`; uses resolved entry id. source: - `react-web-sdk#provider/OptimizationProvider.tsx#TrackEntryInteractionOptions`; - concept:interaction-tracking-in-web-sdks. + `trackEntryInteraction`; uses resolved entry id. + source: `react-web-sdk#provider/OptimizationProvider.tsx#TrackEntryInteractionOptions`; concept:interaction-tracking-in-web-sdks ## Consent & persistence - Model: see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). Per-request - `server.consent` reads request `cookies.get(NAME)`; browser seeded via `defaults`. source: - `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentContext`; - `nextjs-sdk#app-router-server.tsx#resolveClientDefaults`. + `server.consent` reads request `cookies.get(NAME)`; browser seeded via `defaults`. + source: `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentContext`; `nextjs-sdk#app-router-server.tsx#resolveClientDefaults` ## Version / runtime quirks - **Request-handler filename/export is Next.js-version-specific:** Next.js 16 loads a `proxy` export from `proxy.ts`; Next.js 15 loads a `middleware` export from `middleware.ts` (alias `export { proxy as middleware }`). Wrong filename/export ⇒ handler silently never runs, and with - `server.enabled: true` the bound root THROWS (not baseline). source: - `nextjs-sdk#app-router-server.tsx#loadServerData`; - `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; - `extern:Next.js 16 loads a proxy export from proxy.ts; Next.js 15 loads a middleware export from middleware.ts`. + `server.enabled: true` the bound root THROWS (not baseline). + source: `nextjs-sdk#app-router-server.tsx#loadServerData`; `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; `extern:Next.js 16 loads a proxy export from proxy.ts whereas Next.js 15 loads a middleware export from middleware.ts` - **Server personalization forces dynamic rendering:** bound server components read `headers()` ⇒ - route can no longer use `revalidate` / `generateStaticParams` (ISR/SSG). source: - `nextjs-sdk#app-router-server.tsx#loadServerData`; - `extern:calling next/headers headers() opts a route into dynamic rendering, disabling revalidate and generateStaticParams`. + route can no longer use `revalidate` / `generateStaticParams` (ISR/SSG). + source: `nextjs-sdk#app-router-server.tsx#loadServerData`; `extern:calling next/headers headers() opts a route into dynamic rendering, disabling revalidate and generateStaticParams` ## Failure & fallback behavior - Baseline fallback on denied consent / no variant / unresolved links / all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). With `server.enabled: true`, a - missing/misnamed request handler makes the bound root throw rather than fall back. source: - `nextjs-sdk#app-router-server.tsx#loadServerData`; - `nextjs-sdk#app-router-server.tsx#isAutomaticServerOptimizationData`. + missing/misnamed request handler makes the bound root throw rather than fall back. + source: `nextjs-sdk#app-router-server.tsx#loadServerData`; `nextjs-sdk#app-router-server.tsx#isAutomaticServerOptimizationData` From ab1c125ae143b73add42f2489d957ce1197f7ec6 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:19:25 +0200 Subject: [PATCH 11/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Mig?= =?UTF-8?q?rate=20Web=20SDK=20KB=20pointers=20to=20the=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite all ~90 pointer tokens in web/web.md to the symbol-anchored grammar (pnpm knowledge:check passes). Beyond format, the migration re-verified every fact against source and corrected drift the old line/path pointers had hidden: - Moved-file paths: state/EventEmissionResult → events/, state/Consent → consent/, state/StatefulDefaults → package root, handlers/AcceptedCurrentStateTracker → tracking/; a referenced RootElement.ts that no longer exists → the real web-components/ContentfulOptimizationRootElement.ts. - Non-declared anchors re-pointed to their true declaring symbols (Zod object consts, ResolvedData in the resolver, LogLevels in api-client). - Circular "guide" pointers replaced with concrete symbols and concept: links. Multi-line behavioral facts keep their full assertions, now anchored to the enclosing symbol instead of drift-prone line ranges. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/web/web.md | 168 +++++++++--------- 1 file changed, 85 insertions(+), 83 deletions(-) diff --git a/documentation/internal/sdk-knowledge/web/web.md b/documentation/internal/sdk-knowledge/web/web.md index 3009aa9ee..4a03bbfde 100644 --- a/documentation/internal/sdk-knowledge/web/web.md +++ b/documentation/internal/sdk-knowledge/web/web.md @@ -11,55 +11,58 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: ## Package & entry points -| Import path | Purpose | source | -| ----------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `@contentful/optimization-web` (default export) | `ContentfulOptimization` class | `src/index.ts:49`; `src/ContentfulOptimization.ts:503` | -| `@contentful/optimization-web/web-components` | `defineContentfulOptimizationElements()` + element/detail types | `src/web-components/index.ts:4-5,21-45` | -| `@contentful/optimization-web/api-schemas` | Type guards incl. `isMergeTagEntry` | `src/api-schemas.ts:1`; re-export chain → `api-schemas/src/contentful/index.ts:7` | -| `@contentful/optimization-web/constants` | `ANONYMOUS_ID_COOKIE`, `DEFAULT_WEB_ALLOWED_EVENT_TYPES`, etc. | `src/constants.ts:52`; `package.json` exports | -| `@contentful/optimization-web/logger` | logger utilities | `package.json` exports | +| Import path | Purpose | source | +| ----------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `@contentful/optimization-web` (default export) | `ContentfulOptimization` class | web-sdk#index.ts; web-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| `@contentful/optimization-web/web-components` | `defineContentfulOptimizationElements()` + element/detail types | web-sdk#web-components/index.ts#defineContentfulOptimizationElements | +| `@contentful/optimization-web/api-schemas` | Type guards incl. `isMergeTagEntry` | web-sdk#api-schemas.ts; api-schemas#contentful/typeGuards.ts#isMergeTagEntry | +| `@contentful/optimization-web/constants` | `ANONYMOUS_ID_COOKIE`, `DEFAULT_WEB_ALLOWED_EVENT_TYPES`, etc. | web-sdk#constants.ts#DEFAULT_WEB_ALLOWED_EVENT_TYPES; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | +| `@contentful/optimization-web/logger` | logger utilities | web-sdk#logger.ts | ## Setup / factory - `new ContentfulOptimization(config)` — stateful; create ONE per browser runtime and reuse. In a browser it attaches to `window.contentfulOptimization` and **throws `ContentfulOptimization is already initialized`** if one already exists. `destroy()` for teardown - only. source: `src/ContentfulOptimization.ts:290-291,375,490-500`. + only. + source: web-sdk#ContentfulOptimization.ts#ContentfulOptimization; web-sdk#ContentfulOptimization.ts#destroy - Config keys (verified): - - `clientId`, `environment` — `api-client` `ApiClientBase.ts:33,46,54`. - - `locale`, `logLevel` (`'warn'`/`'debug'`) — `core-sdk` `CoreBase.ts:46,54`; - `lib/logger/logging.ts`. - - `api.experienceBaseUrl` / `api.insightsBaseUrl` — `core-sdk` `CoreApiConfig.ts:12-15`. + - `clientId`, `environment`. + source: api-client#ApiClientBase.ts#clientId; api-client#ApiClientBase.ts#environment + - `locale`, `logLevel` (`'warn'`/`'debug'`). + source: core-sdk#CoreBase.ts#locale; core-sdk#CoreBase.ts#logLevel; api-client#lib/logger/logging.ts#LogLevels + - `api.experienceBaseUrl` / `api.insightsBaseUrl`. + source: core-sdk#CoreApiConfig.ts#experienceBaseUrl; core-sdk#CoreApiConfig.ts#insightsBaseUrl - `defaults.consent`, `defaults.persistenceConsent` — **`persistenceConsent` defaults to - `consent`** (`persistenceConsent ?? consent`). source: `core-sdk` - `state/StatefulDefaults.ts:85-86`. - - `allowedEventTypes`, `queuePolicy`, `onEventBlocked` — `core-sdk` `CoreStateful.ts:184,195,197`. - - `app.name` / `app.version` — `api-schemas/.../properties/App.ts:19,24`. - - `cookie.domain`, `cookie.expires` (days; **default 365**) — `src/lib/cookies.ts:11-23`; - `ContentfulOptimization.ts:58,309`. - - `autoTrackEntryInteraction` — default `views`/`clicks`/`hovers` all `true`. source: - `src/entry-tracking/resolveAutoTrackEntryInteractionOptions.ts:123-131`. + `consent`** (`persistenceConsent ?? consent`). + source: core-sdk#StatefulDefaults.ts#resolveStatefulDefaults + - `allowedEventTypes`, `queuePolicy`, `onEventBlocked`. + source: core-sdk#CoreStateful.ts#allowedEventTypes; core-sdk#CoreStateful.ts#queuePolicy; core-sdk#CoreStateful.ts#onEventBlocked + - `app.name` / `app.version`. + source: api-schemas#experience/event/properties/App.ts#App + - `cookie.domain`, `cookie.expires` (days; **default 365**). + source: web-sdk#lib/cookies.ts#CookieAttributes; web-sdk#ContentfulOptimization.ts#EXPIRATION_DAYS_DEFAULT + - `autoTrackEntryInteraction` — default `views`/`clicks`/`hovers` all `true`. + source: web-sdk#entry-tracking/resolveAutoTrackEntryInteractionOptions.ts#resolveAutoTrackEntryInteractionOptions - **`contentful?: ContentfulConfig` (managed entry fetching):** opt-in; from `CoreConfig` (`OptimizationWebConfig extends CoreStatefulConfig extends CoreConfig`). `{ client: ContentfulEntryClient, defaultQuery?: ContentfulEntryQuery, cache?: false |` `{ maxEntries?, ttlMs? } }`. `ContentfulEntryClient` = `{ getEntry(entryId, query?) }`; `ContentfulEntryQuery = EntryQueries`. Managed query merges `defaultQuery` + per-call query + SDK locale fallback + `include: 10`. Cache default - `{ maxEntries: 100, ttlMs: 300_000 }`; `cache: false` disables. source: `core-sdk` `CoreBase.ts` - `CoreConfig.contentful` (:173), `ContentfulConfig` (:68), `ContentfulEntryClient` (:46), - `ContentfulEntryQuery` (:34); `web-sdk` `ContentfulOptimization.ts:68` - (`extends CoreStatefulConfig`); `CoreStateful.ts:175` (`CoreStatefulConfig extends CoreConfig`). + `{ maxEntries: 100, ttlMs: 300_000 }`; `cache: false` disables. + source: core-sdk#CoreBase.ts#CoreConfig; core-sdk#CoreBase.ts#ContentfulConfig; core-sdk#CoreBase.ts#ContentfulEntryClient; core-sdk#CoreBase.ts#ContentfulEntryQuery; core-sdk#CoreBase.ts#ContentfulEntryCacheOptions; web-sdk#ContentfulOptimization.ts#OptimizationWebConfig; core-sdk#CoreStateful.ts#CoreStatefulConfig ## Components & hooks None (imperative class + Web Components; no React surface). Web Components element table: -| Element / symbol | Kind | Notes | source | -| ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `defineContentfulOptimizationElements()` | registrar | Side-effect-free until called; registers the two custom elements | `src/web-components/index.ts:4-5,21-45` | -| `` | element | Attributes: `client-id`, `environment`, `locale`, `live-updates`. Properties: `defaults`, `api`, `trackEntryInteraction`, `sdk`, `onStatesReady`. Reuses `window.contentfulOptimization` automatically if present (`this.assignedSdk ?? getGlobalSdk()`); `sdk` prop supplies an explicit instance. Root-owned SDK defaults view/click/hover on. Events `ctfl-root-ready` / `ctfl-root-error`. | `web-components/ContentfulOptimizationRootElement.ts:51-53,123-166,217,42-48`; `RootElement.ts:13-14`; `presentation/optimizationRootRuntime.ts:131-133` | -| `` | element | Property `baselineEntry` (manual: assigning it triggers resolution) OR `entry-id` attribute / `entryId` property (managed: SDK fetches by ID via configured `contentful.client`); `entryQuery` property; `sdk`. Observed attributes: `entry-id`, `live-updates`, `track-clicks`, `track-hovers`, `track-views`. Events `ctfl-entry-loading` / `ctfl-entry-resolved` / `ctfl-entry-error`. | `web-components/ContentfulOptimizedEntryElement.ts:76-77,92-119,197-201,253-262,16-18` | -| `ContentfulOptimizedEntryEventDetail` | type | `{ entry, metadata, resolvedData, selectedOptimization, selectedOptimizations, snapshot }` | `ContentfulOptimizedEntryElement.ts:27-34,453-468` | +| Element / symbol | Kind | Notes | source | +| ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineContentfulOptimizationElements()` | registrar | Side-effect-free until called; registers the two custom elements | web-sdk#web-components/index.ts#defineContentfulOptimizationElements | +| `` | element | Attributes: `client-id`, `environment`, `locale`, `live-updates`. Properties: `defaults`, `api`, `trackEntryInteraction`, `sdk`, `onStatesReady`. Reuses `window.contentfulOptimization` automatically if present (`this.assignedSdk ?? getGlobalSdk()`); `sdk` prop supplies an explicit instance. Root-owned SDK defaults view/click/hover on. Events `ctfl-root-ready` / `ctfl-root-error`. | web-sdk#web-components/ContentfulOptimizationRootElement.ts#ContentfulOptimizationRootElement; web-sdk#web-components/ContentfulOptimizationRootElement.ts#assignedSdk; web-sdk#presentation/optimizationRootRuntime.ts#resolveTrackEntryInteractionOptions | +| `` | element | Property `baselineEntry` (manual: assigning it triggers resolution) OR `entry-id` attribute / `entryId` property (managed: SDK fetches by ID via configured `contentful.client`); `entryQuery` property; `sdk`. Observed attributes: `entry-id`, `live-updates`, `track-clicks`, `track-hovers`, `track-views`. Events `ctfl-entry-loading` / `ctfl-entry-resolved` / `ctfl-entry-error`. | web-sdk#web-components/ContentfulOptimizedEntryElement.ts#ContentfulOptimizedEntryElement; web-sdk#web-components/ContentfulOptimizedEntryElement.ts#baselineEntry; web-sdk#web-components/ContentfulOptimizedEntryElement.ts#entryQuery | +| `ContentfulOptimizedEntryEventDetail` | type | `{ entry, metadata, resolvedData, selectedOptimization, selectedOptimizations, snapshot }` | web-sdk#web-components/ContentfulOptimizedEntryElement.ts#ContentfulOptimizedEntryEventDetail | ## Render / entry resolution @@ -71,106 +74,105 @@ None (imperative class + Web Components; no React surface). Web Components eleme `selectedOptimization?, ... }>` — fetches then resolves; `options = { query?,` `selectedOptimizations? }`. Additive to synchronous `resolveOptimizedEntry()`. - `clearContentfulEntryCache()` → clears this instance's managed-entry cache. - - source: `core-sdk` `CoreBase.ts` `clearContentfulEntryCache` (:273), `fetchContentfulEntry` - (:288), `fetchOptimizedEntry` (:352), `FetchOptimizedEntryResult` (:99). + source: core-sdk#CoreBase.ts#clearContentfulEntryCache; core-sdk#CoreBase.ts#fetchContentfulEntry; core-sdk#CoreBase.ts#fetchOptimizedEntry; core-sdk#CoreBase.ts#FetchOptimizedEntryResult - `resolveOptimizedEntry(baselineEntry, selectedOptimizations?)` → `{ entry, selectedOptimization?, optimizationContextId? }` (public `ResolvedData` shape). Omitting - arg 2 defaults to `selectedOptimizationsSignal.value` (current SDK state). source: `core-sdk` - `CoreBase.ts:412-433`; `resolvers/OptimizedEntryResolver.ts:32-43`; `CoreStateful.ts:370-372`. + arg 2 defaults to `selectedOptimizationsSignal.value` (current SDK state). + source: core-sdk#CoreBase.ts#resolveOptimizedEntry; core-sdk#resolvers/OptimizedEntryResolver.ts#ResolvedData; core-sdk#CoreStateful.ts#resolveOptimizedEntry - `entry` is a base `contentful` `Entry` ⇒ cast `entry as YourType` (`as unknown as YourType` only for genuinely disjoint). Baseline fallback: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). - **Control-variant precision (subtle):** `selectedOptimization` is `undefined` ONLY when no - experience matched — no selections (`:148-151`), entry not optimized (`:153-156`), no optimization - entry (`:163-168`), no matching selection (`:175-177`). When an experience matches but assigns the - visitor to the CONTROL/baseline variant (`variantIndex === 0`, `:206-209` → `resolveTo(entry)`), - the returned `entry` equals the baseline YET `selectedOptimization` IS defined with - `variantIndex: 0` (`:191-192`). ⇒ Do not read `selectedOptimization === undefined` as "seeing - baseline content." source: `core-sdk` `OptimizedEntryResolver.ts:148-209`. -- `SelectedOptimization` fields: `experienceId`, `variantIndex`, `variants`, `sticky`. source: - `api-schemas` `SelectedOptimization.ts:13-46`. + experience matched — no selections, entry not optimized, no optimization entry, or no matching + selection. When an experience matches but assigns the visitor to the CONTROL/baseline variant + (`variantIndex === 0` → `resolveTo(entry)`), the returned `entry` equals the baseline YET + `selectedOptimization` IS defined with `variantIndex: 0`. ⇒ Do not read + `selectedOptimization === undefined` as "seeing baseline content." + source: core-sdk#resolvers/OptimizedEntryResolver.ts#resolveWithContext; core-sdk#resolvers/OptimizedEntryResolver.ts#resolveTo +- `SelectedOptimization` fields: `experienceId`, `variantIndex`, `variants`, `sticky`. + source: api-schemas#experience/optimization/SelectedOptimization.ts#SelectedOptimization ## Identifier ownership -| Identifier | Owner | Notes | source | -| --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Browser-readable; NOT `HttpOnly` (browser SDK reads it for hybrid takeover) | `core-sdk` `constants.ts:38` | -| `ANONYMOUS_ID_COOKIE` constant | SDK | Exported from `/constants`; value === `'ctfl-opt-aid'` | `src/constants.ts:52` re-exports `core-sdk` `constants.ts:38` | -| `data-ctfl-*` tracking attributes | SDK | `-entry-id`, `-baseline-id`, `-optimization-id`, `-optimization-context-id`, `-variant-index`, `-sticky`, `-clickable`; entry-id must be the RESOLVED id | `presentation/OptimizedEntryTrackingAttributes.ts:70-73,83-97`; `entry-tracking/resolveTrackingPayload.ts:8-31` | -| app consent cookie/record | reader | Reader names/writes/reads; SDK only reflects `consent()` | shared concept; guide | -| `` | SDK | Managed-fetch INPUT: entry ID the SDK fetches via the configured `contentful.client`; NOT app lookup metadata. Manual alternative: assign the `baselineEntry` property instead. | `web-components/ContentfulOptimizedEntryElement.ts:77,101-111,253-262` | -| browser env/config values | reader | Bundler-agnostic; match the app's own browser-visible-var convention | guide | +| Identifier | Owner | Notes | source | +| --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Browser-readable; NOT `HttpOnly` (browser SDK reads it for hybrid takeover) | core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | +| `ANONYMOUS_ID_COOKIE` constant | SDK | Exported from `/constants`; value === `'ctfl-opt-aid'` | web-sdk#constants.ts; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | +| `data-ctfl-*` tracking attributes | SDK | `-entry-id`, `-baseline-id`, `-optimization-id`, `-optimization-context-id`, `-variant-index`, `-sticky`, `-clickable`; entry-id must be the RESOLVED id | web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes; web-sdk#entry-tracking/resolveTrackingPayload.ts#CtflDataset | +| app consent cookie/record | reader | Reader names/writes/reads; SDK only reflects `consent()` | concept:consent-management-in-the-optimization-sdk-suite; core-sdk#CoreStateful.ts#consent | +| `` | SDK | Managed-fetch INPUT: entry ID the SDK fetches via the configured `contentful.client`; NOT app lookup metadata. Manual alternative: assign the `baselineEntry` property instead. | web-sdk#web-components/ContentfulOptimizedEntryElement.ts#entryId; web-sdk#web-components/ContentfulOptimizedEntryElement.ts#baselineEntry | +| browser env/config values | reader | Bundler-agnostic; match the app's own browser-visible-var convention | extern:app owns browser-visible config values (bundler-agnostic) | ## Events & tracking - Event methods return `EventEmissionResult` `{ accepted, data? }`: `page(payload?)`, `identify({ userId, traits? })` (`userId` required), `track({ event, properties? })` (`event` - required), `screen()`. source: `core-sdk` `CoreStatefulEventEmitter.ts:114,134,154,177`; - `state/EventEmissionResult.ts:13-21`; `events/EventBuilder.ts:206,270`. -- `page()` (accepted) populates `states.selectedOptimizations`. source: `core-sdk` - `applyOptimizationDataToSignals.ts`. + required), `screen()`. + source: core-sdk#CoreStatefulEventEmitter.ts#page; core-sdk#CoreStatefulEventEmitter.ts#identify; core-sdk#CoreStatefulEventEmitter.ts#track; core-sdk#CoreStatefulEventEmitter.ts#screen; core-sdk#events/EventEmissionResult.ts#EventEmissionResult; core-sdk#events/EventBuilder.ts#IdentifyBuilderArgs; core-sdk#events/EventBuilder.ts#TrackBuilderArgs +- `page()` (accepted) populates `states.selectedOptimizations`. + source: core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals - `trackCurrentPage({ routeKey, buildPayload, initialPageEvent? })` — dedupes consecutive identical route keys; `initialPageEvent: 'skip'` for hybrid first-route dedupe; a bare `page()` always emits - when consent permits. source: `src/ContentfulOptimization.ts:460-481`; - `handlers/AcceptedCurrentStateTracker.ts:60-85`. + when consent permits. + source: web-sdk#ContentfulOptimization.ts#trackCurrentPage; core-sdk#tracking/AcceptedCurrentStateTracker.ts#emitIfNeeded - Interaction tracking: SDK observes any DOM element carrying `data-ctfl-*`; auto view/click/hover on by default; opt out per-type via `autoTrackEntryInteraction`. Manual: `tracking.enableElement('views', el, { data, dwellTimeMs })` / `disableElement` / `clearElement` - (manual data precedes attributes). Uses RESOLVED entry id. source: - `entry-tracking/EntryInteractionRuntime.ts:199-219`; - `resolveAutoTrackEntryInteractionOptions.ts:34-41,150-163`; - `OptimizedEntryTrackingAttributes.ts:70-73`. + (manual data precedes attributes). Uses RESOLVED entry id. + source: web-sdk#entry-tracking/EntryInteractionRuntime.ts#EntryInteractionRuntime; web-sdk#entry-tracking/resolveAutoTrackEntryInteractionOptions.ts#EntryInteractionApi; web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes - Flags: `getFlag(name)` one-off; `states.flag(name)` reactive; flag-view emission gated on - consent+profile and deduped. source: `core-sdk` `CoreStatefulEventEmitter.ts:87-92,371-404`. + consent+profile and deduped. + source: core-sdk#CoreStatefulEventEmitter.ts#getFlag - Analytics forwarding: `states.eventStream.current?.messageId` + `.subscribe`; dedupe by - `messageId`; every event carries `messageId` + `type`. source: `api-schemas` - `UniversalEventProperties.ts:94`; `experience/event/PageViewEvent.ts:39`. + `messageId`; every event carries `messageId` + `type`. + source: api-schemas#experience/event/UniversalEventProperties.ts#UniversalEventProperties; api-schemas#experience/event/PageViewEvent.ts#PageViewEvent ## Consent & persistence - Model: see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). Two axes. - `consent(boolean)` sets both; `consent({ events, persistence })` sets independently. source: - `core-sdk` `CoreStateful.ts:467-478`; `state/Consent.ts:9`. + `consent(boolean)` sets both; `consent({ events, persistence })` sets independently. + source: core-sdk#CoreStateful.ts#consent; core-sdk#consent/Consent.ts#ConsentInput - Default pre-consent allow-list = `['identify','page']`; other events blocked until consent. - Overridden by `allowedEventTypes`. source: `src/constants.ts:13`; - `consent/ConsentPolicy.ts:13-28`. + Overridden by `allowedEventTypes`. + source: web-sdk#constants.ts#DEFAULT_WEB_ALLOWED_EVENT_TYPES; core-sdk#consent/ConsentPolicy.ts#UNLOCKING_EVENT_TYPES; core-sdk#consent/ConsentPolicy.ts#hasEventConsent - `states.*` observables: `consent`, `persistenceConsent`, `profile`, `selectedOptimizations`, `eventStream`, `blockedEventStream`, `flag(name)`. Immediate-emit-on-subscribe; `.current` sync - read; `.subscribe(...).unsubscribe()`. source: `core-sdk` `CoreStateful.ts:226-240`; - `signals/Observable.ts:20-52,108-168`. + read; `.subscribe(...).unsubscribe()`. + source: core-sdk#CoreStateful.ts#CoreStates; core-sdk#signals/Observable.ts#Observable; core-sdk#signals/Observable.ts#Subscription - `reset()` clears profile state, selected optimizations, route dedupe, `ctfl-opt-aid` cookie + LocalStore continuity (also stops entry-interaction tracking, clears changes/eventStream/ blockedEventStream/experienceRequestState — non-exhaustive but nothing extra harms the reader). - Does NOT touch consent/persistence signals or app/CMP records. source: - `src/ContentfulOptimization.ts:443-449`; `core-sdk` `CoreStateful.ts:451-461`; - `storage/LocalStore.ts:48-62`. + Does NOT touch consent/persistence signals or app/CMP records. + source: web-sdk#ContentfulOptimization.ts#reset; core-sdk#CoreStateful.ts#reset; web-sdk#storage/LocalStore.ts#LocalStore - `setLocale(nextLocale)` updates subsequent Experience/event locale only; does not refetch or clear - caches. source: `core-sdk` `CoreStateful.ts:488-496`. + caches. + source: core-sdk#CoreStateful.ts#setLocale ## Version / runtime quirks - **Imperative + synchronously ready:** unlike the React SDK there is no "not ready on first render" window — `resolveOptimizedEntry()`, `getFlag()`, `states.*` work the moment the instance is constructed. BUT optimization state is empty until an accepted `page()`/`identify()` returns - selections ⇒ resolve-before-emit yields baseline. Ordering: construct → emit → resolve. source: - guide lifecycle section; `CoreStateful.ts:370-372`. + selections ⇒ resolve-before-emit yields baseline. Ordering: construct → emit → resolve. + source: core-sdk#CoreStateful.ts#resolveOptimizedEntry; core-sdk#CoreStatefulEventEmitter.ts#page - SDK config is bundler-agnostic (no framework env-var convention). Store the instance in a module-level singleton. - Web Components entrypoint is side-effect-free until `defineContentfulOptimizationElements()` runs. + source: web-sdk#web-components/index.ts#defineContentfulOptimizationElements ## Failure & fallback behavior - Baseline fallback on denied consent / no variant / unresolved links / all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). Concrete fallback returns in - `OptimizedEntryResolver.ts:148-177,218-235`. + the resolver. + source: core-sdk#resolvers/OptimizedEntryResolver.ts#resolveWithContext - `consent(false)` blocks non-allowed events and clears SDK durable storage; does NOT drop the - active in-memory profile (use `reset()`) or erase app/server/CMP records. source: - `CoreStateful.ts:467-478`. + active in-memory profile (use `reset()`) or erase app/server/CMP records. + source: core-sdk#CoreStateful.ts#consent - Preview panel: `attachOptimizationPreviewPanel({ contentful? | entries? | optimization?, nonce? })` default export; `entries: { audiences, experiences }`; defaults to `window.contentfulOptimization` when - `optimization` omitted. source: - `preview-panel/src/attachOptimizationPreviewPanel.ts:242-261,425-432,138-144`; - `preview-panel/src/lib/entries.ts:35-40`. -- Validation: `pnpm implementation:run -- web-sdk typecheck`; `pnpm test:e2e:web-sdk`. source: - `implementations/web-sdk/package.json`; root `package.json`. + `optimization` omitted. + source: preview-panel#attachOptimizationPreviewPanel.ts#attachOptimizationPreviewPanel; preview-panel#lib/entries.ts#PreviewPanelEntries +- Validation: `pnpm implementation:run -- web-sdk typecheck`; `pnpm test:e2e:web-sdk`. + source: impl:web-sdk#package.json From 11630073583f615d57aff4c192968ed53801759e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:32:34 +0200 Subject: [PATCH 12/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Mig?= =?UTF-8?q?rate=20React=20Web=20SDK=20KB=20pointers=20to=20the=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite all ~90 pointer tokens in web/react-web.md to the symbol-anchored grammar (pnpm knowledge:check passes). Re-verification against source corrected drift the old pointers hid: - Re-export barrels (index.ts, api-schemas.ts, server-optimized-entries.ts) carry no declarations; repointed to the real declaring files (OptimizationRoot.tsx, contentful/typeGuards.ts, OptimizedEntrySourceController.ts, etc.). - Logger misattributed to core-sdk; createScopedLogger is declared in api-client. - Consent action drift: setConsent/identifyUser/resetUser are React hook names on UseOptimizationActionsResult; the SDK methods are consent/identify/reset — pointers now cite both layers. - Renamed constants (DEFAULT_WEB_ALLOWED_EVENT_TYPES, ANONYMOUS_ID_COOKIE) anchored correctly; circular "guide" pointers replaced with concept: links and real symbols. - Fixed escaped-pipe table cells that desynced the source column. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/web/react-web.md | 193 +++++++++--------- 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/documentation/internal/sdk-knowledge/web/react-web.md b/documentation/internal/sdk-knowledge/web/react-web.md index b83b97ec9..b8b493a07 100644 --- a/documentation/internal/sdk-knowledge/web/react-web.md +++ b/documentation/internal/sdk-knowledge/web/react-web.md @@ -11,34 +11,38 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web ## Package & entry points -| Import path | Purpose | source | -| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `@contentful/optimization-react-web` | `OptimizationRoot`, `OptimizationProvider`, `LiveUpdatesProvider`, `OptimizedEntry`, all hooks | `package.json` exports; `src/index.ts` | -| `@contentful/optimization-react-web/router/react-router` | `ReactRouterAutoPageTracker` | `package.json:95`; `src/router/react-router.tsx:55` | -| `@contentful/optimization-react-web/router/tanstack-router` | TanStack Router auto page tracker | `package.json:105`; `src/router/tanstack-router.tsx:23` | -| `@contentful/optimization-react-web/router/next-pages` | Next.js Pages Router auto page tracker (React-Web-only setups) | `package.json:75`; `src/router/next-pages.tsx:49-58` | -| `@contentful/optimization-react-web/router/next-app` | Next.js App Router auto page tracker (React-Web-only setups) | `package.json:85`; `src/router/next-app.tsx:40-42` | -| `@contentful/optimization-react-web/api-schemas` | Type guards `isMergeTagEntry`, `isRichTextDocument` | `package.json:65`; `src/api-schemas.ts` | -| `@contentful/optimization-core/entry-source` | Framework-adapter primitives for managed fetch: `prefetchOptimizedEntries`, `createOptimizedEntryLoadingEntry`, `getOptimizedEntrySourceKey`, `OptimizedEntrySourceController` + handoff types | `core-sdk` `entry-source.ts`; `OptimizedEntrySourceController.ts` | -| `@contentful/optimization-react-web/logger` | `createScopedLogger` | `package.json:25`; `src/logger.ts:1` | +| Import path | Purpose | source | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-react-web` | `OptimizationRoot`, `OptimizationProvider`, `LiveUpdatesProvider`, `OptimizedEntry`, all hooks | react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot; react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider; react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry | +| `@contentful/optimization-react-web/router/react-router` | `ReactRouterAutoPageTracker` | react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker | +| `@contentful/optimization-react-web/router/tanstack-router` | TanStack Router auto page tracker | react-web-sdk#router/tanstack-router.tsx#TanStackRouterAutoPageTracker | +| `@contentful/optimization-react-web/router/next-pages` | Next.js Pages Router auto page tracker (React-Web-only setups) | react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker | +| `@contentful/optimization-react-web/router/next-app` | Next.js App Router auto page tracker (React-Web-only setups) | react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker | +| `@contentful/optimization-react-web/api-schemas` | Type guards `isMergeTagEntry`, `isRichTextDocument` | react-web-sdk#api-schemas.ts; api-schemas#contentful/typeGuards.ts#isMergeTagEntry; api-schemas#contentful/typeGuards.ts#isRichTextDocument | +| `@contentful/optimization-core/entry-source` | Framework-adapter primitives for managed fetch: `prefetchOptimizedEntries`, `createOptimizedEntryLoadingEntry`, `getOptimizedEntrySourceKey`, `OptimizedEntrySourceController` + handoff types | core-sdk#entry-source.ts; core-sdk#OptimizedEntrySourceController.ts#prefetchOptimizedEntries; core-sdk#OptimizedEntrySourceController.ts#createOptimizedEntryLoadingEntry; core-sdk#OptimizedEntrySourceController.ts#getOptimizedEntrySourceKey; core-sdk#OptimizedEntrySourceController.ts#OptimizedEntrySourceController; core-sdk#OptimizedEntrySourceController.ts#ServerOptimizedEntryHandoff | +| `@contentful/optimization-react-web/logger` | `createScopedLogger` | react-web-sdk#logger.ts; api-client#lib/logger/Logger.ts#createScopedLogger | ## Setup / factory - **No factory** (unlike the Next.js adapters). Configure by passing props directly to `OptimizationRoot`; mount it exactly once around the subtree that uses the SDK. It composes `OptimizationProvider` + `LiveUpdatesProvider`, creates the Web SDK instance after React commits, - and destroys it on unmount. source: `src/root/OptimizationRoot.tsx:9-22`. -- `OptimizationRootProps` = `OptimizationProviderConfigProps & { liveUpdates? }`. source: - `src/root/OptimizationRoot.tsx:9-11`. Config props: - - `clientId`, `environment`, `fetchOptions?` — `CoreConfig` via `core-sdk` `CoreBase.ts:42,107`. - - `locale`, `logLevel?` — `core-sdk` `CoreBase.ts:46,54`. + and destroys it on unmount. source: react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot. +- `OptimizationRootProps` = `OptimizationProviderConfigProps & { liveUpdates? }`. + source: react-web-sdk#root/OptimizationRoot.tsx#OptimizationRootProps + - `clientId`, `environment`, `fetchOptions?` — `CoreConfig` via `api-client` `ApiConfig`. + source: core-sdk#CoreBase.ts#CoreConfig; api-client#ApiClientBase.ts#ApiConfig + - `locale`, `logLevel?` — `core-sdk` `CoreConfig`. + source: core-sdk#CoreBase.ts#CoreConfig - `defaults` (`consent`, `persistenceConsent`), `api?`, `allowedEventTypes?`, `onEventBlocked?`, - `queuePolicy?` — `core-sdk` `CoreStateful.ts:175-199`. `api` = `experienceBaseUrl`, - `insightsBaseUrl` — `core-sdk` `CoreApiConfig.ts:11-18`. + `queuePolicy?` — `core-sdk` `CoreStatefulConfig`. `api` = `experienceBaseUrl`, + `insightsBaseUrl` — `core-sdk` `CoreSharedApiConfig`. + source: core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#StatefulDefaults.ts#StatefulDefaults; core-sdk#CoreApiConfig.ts#CoreSharedApiConfig - `app` (`name`, `version`), `cookie?` (`domain`, `expires` days) — `web-sdk` - `ContentfulOptimization.ts:68-91`; `web-sdk` `lib/cookies.ts:11-23`. - - `trackEntryInteraction?`, `onStatesReady?`, `serverOptimizationState?` — - `src/provider/OptimizationProvider.tsx:53-69`. + `OptimizationWebConfig`; `web-sdk` `CookieAttributes`. + source: web-sdk#ContentfulOptimization.ts#OptimizationWebConfig; web-sdk#lib/cookies.ts#CookieAttributes + - `trackEntryInteraction?`, `onStatesReady?`, `serverOptimizationState?`. + source: react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProviderConfigProps - **`contentful?: ContentfulConfig` (managed entry fetching):** opt-in. `{ client:` `ContentfulEntryClient, defaultQuery?: ContentfulEntryQuery, cache?: false | { maxEntries?,` `ttlMs? } }`. Enables `` / `useOptimizedEntry({ entryId })` to fetch by @@ -48,41 +52,39 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web `{ maxEntries: 100, ttlMs: 300_000 }`; `cache: false` disables. Manual `baselineEntry` / `resolveOptimizedEntry()` is the alternative. Prop-surface chain: `CoreConfig.contentful` → `CoreStatefulConfig` → `OptimizationWebConfig` → `OptimizationRootSdkConfig` → - `OptimizationProviderConfigProps` → `OptimizationRootProps`. source: `core-sdk` `CoreBase.ts` - `ContentfulConfig` (:68), `ContentfulEntryClient` (:46), `ContentfulEntryQuery` (:34), - `CoreConfig.contentful` (:173), - `fetchContentfulEntry`/`fetchOptimizedEntry`/`clearContentfulEntryCache` (:273,288,352). + `OptimizationProviderConfigProps` → `OptimizationRootProps`. + source: core-sdk#CoreBase.ts#ContentfulConfig; core-sdk#CoreBase.ts#ContentfulEntryClient; core-sdk#CoreBase.ts#ContentfulEntryQuery; core-sdk#CoreBase.ts#CoreConfig; core-sdk#CoreBase.ts#fetchContentfulEntry; core-sdk#CoreBase.ts#fetchOptimizedEntry; core-sdk#CoreBase.ts#clearContentfulEntryCache - **`serverOptimizedEntries?: readonly ServerOptimizedEntryHandoff[]` (SSR handoff):** forwarded to the provider; seeds baseline entries prefetched on the server so managed-`entryId` entries - render without a client round-trip. source: `src/provider/OptimizationProvider.tsx:59,210-234`; - `src/server-optimized-entries.ts`. + render without a client round-trip. + source: react-web-sdk#provider/OptimizationProvider.tsx#ServerOptimizationStateProps; react-web-sdk#server-optimized-entries.ts; core-sdk#OptimizedEntrySourceController.ts#ServerOptimizedEntryHandoff - Mount once. A second **owned** instance in the same browser runtime throws - `ContentfulOptimization is already initialized`. source: `web-sdk` - `ContentfulOptimization.ts:290-291`. + `ContentfulOptimization is already initialized`. + source: web-sdk#ContentfulOptimization.ts#ContentfulOptimization ## Components & hooks -| Name | Kind | Import path | Key props/args | Returns | source | -| --------------------------------------------------- | --------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -| `OptimizationRoot` | component | root | config props above; `liveUpdates?`; `children` | element | `src/root/OptimizationRoot.tsx:9-22` | -| `OptimizationProvider` | provider | root | `sdk={optimization}` (injected) OR config; `onStatesReady?`, `serverOptimizationState?` | always renders children | `src/provider/OptimizationProvider.tsx:71-81` | -| `LiveUpdatesProvider` | provider | root | required for `OptimizedEntry`/`useOptimizedEntry`/`useLiveUpdates` when composing providers by hand | element | `src/hooks/useLiveUpdates.ts:7-9` | -| `OptimizedEntry` | component | root | **discriminated union:** `baselineEntry` (manual) XOR `entryId` (+`entryQuery?`, managed) — never both; render-prop child, `liveUpdates?`, `loadingFallback?`, `errorFallback?`, `onEntryError?`, `as?` (`'div'`\|`'span'`), tracking props | element / `null` | `src/optimized-entry/OptimizedEntry.tsx:81-90,118-133` | -| `ReactRouterAutoPageTracker` | component | `/router/react-router` | `getPagePayload?`, `pagePayload?` (no `initialPageEvent`) | `null` | `src/router/react-router.tsx:53-55` | -| next-pages / next-app tracker | component | `/router/next-pages` \| `next-app` | also accept `initialPageEvent` | `null` | `src/router/next-pages.tsx:49-51`; `next-app.tsx:40-42` | -| `useOptimizationContext` | hook | root | — | `{ sdk, error }` (`sdk` seeded, defined from 1st render; `error` on init fail) | `src/context/OptimizationContext.tsx:7-10`; `provider/OptimizationProvider.tsx:191-197,238-242` | -| `useOptimization` | hook | root | — | SDK instance; **throws** if unavailable / no provider | `src/hooks/useOptimization.ts:31-45` | -| `useOptimizedEntry` | hook | root | same discriminated union: `{ baselineEntry }` XOR `{ entryId, entryQuery? }` + `liveUpdates?`, `onEntryError?` | `{ entry, baselineEntry, error?, isLoading, canOptimize, selectedOptimization, isPresentationReady, resolvedData, selectedOptimizations, … }` (`entry`/`baselineEntry` `undefined` while managed fetch unresolved) | `src/optimized-entry/useOptimizedEntry.ts:16-35,47-60` | -| `prefetchOptimizedEntries` | function | root / `entry-source` | `(runtime: { fetchContentfulEntry }, descriptors: { entryId, entryQuery? }[])` | `Promise` (each `{ entryId, entryQuery?, baselineEntry }`); feed to `serverOptimizedEntries` | `src/server-optimized-entries.ts`; `core-sdk` `OptimizedEntrySourceController.ts:98-101,32-39` | -| `useConsentState` | hook | root | — | `boolean \| undefined` | `src/hooks/useOptimizationState.ts:49-52` | -| `useProfileState` / `useSelectedOptimizationsState` | hook | root | — | profile (`id`, `traits`) / selected optimizations | `src/hooks/useOptimizationState.ts:79-94` | -| `useOptimizationActions` | hook | root | — | `{ setConsent, identifyUser, resetUser, … }` | `src/hooks/useOptimizationActions.ts:11-19,44-58` | -| `useMergeTagResolver` | hook | root | — | `{ getMergeTagValue }` | `src/hooks/useMergeTagResolver.ts:11-16,29-39` | +| Name | Kind | Import path | Key props/args | Returns | source | +| --------------------------------------------------- | --------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OptimizationRoot` | component | root | config props above; `liveUpdates?`; `children` | element | react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot | +| `OptimizationProvider` | provider | root | `sdk={optimization}` (injected) OR config; `onStatesReady?`, `serverOptimizationState?` | always renders children | react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider | +| `LiveUpdatesProvider` | provider | root | required for `OptimizedEntry`/`useOptimizedEntry`/`useLiveUpdates` when composing providers by hand | element | react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider; react-web-sdk#hooks/useLiveUpdates.ts#useLiveUpdates | +| `OptimizedEntry` | component | root | **discriminated union:** `baselineEntry` (manual) XOR `entryId` (+`entryQuery?`, managed) — never both; render-prop child, `liveUpdates?`, `loadingFallback?`, `errorFallback?`, `onEntryError?`, `as?` (`'div'` or `'span'`), tracking props | element or `null` | react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntrySourceProps | +| `ReactRouterAutoPageTracker` | component | `/router/react-router` | `getPagePayload?`, `pagePayload?` (no `initialPageEvent`) | `null` | react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker | +| next-pages / next-app tracker | component | `/router/next-pages` or next-app | also accept `initialPageEvent` | `null` | react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker | +| `useOptimizationContext` | hook | root | — | `{ sdk, error }` (`sdk` seeded, defined from 1st render; `error` on init fail) | react-web-sdk#hooks/useOptimization.ts#useOptimizationContext; react-web-sdk#context/OptimizationContext.tsx#OptimizationContextValue | +| `useOptimization` | hook | root | — | SDK instance; **throws** if unavailable / no provider | react-web-sdk#hooks/useOptimization.ts#useOptimization | +| `useOptimizedEntry` | hook | root | same discriminated union: `{ baselineEntry }` XOR `{ entryId, entryQuery? }` + `liveUpdates?`, `onEntryError?` | `{ entry, baselineEntry, error?, isLoading, canOptimize, selectedOptimization, isPresentationReady, resolvedData, selectedOptimizations, … }` (`entry`/`baselineEntry` `undefined` while managed fetch unresolved) | react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntry; react-web-sdk#optimized-entry/useOptimizedEntry.ts#UseOptimizedEntryResult | +| `prefetchOptimizedEntries` | function | root / `entry-source` | `(runtime: { fetchContentfulEntry }, descriptors: { entryId, entryQuery? }[])` | `Promise` (each `{ entryId, entryQuery?, baselineEntry }`); feed to `serverOptimizedEntries` | react-web-sdk#server-optimized-entries.ts; core-sdk#OptimizedEntrySourceController.ts#prefetchOptimizedEntries; core-sdk#OptimizedEntrySourceController.ts#ServerOptimizedEntryHandoff | +| `useConsentState` | hook | root | — | `boolean` or `undefined` | react-web-sdk#hooks/useOptimizationState.ts#useConsentState | +| `useProfileState` / `useSelectedOptimizationsState` | hook | root | — | profile (`id`, `traits`) / selected optimizations | react-web-sdk#hooks/useOptimizationState.ts#useProfileState; react-web-sdk#hooks/useOptimizationState.ts#useSelectedOptimizationsState | +| `useOptimizationActions` | hook | root | — | `{ setConsent, identifyUser, resetUser, … }` | react-web-sdk#hooks/useOptimizationActions.ts#useOptimizationActions; react-web-sdk#hooks/useOptimizationActions.ts#UseOptimizationActionsResult | +| `useMergeTagResolver` | hook | root | — | `{ getMergeTagValue }` | react-web-sdk#hooks/useMergeTagResolver.ts#useMergeTagResolver; react-web-sdk#hooks/useMergeTagResolver.ts#UseMergeTagResolverResult | Note: `useOptimizationContext` is safe during render (returns the seeded snapshot runtime, whose actions are inert no-ops until live); `useOptimization` throws only on init failure / missing provider, so it is safe during render too, but is framed for post-mount code (handlers/effects). -source: `core-sdk` `SnapshotRuntime.ts:52-87,191-198`. +source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/SnapshotRuntime.ts#createSnapshotRuntime ## Render / entry resolution @@ -90,106 +92,103 @@ source: `core-sdk` `SnapshotRuntime.ts:52-87,191-198`. union — either `baselineEntry` (app fetched it: manual) OR `entryId` + optional `entryQuery` (SDK fetches it via `OptimizationRoot`'s `contentful.client`: managed). Never both. Managed fetch requires `contentful: { client }` on the root; without it the managed path has no client. - `errorFallback` / `onEntryError` handle a managed-fetch failure. source: - `src/optimized-entry/OptimizedEntry.tsx:81-90,118-133`; `useOptimizedEntry.ts:16-35`. See + `errorFallback` / `onEntryError` handle a managed-fetch failure. See [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual). + source: react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntrySourceProps; react-web-sdk#optimized-entry/useOptimizedEntry.ts#UseOptimizedEntryParams; concept:entry-personalization-and-variant-resolution - Render prop = `(resolvedEntry: Entry, { getMergeTagValue }) => ReactNode`; `resolvedEntry` is a base `contentful` `Entry` ⇒ cast `resolved as YourType` (`as unknown as YourType` only for genuinely disjoint types; direct cast works for `.withoutUnresolvableLinks`-narrowed types). - source: `src/optimized-entry/optimizedEntryUtils.ts:8-11`; `OptimizedEntry.tsx:137-143`. Baseline - fallback: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + Baseline fallback: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: react-web-sdk#optimized-entry/optimizedEntryUtils.ts#RenderProp; react-web-sdk#optimized-entry/optimizedEntryUtils.ts#OptimizedEntryRenderContext - **Loading model:** while resolving, renders baseline as a HIDDEN layout target (no jump), reveals resolved content when resolution settles; if it never settles, reveals baseline after a **5s** - timeout; `loadingFallback` shows custom UI in that window. source: `web-sdk` - `presentation/OptimizedEntryController.ts:9,167` (`BASELINE_REVEAL_TIMEOUT_MS = 5000`), - `:462-467,517-534`; `src/optimized-entry/OptimizedEntry.tsx:178-203`. + timeout (`BASELINE_REVEAL_TIMEOUT_MS = 5000`); `loadingFallback` shows custom UI in that window. + source: web-sdk#presentation/OptimizedEntryController.ts#BASELINE_REVEAL_TIMEOUT_MS; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry - **Double-wrap:** nested `OptimizedEntry` sharing a baseline id returns `null` + dev-only warning - (gated `NODE_ENV !== 'production'`); different baseline ids are fine. source: - `OptimizedEntry.tsx:104-116,164-166`. -- **Host element:** wraps in a layout-neutral element, `display: contents` by default; `as` accepts - only `'div'`/`'span'` (default `'div'`). Plain-node children still emit tracking attributes. - source: `optimizedEntryUtils.ts:7`; `OptimizedEntry.tsx:89,125,207`; `web-sdk` - `OptimizedEntryController.ts:23` (`OPTIMIZED_ENTRY_HOST_DISPLAY = 'contents'`). + (gated `NODE_ENV !== 'production'`); different baseline ids are fine. + source: react-web-sdk#optimized-entry/OptimizedEntry.tsx#useDuplicateBaselineGuard; web-sdk#presentation/OptimizedEntryController.ts#resolveOptimizedEntryNestingState +- **Host element:** wraps in a layout-neutral element, `display: contents` by default + (`OPTIMIZED_ENTRY_HOST_DISPLAY = 'contents'`); `as` accepts only `'div'`/`'span'` (default + `'div'`). Plain-node children still emit tracking attributes. + source: react-web-sdk#optimized-entry/optimizedEntryUtils.ts#WrapperElement; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry; web-sdk#presentation/OptimizedEntryController.ts#OPTIMIZED_ENTRY_HOST_DISPLAY ## Identifier ownership -| Identifier | Owner | Notes | source | -| --------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Browser-readable; the one persistence value the SDK owns | `core-sdk` `constants.ts:38`; `web-sdk` `ContentfulOptimization.ts:299-373` | -| app consent cookie/record | reader | Reader names/writes/reads; SDK only reflects what you pass to `setConsent` | shared concept; guide | -| browser env vars (`PUBLIC_*` / bundler) | reader | Match the bundler's browser-var convention (Vite `import.meta.env`, CRA `process.env.REACT_APP_*`) | bundler convention | +| Identifier | Owner | Notes | source | +| --------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Browser-readable; the one persistence value the SDK owns | core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; web-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| app consent cookie/record | reader | Reader names/writes/reads; SDK only reflects what you pass to `setConsent` | concept:consent-management-in-the-optimization-sdk-suite; react-web-sdk#hooks/useOptimizationActions.ts#UseOptimizationActionsResult | +| browser env vars (`PUBLIC_*` / bundler) | reader | Match the bundler's browser-var convention (Vite `import.meta.env`, CRA `process.env.REACT_APP_*`) | extern:bundler exposes only its prefixed vars to the browser (Vite import.meta.env, CRA process.env.REACT*APP*\*) | ## Events & tracking - Page events: auto-page trackers emit on navigation; each dedupes consecutive route keys incl. Strict Mode double effects. Mount ONE tracker per router tree. React Router / TanStack trackers do NOT take `initialPageEvent`; only next-pages / next-app trackers do (React-Web-only Next setups). - source: `src/router/react-router.tsx:53`, `tanstack-router.tsx:23`, `next-pages.tsx:49-51`, - `next-app.tsx:40-42`; `web-sdk` `ContentfulOptimization.ts:460-481` (routeKey dedup). + source: react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker; react-web-sdk#router/tanstack-router.tsx#TanStackRouterAutoPageTracker; react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker; web-sdk#ContentfulOptimization.ts#trackCurrentPage - `getPagePayload` receives `{ context, routeKey, isInitialEmission }`; React Router `context` has `pathname`. Payload layer merge order: router-derived → static `pagePayload` → dynamic - `getPagePayload` (later wins). source: `src/auto-page/types.ts:10-19`, - `router/react-router.tsx:43-51`, `auto-page/pagePayload.ts:49-58`. + `getPagePayload` (later wins). + source: react-web-sdk#auto-page/types.ts#AutoPageEmissionContext; react-web-sdk#router/react-router.tsx#ReactRouterAutoPageContext; react-web-sdk#auto-page/pagePayload.ts#buildAutoPagePayload - Interaction tracking (views/clicks/hovers): on by default with `OptimizedEntry`; opt out per-type via `OptimizationRoot` `trackEntryInteraction`; per-entry props `clickable`/`trackViews`/ `trackClicks`/`trackHovers` + duration props; uses RESOLVED entry id. Manual DOM: - `sdk.tracking.enableElement('views', el, { data })` / `clearElement`. source: `web-sdk` - `entry-tracking/EntryInteractionRuntime.ts:199-219`; - `presentation/OptimizedEntryTrackingAttributes.ts:66-72`. + `sdk.tracking.enableElement('views', el, { data })` / `clearElement`. + source: web-sdk#entry-tracking/EntryInteractionRuntime.ts#EntryInteractionRuntime; web-sdk#entry-tracking/resolveAutoTrackEntryInteractionOptions.ts#EntryInteractionApi; web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes - Flags: `getFlag(name)` nonreactive read (defaults to current changes signal, emits flag-view - tracking when consent+profile allow); `states.flag(name)` reactive. source: `core-sdk` - `CoreStatefulEventEmitter.ts:87-92,379-384,455-461`; `CoreStateful.ts:228`. + tracking when consent+profile allow); `states.flag(name)` reactive. + source: core-sdk#CoreStatefulEventEmitter.ts#getFlag; core-sdk#CoreStatefulEventEmitter.ts#getFlagObservable; core-sdk#CoreStateful.ts#CoreStates - Analytics forwarding: `states.eventStream.current?.messageId` + `.subscribe`; dedupe by `messageId`; `states.blockedEventStream` for blocked-event diagnostics; - `subscription.unsubscribe()`. source: `api-schemas` - `experience/event/UniversalEventProperties.ts:88-94`; `core-sdk` `CoreStateful.ts:144,227`; - `signals/Observable.ts:11,40`. + `subscription.unsubscribe()`. + source: api-schemas#experience/event/UniversalEventProperties.ts#UniversalEventProperties; core-sdk#CoreStateful.ts#CoreStates; core-sdk#signals/Observable.ts#Subscription; core-sdk#signals/Observable.ts#Observable ## Consent & persistence - Model: see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). Two axes `consent` / `persistenceConsent`. Default pre-consent allow-list = `['identify','page']`; other - events blocked until consent. source: `web-sdk` `constants.ts:13`. + events blocked until consent. source: web-sdk#constants.ts#DEFAULT_WEB_ALLOWED_EVENT_TYPES - `setConsent(true|false)` sets both axes; object form `setConsent({ events, persistence })` when - they differ. source: `core-sdk` `CoreStateful.ts:467-478`. + they differ (SDK method `consent(accept)`, `accept: ConsentInput`). + source: react-web-sdk#hooks/useOptimizationActions.ts#UseOptimizationActionsResult; core-sdk#CoreStateful.ts#consent; core-sdk#consent/Consent.ts#ConsentInput - `identifyUser({ userId, traits })`; `resetUser()` preserves consent (does not touch consent/ persistence signals) and clears profile + selected optimizations + route dedupe. Clear your own - consent record separately on withdrawal. source: `core-sdk` `CoreStatefulEventEmitter.ts:114-122`, - `CoreStateful.ts:451-461`; `web-sdk` `ContentfulOptimization.ts:443-449`. + consent record separately on withdrawal (SDK methods `identify` / `reset`). + source: react-web-sdk#hooks/useOptimizationActions.ts#UseOptimizationActionsResult; core-sdk#CoreStatefulEventEmitter.ts#identify; core-sdk#CoreStateful.ts#reset; web-sdk#ContentfulOptimization.ts#reset ## Version / runtime quirks - Browser-only: no server first paint; the SDK is **not ready on first render** (created after React - commits, then queries the Experience API). Readiness/loading/error is first-class. source: - `src/provider/OptimizationProvider.tsx:184-266`. -- Injected-SDK path (`OptimizationProvider sdk={optimization}`): - `injectedSdkBacksInitialRender = props.sdk !== undefined && props.serverOptimizationState === undefined` + commits, then queries the Experience API). Readiness/loading/error is first-class. + source: react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider +- Injected-SDK path (`OptimizationProvider sdk={optimization}`): `injectedSdkBacksInitialRender` ⇒ with an injected `sdk` and no `serverOptimizationState`, children render against the LIVE injected SDK from the first render; `onStatesReady` alone adds NO snapshot phase. Only `serverOptimizationState` routes the injected path through the read-only snapshot (hydrated in a layout effect via `hydrateOptimizationData`). Provider always renders children (never withheld/ - unmounted). The provider does NOT `destroy()` an instance it did not create - (`ownsInstance:false`). source: `src/provider/OptimizationProvider.tsx:180-198,206,131-144`; - `web-sdk` `presentation/optimizationRootRuntime.ts:146-152,167-174`. -- Owned/config `OptimizationRoot` path always seeds a snapshot runtime initially. source: - `OptimizationProvider.tsx:191-197`. + unmounted). The provider does NOT `destroy()` an instance it did not create (`ownsInstance:false`). + source: react-web-sdk#provider/OptimizationProvider.tsx#injectedSdkBacksInitialRender; web-sdk#presentation/optimizationRootRuntime.ts#OptimizationRootSdkBinding; web-sdk#presentation/optimizationRootRuntime.ts#disposeOptimizationRootSdkBinding +- Owned/config `OptimizationRoot` path always seeds a snapshot runtime initially. + source: react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime; web-sdk#runtime.ts#createWebSnapshotRuntime - Live updates precedence: preview panel open → per-entry `liveUpdates` → root `liveUpdates` → - default (locked to first resolved state). source: `web-sdk` `OptimizedEntryController.ts:214-226`; - `src/optimized-entry/OptimizedEntry.tsx:45`. + default (locked to first resolved state). + source: web-sdk#presentation/OptimizedEntryController.ts#resolveShouldLiveUpdate; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry - `locale` prop change updates the SDK's Experience/event locale; the app still refetches Contentful - and re-emits page events itself. source: guide; `core-sdk` `CoreBase.ts:46`. + and re-emits page events itself. + source: react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider; core-sdk#CoreStateful.ts#setLocale ## Failure & fallback behavior - Baseline fallback on denied consent / no variant / unresolved links / all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry; concept:entry-personalization-and-variant-resolution - On SDK **initialization failure**, `useOptimizationContext().error` is set; `OptimizedEntry` throws rather than rendering baseline, so it must render under an ancestor that handles `error` - (an unguarded subtree crashes). source: `src/provider/OptimizationProvider.tsx:238-242`; guide - readiness section. -- Storage-write failure ⇒ SDK continues with in-memory state. source: `web-sdk` - `ContentfulOptimization.ts:299-373`. + (an unguarded subtree crashes). + source: react-web-sdk#context/OptimizationContext.tsx#OptimizationContextValue; react-web-sdk#hooks/useOptimization.ts#useOptimization +- Storage-write failure ⇒ SDK continues with in-memory state. + source: web-sdk#storage/LocalStore.ts#LocalStore - **Managed fetch failure:** when `entryId` is used and `contentful.client.getEntry()` rejects, the source snapshot carries `error`; `onEntryError(error)` fires and `errorFallback` renders. Distinct - from baseline fallback (which is for resolution outcomes on a present entry). source: `core-sdk` - `OptimizedEntrySourceController.ts:19-28`; `OptimizedEntry.tsx:81-90`. + from baseline fallback (which is for resolution outcomes on a present entry). + source: core-sdk#OptimizedEntrySourceController.ts#OptimizedEntrySourceSnapshot; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntrySourceProps From fb41104e6ae72a969f3773f0e8258d1b29794c29 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:37:57 +0200 Subject: [PATCH 13/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Mig?= =?UTF-8?q?rate=20Pages=20Router=20KB=20pointers=20to=20the=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite every source pointer in web/nextjs-pages-router.md to the symbol-anchored grammar (pnpm knowledge:check passes). ref-impl pointers → impl:nextjs-sdk_pages-router#…, cross-doc references → kb:shared/…, framework conventions → extern:, circular guide pointers and line ranges → verified file#symbol anchors in packages/**/src. With this the entire SDK knowledge base passes knowledge:check: every source pointer resolves to a real symbol and all per-SDK files conform to _template.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk-knowledge/web/nextjs-pages-router.md | 139 +++++++++--------- 1 file changed, 70 insertions(+), 69 deletions(-) diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index 1abe786f4..d27d633c6 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md @@ -9,13 +9,13 @@ App Router surface: see [`nextjs-app-router.md`](./nextjs-app-router.md). ## Package & entry points -| Import path | Purpose | source | -| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `@contentful/optimization-nextjs/pages-router` | **Client** factory → bound `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, `NextPagesAutoPageTracker`. Exports the factory + `NextPagesAutoPageTracker` + bound types (React Web hooks import from `/client`). | `src/pages-router.ts:29-33, 50, 96-101` (`'use client'` line 1) | -| `@contentful/optimization-nextjs/pages-router/server` | **Server** factory → `getServerSideOptimizationProps`; also exports `prefetchOptimizedEntries`, `OptimizedEntryPrefetchDescriptor`, `ServerOptimizedEntryHandoff` | `src/pages-router-server.ts:36-40, 85` | -| `@contentful/optimization-nextjs/client` | Browser-only hooks + per-entry controls | `src/client.ts`; `package.json` exports | -| `@contentful/optimization-nextjs/server` | Manual server SDK control (escape hatches) | `src/server.tsx`; `package.json` exports | -| `@contentful/optimization-nextjs/api-schemas` | Type guards `isMergeTagEntry`, `isResolvedContentfulEntry` | `src/api-schemas.ts` | +| Import path | Purpose | source | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/pages-router` | **Client** factory → bound `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, `NextPagesAutoPageTracker`. Exports the factory + `NextPagesAutoPageTracker` + bound types (React Web hooks import from `/client`). | `nextjs-sdk#pages-router.ts#createNextjsPagesRouterOptimization`; `nextjs-sdk#pages-router.ts#NextPagesAutoPageTracker` | +| `@contentful/optimization-nextjs/pages-router/server` | **Server** factory → `getServerSideOptimizationProps`; also exports `prefetchOptimizedEntries`, `OptimizedEntryPrefetchDescriptor`, `ServerOptimizedEntryHandoff` | `nextjs-sdk#pages-router-server.ts#getServerSideOptimizationProps`; `nextjs-sdk#pages-router-server.ts#prefetchOptimizedEntries`; `core-sdk#OptimizedEntrySourceController.ts#OptimizedEntryPrefetchDescriptor`; `core-sdk#OptimizedEntrySourceController.ts#ServerOptimizedEntryHandoff` | +| `@contentful/optimization-nextjs/client` | Browser-only hooks + per-entry controls | `nextjs-sdk#client.ts`; `nextjs-sdk#../package.json` | +| `@contentful/optimization-nextjs/server` | Manual server SDK control (escape hatches) | `nextjs-sdk#server.tsx`; `nextjs-sdk#../package.json` | +| `@contentful/optimization-nextjs/api-schemas` | Type guards `isMergeTagEntry`, `isResolvedContentfulEntry` | `nextjs-sdk#api-schemas.ts`; `api-schemas#contentful/typeGuards.ts#isMergeTagEntry`; `api-schemas#contentful/typeGuards.ts#isResolvedContentfulEntry` | Note: `/pages-router` and `/pages-router/server` export DIFFERENT functions both named `createNextjsPagesRouterOptimization`. @@ -23,8 +23,8 @@ Note: `/pages-router` and `/pages-router/server` export DIFFERENT functions both The package root (`@contentful/optimization-nextjs`) is not an import path — the `package.json` exports map starts at `./app-router`, with no `.` entry. `/pages-router` exports the factory, tracker, and bound types only; import React Web hooks/providers from `/client`. (A `'use client'` -module cannot wildcard-re-export React Web without breaking Next.js 15 builds.) source: -`nextjs-sdk/package.json` exports; `src/pages-router.ts:29-33`. +module cannot wildcard-re-export React Web without breaking Next.js 15 builds.) +source: `nextjs-sdk#../package.json`; `nextjs-sdk#pages-router.ts#createNextjsPagesRouterOptimization`. ## Setup / factory @@ -35,119 +35,119 @@ module cannot wildcard-re-export React Web without breaking Next.js 15 builds.) `trackEntryInteraction?`, `liveUpdates?`, `app` (`name`, `version`), `logLevel?`. **The client factory strips `contentful` from the config** it forwards (`toClientRootConfig` / `toClientProviderConfig` destructure `contentful: _contentful`), so managed fetching on the client - is not wired here; server-side prefetch is the Pages Router managed path (see below). source: - `src/pages-router.ts:51-99, 104-118`. + is not wired here; server-side prefetch is the Pages Router managed path (see below). + source: `nextjs-sdk#pages-router.ts#createNextjsPagesRouterOptimization`; `nextjs-sdk#pages-router.ts#toClientRootConfig`; `nextjs-sdk#pages-router.ts#toClientProviderConfig`. - **Server:** `createNextjsPagesRouterOptimization(config)` → `{ getServerSideOptimizationProps }`. - source: `src/pages-router-server.ts:85-99`. + source: `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterOptimization`. - Config `NextjsPagesRouterOptimizationConfig extends OptimizationNodeConfig`. **Requires** `server.consent` — a `CoreStatelessRequestConsent` value OR - `(context: GetServerSidePropsContext) => CoreStatelessRequestConsent | Promise<...>`. source: - `src/pages-router-server.ts:56-65, 308-313`. - - Optional `cookie?` (`domain`, `expires` in days → maxAge seconds). source: - `src/pages-router-server.ts:56-57, 293-306`. + `(context: GetServerSidePropsContext) => CoreStatelessRequestConsent | Promise<...>`. + source: `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimizationConfig`; `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterServerConsentResolver`; `core-sdk#CoreStatelessRequest.ts#CoreStatelessRequestConsent`. + - Optional `cookie?` (`domain`, `expires` in days → maxAge seconds). + source: `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimizationConfig`; `nextjs-sdk#pages-router-server.ts#toAnonymousIdCookieOptions`. - **`contentful?: ContentfulConfig` (managed fetching):** via `OptimizationNodeConfig` → core `contentful` config; enables server-side managed fetch through the request optimization instance (`requestOptimization.fetchOptimizedEntry(id)`) and the `prefetchOptimizedEntries` option below. - source: `core-sdk` `CoreBase.ts:173` (`CoreConfig.contentful`). - - Consent resolver reads cookies as `context.req.cookies[NAME]` (NOT App Router's - `cookies.get()`). source: `src/pages-router-server.ts:52-54`; ref impl - `lib/optimization-server.ts:16-19`. + source: `core-sdk#CoreBase.ts#CoreConfig`; `core-sdk#CoreBase.ts#ContentfulConfig`; `core-sdk#CoreStatelessRequest.ts#fetchOptimizedEntry`. + - Consent resolver reads cookies as `context.req.cookies[NAME]` (NOT App Router's `cookies.get()`). + source: `nextjs-sdk#pages-router-server.ts#createPagesRouterCookieReader`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts`. - `getServerSideOptimizationProps(context, options?)` return shape: `{ props: { contentfulOptimization: { clientDefaults?, initialPageEvent, serverOptimizationState?, serverOptimizedEntries? } }, data, requestOptimization }`. `clientDefaults` + `serverOptimizationState` + `serverOptimizedEntries` optional; - `initialPageEvent` required. Also writes the anonymous-id `Set-Cookie`. source: - `src/pages-router-server.ts:41-49, 72-76, 101-149, 233-258`. + `initialPageEvent` required. Also writes the anonymous-id `Set-Cookie`. + source: `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimizationPropsResult`; `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimizationPageProps`; `nextjs-sdk#pages-router-server.ts#getNextjsPagesRouterOptimizationProps`; `nextjs-sdk#pages-router-server.ts#appendSetCookie`. - **`prefetchOptimizedEntries` option:** `options.prefetchOptimizedEntries?: readonly` `OptimizedEntryPrefetchDescriptor[]` (`{ entryId, entryQuery? }`). When present, the server calls `prefetchOptimizedEntries(requestOptimization, descriptors)` and puts the resulting `ServerOptimizedEntryHandoff[]` (each `{ entryId, entryQuery?, baselineEntry }`) into `contentfulOptimization.serverOptimizedEntries`, which `OptimizationRoot` forwards to react-web's `serverOptimizedEntries` prop so managed-`entryId` entries hydrate without a client - fetch. source: `src/pages-router-server.ts:36-40, 59, 121-149, 253-258`; `core-sdk` - `OptimizedEntrySourceController.ts:98-101`. + fetch. + source: `nextjs-sdk#pages-router-server.ts#prefetchOptimizedEntries`; `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimizationPropsOptions`; `core-sdk#OptimizedEntrySourceController.ts#prefetchOptimizedEntries`; `core-sdk#OptimizedEntrySourceController.ts#ServerOptimizedEntryHandoff`. ## Components & hooks -| Name | Kind | Import path | Key props/args | Returns | source | -| -------------------------- | --------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------- | -| `OptimizationRoot` | component | `/pages-router` | `clientDefaults?`, `serverOptimizationState?`, `children` | `ReactElement` | `src/pages-router.ts:37-71` | -| `OptimizationProvider` | component | `/pages-router` | same; internally wraps `LiveUpdatesProvider` (`globalLiveUpdates`) | `ReactElement \| null` | `src/pages-router.ts:73-91` | -| `OptimizedEntry` | component | `/pages-router` (factory return) | discriminated union `baselineEntry` XOR `entryId` (+`entryQuery?`), render-prop child, `liveUpdates?`, `loadingFallback?`, `errorFallback?`, `onEntryError?`, tracking props | `ReactElement \| null` | `src/pages-router.ts:100` (= react-web `OptimizedEntry`) | -| `NextPagesAutoPageTracker` | component | `/pages-router` | `initialPageEvent?: 'emit'\|'skip'`, `getPagePayload?` | `null` | `src/pages-router.ts:30`; react-web `src/router/next-pages.tsx:53-58` | -| `useConsentState` | hook | `/client` | — | consent state | react-web `hooks/useOptimizationState.ts:49` | -| `useProfileState` | hook | `/client` | — | profile (`traits`) | react-web `hooks/useOptimizationState.ts:79` | -| `useOptimizationActions` | hook | `/client` | — | `{ setConsent, identifyUser, resetUser }` | react-web `hooks/useOptimizationActions.ts:11-19,44-58` | -| `useOptimizationContext` | hook | `/client` | — | `{ sdk }` (undefined until ready) | react-web `context/OptimizationContext.tsx:8` | -| `useMergeTagResolver` | hook | `/client` | — | merge-tag resolver | react-web `hooks/useMergeTagResolver.ts` | -| `useOptimizedEntry` | hook | `/client` | entry + options (same `baselineEntry` XOR `entryId` union) | resolved entry state | react-web `optimized-entry/useOptimizedEntry.ts` | +| Name | Kind | Import path | Key props/args | Returns | source | +| -------------------------- | --------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `OptimizationRoot` | component | `/pages-router` | `clientDefaults?`, `serverOptimizationState?`, `children` | `ReactElement` | `nextjs-sdk#pages-router.ts#OptimizationRoot`; `nextjs-sdk#pages-router.ts#BoundNextjsPagesRouterOptimizationRootProps` | +| `OptimizationProvider` | component | `/pages-router` | same; internally wraps `LiveUpdatesProvider` (`globalLiveUpdates`) | `ReactElement` / `null` | `nextjs-sdk#pages-router.ts#OptimizationProvider` | +| `OptimizedEntry` | component | `/pages-router` (factory return) | discriminated union `baselineEntry` XOR `entryId` (+`entryQuery?`), render-prop child, `liveUpdates?`, `loadingFallback?`, `errorFallback?`, `onEntryError?`, tracking props | `ReactElement` / `null` | `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry` | +| `NextPagesAutoPageTracker` | component | `/pages-router` | `initialPageEvent?: 'emit' / 'skip'`, `getPagePayload?` | `null` | `nextjs-sdk#pages-router.ts#NextPagesAutoPageTracker`; `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker` | +| `useConsentState` | hook | `/client` | — | consent state | `react-web-sdk#hooks/useOptimizationState.ts#useConsentState` | +| `useProfileState` | hook | `/client` | — | profile (`traits`) | `react-web-sdk#hooks/useOptimizationState.ts#useProfileState` | +| `useOptimizationActions` | hook | `/client` | — | `{ setConsent, identifyUser, resetUser }` | `react-web-sdk#hooks/useOptimizationActions.ts#useOptimizationActions` | +| `useOptimizationContext` | hook | `/client` | — | `{ sdk }` (undefined until ready) | `react-web-sdk#hooks/useOptimization.ts#useOptimizationContext`; `react-web-sdk#context/OptimizationContext.tsx#OptimizationContextValue` | +| `useMergeTagResolver` | hook | `/client` | — | merge-tag resolver | `react-web-sdk#hooks/useMergeTagResolver.ts#useMergeTagResolver` | +| `useOptimizedEntry` | hook | `/client` | entry + options (same `baselineEntry` XOR `entryId` union) | resolved entry state | `react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntry` | Note: the hooks (`useConsentState`/`useProfileState`/`useOptimizationActions`/`useOptimizationContext`/ `useMergeTagResolver`/`useOptimizedEntry`) import from `/client`, not `/pages-router` (which exports -the factory + tracker + types only). source: `src/pages-router.ts:29-33`. +the factory + tracker + types only). +source: `nextjs-sdk#pages-router.ts#createNextjsPagesRouterOptimization`; `nextjs-sdk#client.ts`. Note: unlike App Router's bound `OptimizedEntry`, the Pages Router `OptimizedEntry` IS the react-web -component directly, so it accepts per-entry `liveUpdates` (`OptimizedEntry.tsx:60`), -`loadingFallback`, and the managed `entryId`/`entryQuery`/`errorFallback`/`onEntryError` props. -Double-wrapping the same baseline id returns null + a dev warning -(`OptimizedEntry.tsx:104-116, 164-166`). +component directly, so it accepts per-entry `liveUpdates`, `loadingFallback`, and the managed +`entryId`/`entryQuery`/`errorFallback`/`onEntryError` props. Double-wrapping the same baseline id +returns null + a dev warning. +source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntrySourceProps`. ## Render / entry resolution - Render prop `(resolvedEntry: Entry, { getMergeTagValue }) => ReactNode`; `resolvedEntry` is a base `contentful` `Entry` ⇒ cast `resolved as YourType` for narrower component types - (`as unknown as YourType` only for genuinely disjoint). source: react-web - `optimized-entry/optimizedEntryUtils.ts:11`; `OptimizedEntry.tsx:36`. Baseline-fallback contract: - see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + (`as unknown as YourType` only for genuinely disjoint). Baseline-fallback contract: see + [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: `react-web-sdk#optimized-entry/optimizedEntryUtils.ts#RenderProp`; `react-web-sdk#optimized-entry/optimizedEntryUtils.ts#OptimizedEntryRenderContext`. - Merge tags: guard embedded nodes with `isMergeTagEntry`; pass node `target` to `getMergeTagValue`. - source: `core-sdk` `CoreBase.ts:213`; `test/typeGuards.ts:285`. + source: `api-schemas#contentful/typeGuards.ts#isMergeTagEntry`; `core-sdk#CoreBase.ts#getMergeTagValue`. ## Identifier ownership -| Identifier | Owner | Notes | source | -| -------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by server props helper via `Set-Cookie`; must NOT be `HttpOnly` (browser reads it) | `core-sdk/constants.ts:38`; `server.tsx:18`; `cookies.ts:39-67, 105` | -| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `server.consent` and personalizes on the result | ref impl `lib/config.ts`, `lib/optimization-server.ts:16-19` | -| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | Next.js convention | -| preview-panel enable flag | reader | Guide uses `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL`; ref impl uses non-standard `PUBLIC_...` — DRIFT | ref impl `lib/config.ts:6`; see `../shared/consistency-notes.md` | +| Identifier | Owner | Notes | source | +| -------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by server props helper via `Set-Cookie`; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader` | +| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `server.consent` and personalizes on the result | `impl:nextjs-sdk_pages-router#lib/config.ts`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts` | +| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | +| preview-panel enable flag | reader | Guide uses `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL`; ref impl uses non-standard `PUBLIC_...` — DRIFT | `impl:nextjs-sdk_pages-router#lib/config.ts`; `kb:shared/consistency-notes.md` | ## Events & tracking - Page events: `NextPagesAutoPageTracker` emits on navigation; reads route via `useRouter` (NOT `useSearchParams`) ⇒ **no `Suspense` boundary needed** (App Router's tracker does need it). - source: react-web `router/next-pages.tsx:3, 53-58`. + source: `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker`. - `getPagePayload` callback receives `AutoPageEmissionContext` = `{ context, routeKey, isInitialEmission }`. Route fields (`pathname`, `asPath`, `query`, `router`, `routeKey`) are nested under `.context`, NOT top-level ⇒ destructure - `({ context: { pathname } }) => ...`. source: react-web `auto-page/types.ts:10-19`, - `router/next-pages.tsx:79-89`, `auto-page/pagePayload.ts:57`. Arbitrary `properties` keys are - allowed (`Page` is `z.catchall(z.json())`; `api-schemas/.../Page.ts:13-46`; - `EventBuilder.ts:219-221`). + `({ context: { pathname } }) => ...`. Arbitrary `properties` keys are allowed (`Page` is + `z.catchall(z.json())`). + source: `react-web-sdk#auto-page/types.ts#AutoPageEmissionContext`; `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageContext`; `react-web-sdk#auto-page/pagePayload.ts#buildAutoPagePayload`; `api-schemas#experience/event/properties/Page.ts#Page`; `core-sdk#events/EventBuilder.ts#PageViewBuilderArgs`. - Duplicate-page-event control: `initialPageEvent: 'emit' | 'skip'`. Server auto-resolves to `'skip'` when data present AND event consent, else `'emit'`; overridable via 2nd arg. Flows - through `contentfulOptimization.initialPageEvent` → tracker prop. source: - `src/pages-router-server.ts:28, 48, 132, 251-260`. + through `contentfulOptimization.initialPageEvent` → tracker prop. + source: `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterInitialPageEvent`; `nextjs-sdk#pages-router-server.ts#resolveInitialPageEvent`; `nextjs-sdk#pages-router-server.ts#toContentfulOptimizationProps`. - Interaction tracking (views/clicks/hovers): on by default with `OptimizedEntry`; opt out per-type - via factory `trackEntryInteraction`; uses resolved entry id. source: ref impl - `lib/optimization.ts:11`. + via factory `trackEntryInteraction`; uses resolved entry id. + source: `impl:nextjs-sdk_pages-router#lib/optimization.ts`. ## Consent & persistence - Model: see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). - Server-derived client defaults: `server.consent` value/resolver → `resolveClientDefaults` maps to `{ consent, persistenceConsent }` → `contentfulOptimization.clientDefaults` → - `OptimizationRoot clientDefaults`, so the browser starts in the same consent state the server - used. source: `src/pages-router-server.ts:262-275`; `src/pages-router.ts:114-124`. + `OptimizationRoot clientDefaults`, so the browser starts in the same consent state the server used. + source: `nextjs-sdk#pages-router-server.ts#resolveClientDefaults`; `nextjs-sdk#pages-router.ts#resolveClientDefaults`. ## Version / runtime quirks - **No proxy/middleware.** Server identity + resolution + `Set-Cookie` all happen inside - `getServerSideProps` via `getServerSideOptimizationProps(context)`. source: - `src/pages-router-server.ts:101-137`. + `getServerSideProps` via `getServerSideOptimizationProps(context)`. + source: `nextjs-sdk#pages-router-server.ts#getNextjsPagesRouterOptimizationProps`; `nextjs-sdk#pages-router-server.ts#appendSetCookie`. - `getServerSideProps` is already per-request dynamic — no static/ISR conflict; the page is the request boundary. (Contrast App Router, where server personalization forces a route dynamic.) + source: `extern:Next.js getServerSideProps runs per request (never statically pre-rendered)`. - `_app.tsx` is the mount point for `OptimizationRoot` + tracker (reads - `pageProps.contentfulOptimization`). source: ref impl `pages/_app.tsx:31-37`. + `pageProps.contentfulOptimization`). source: `impl:nextjs-sdk_pages-router#pages/_app.tsx`. ## Failure & fallback behavior @@ -155,7 +155,8 @@ Double-wrapping the same baseline id returns null + a dev warning - **Experience API failure inside `getServerSideProps` REJECTS the request ⇒ 500** (no internal try/catch to baseline): `page()` → `sendAllowedExperienceEvent` awaits `upsertProfile` with no catch. Reader should wrap `getServerSideOptimizationProps` in try/catch and render baseline on - failure. Denied consent short-circuits to `{ accepted: false }` with no API call. source: - `core-sdk` `CoreStatelessRequest.ts:299-333`. -- All-locale payloads (`withAllLocales` / `locale=*`) ⇒ baseline. source: see - [`../shared/concepts.md`](../shared/concepts.md#entry-resolution). + failure. Denied consent short-circuits to `{ accepted: false }` with no API call. + source: `core-sdk#CoreStatelessRequest.ts#page`; `core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent`. +- All-locale payloads (`withAllLocales` / `locale=*`) ⇒ baseline. Model: see + [`../shared/concepts.md`](../shared/concepts.md#entry-resolution). + source: `kb:shared/concepts.md`. From 3eb24e16c85934321a6f5887febd5855809056da Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:38:58 +0200 Subject: [PATCH 14/42] =?UTF-8?q?=F0=9F=A7=B9=20chore(sdk-knowledge):=20Re?= =?UTF-8?q?move=20stray=20=20tags=20from=20KB=20meta=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md and _template.md carried a leftover tag at EOF (an artifact from the #358 knowledge-base scaffolding). Remove them so the meta files are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/internal/sdk-knowledge/README.md | 2 +- documentation/internal/sdk-knowledge/_template.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/documentation/internal/sdk-knowledge/README.md b/documentation/internal/sdk-knowledge/README.md index d87dcbb25..dba37ee1f 100644 --- a/documentation/internal/sdk-knowledge/README.md +++ b/documentation/internal/sdk-knowledge/README.md @@ -101,4 +101,4 @@ Examples: Copy `_template.md` into the right family dir, keep every heading, fill each section with verified facts + source pointers, mark empty sections `None.`, and add shared vocabulary/concepts to -`shared/` rather than duplicating them. +`shared/` rather than duplicating them. diff --git a/documentation/internal/sdk-knowledge/_template.md b/documentation/internal/sdk-knowledge/_template.md index 353343f2b..ae19c8829 100644 --- a/documentation/internal/sdk-knowledge/_template.md +++ b/documentation/internal/sdk-knowledge/_template.md @@ -57,4 +57,3 @@ this SDK. ## Failure & fallback behavior - What happens on API failure, missing variant, unresolved links, bad payload shape. source: - From d4d75d539db34581f3df7c2c176f803d0d65cd4d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:40:21 +0200 Subject: [PATCH 15/42] =?UTF-8?q?=F0=9F=90=9E=20fix(docs):=20Parse=20escap?= =?UTF-8?q?ed=20pipes=20in=20KB=20table=20cells=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table-row parser split on every `|`, so a `Returns` cell like `ReactElement \| null` became two cells and its `null` could be misread as the source column. Split on unescaped pipes only and unescape `\|` within each cell (correct GFM semantics), so a union-type cell is one value. Both Next.js KB migrations had to work around this by rewording cells; now the parser handles them. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/sdk-knowledge/markdown.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/sdk-knowledge/markdown.ts b/scripts/sdk-knowledge/markdown.ts index 670473aae..1eb635ea3 100644 --- a/scripts/sdk-knowledge/markdown.ts +++ b/scripts/sdk-knowledge/markdown.ts @@ -34,10 +34,12 @@ export function parseTableRow(line: string): string[] | undefined { if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { return undefined } + // Split on unescaped pipes only, then unescape `\|` within each cell — a `Returns` column value + // like `ReactElement \| null` is one cell, not two, so its `null` is never misread as a pointer. return trimmed .slice(1, -1) - .split('|') - .map((cell) => cell.trim()) + .split(/(? cell.trim().replace(/\\\|/gu, '|')) } /** A `| --- | :--: |` divider row separating a table header from its body. */ From 84a0e5cfe0ef38393c7eba36ddf2efcf08ccd535 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:47:18 +0200 Subject: [PATCH 16/42] =?UTF-8?q?=F0=9F=93=9D=20docs(scripts):=20Document?= =?UTF-8?q?=20the=20knowledge-base=20validator=20thoroughly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore and expand the explanatory comments lost when the header was condensed to fit the line cap (the module split fixed the line count; this restores the docs). The validator does non-obvious work and was under-commented. - Header: the four checks (grammar/resolution/coverage/template), the two pointer carriers, per-file check scope, and why symbol anchors beat line numbers. - extractPointers/handleTableRow: the stateful source-column tracking across a table's rows and why it is cleared when the table ends. - checkSourceToken: the three resolution gates and which one catches a moved/renamed export. matchProseSource: why a wrapped pointer tail would be dropped. - coverage heuristics (itemHasPointerNearby, isCrossReference): what each rejects and why. - discoverSdkSrcRoots: why keys are derived not hardcoded, and the uniqueness invariant. - source-symbols.ts: parser-only rationale, declarations-not-re-exports (the barrel-file drift the migration caught), and why variable declarations are handled separately. No logic changes; knowledge:check still passes, lint and format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/sdk-knowledge/source-symbols.ts | 37 +++++- scripts/validate-sdk-knowledge.ts | 163 ++++++++++++++++++++++-- 2 files changed, 180 insertions(+), 20 deletions(-) diff --git a/scripts/sdk-knowledge/source-symbols.ts b/scripts/sdk-knowledge/source-symbols.ts index 675f172f4..310163673 100644 --- a/scripts/sdk-knowledge/source-symbols.ts +++ b/scripts/sdk-knowledge/source-symbols.ts @@ -1,13 +1,31 @@ +/** + * Symbol collection for the knowledge-base validator. + * + * A `#file#symbol` pointer is only trustworthy if the named symbol actually exists in the file. + * This module answers "what names does this file declare?" by parsing the source with the TypeScript + * compiler API and walking the AST. + * + * Design choices: + * - PARSER ONLY. We use `ts.createSourceFile` (syntax) — not a Program/TypeChecker (types). We only + * need to know a name is declared, not resolve its type, so we avoid the cost and the tsconfig / + * module-resolution graph a checker would require. This keeps the validator fast and hermetic. + * - DECLARATIONS, NOT RE-EXPORTS. We match declaration nodes, so `export { foo } from './x'` does + * NOT make `foo` count as declared here — it is declared in `./x`. This is intentional: a pointer + * should name the file that actually declares the symbol, not a barrel that re-exports it (a + * recurring source of drift the migration caught). + * - EXPORTED OR NOT. A pointer may name an internal symbol as evidence, so we do not require the + * `export` modifier — only that the name is declared somewhere in the file. + */ + import { readFileSync } from 'node:fs' import ts from 'typescript' /** - * Collects every named declaration in a TypeScript file so a knowledge-base pointer can be resolved - * to a real symbol. Uses the compiler's PARSER only — no type-checking, no tsconfig graph — so it is - * fast and self-contained. Recognizes: - * - top-level declarations: function / class / interface / type / enum, and `const`/`let` names - * - members: enum members, interface/type-literal property & method signatures, and class - * members/methods (so a pointer can name a config key or an SDK method like `fetchContentfulEntry`) + * Returns every name declared anywhere in the file: top-level function / class / interface / type / + * enum and `const`/`let`/`var` names, plus members (enum members, interface & type-literal property/ + * method signatures, and class members/methods — so a pointer can name a config key or an SDK method + * like `fetchContentfulEntry`). The whole tree is walked (not just top level) so nested members are + * reached. */ export function collectDeclaredSymbols(filePath: string): Set { const sourceFile = ts.createSourceFile( @@ -27,6 +45,11 @@ export function collectDeclaredSymbols(filePath: string): Set { return symbols } +/** + * Adds `node`'s declared name to the set, if it has one. Variable declarations are handled separately + * from the isNamedDeclaration group because a `VariableDeclaration`'s binding may be a destructuring + * pattern rather than a plain identifier; we only record the simple-identifier case (`const x = …`). + */ function addDeclaredName(node: ts.Node, symbols: Set): void { if (isNamedDeclaration(node) && node.name !== undefined && ts.isIdentifier(node.name)) { symbols.add(node.name.text) @@ -36,6 +59,7 @@ function addDeclaredName(node: ts.Node, symbols: Set): void { } } +/** True for declaration nodes that expose a `name` — either a top-level declaration or a member. */ function isNamedDeclaration(node: ts.Node): node is ts.Declaration & { name: ts.Node | undefined } { return isTopLevelDeclaration(node) || isMemberDeclaration(node) } @@ -64,6 +88,7 @@ function isMemberDeclaration(node: ts.Node): boolean { ) } +/** Parse as TSX for `.tsx` files (JSX changes the grammar), otherwise plain TS. */ function scriptKindFor(filePath: string): ts.ScriptKind { return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS } diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index c229f6d44..48e0b81ab 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -3,11 +3,46 @@ /** * Validates the internal SDK knowledge base under documentation/internal/sdk-knowledge/. * - * Enforces the source-pointer grammar documented in that directory's README.md so the base stays a - * store of facts grounded in packages source, not claims grounded in other docs. It checks grammar - * (pointers parse), resolution (symbols resolve against real TypeScript source via the compiler - * API — symbol-anchored, no drift-prone line numbers), coverage (no pointerless facts), and - * template conformance (per-SDK files match _template.md). Usage: tsx scripts/validate-sdk-knowledge.ts + * The knowledge base records verified SDK facts, each ending in a `source:` pointer. This script is + * the machine that keeps those pointers honest: it enforces the pointer grammar documented in the + * base's own README (`#source-pointer-grammar`), so the base stays a store of facts grounded in real + * source rather than claims grounded in other docs. It is wired into CI (on any change to the base + * OR to packages/**\/src) and into a Stop hook, so a source refactor that orphans a pointer fails on + * the same change. + * + * ## The four checks + * + * 1. Grammar — every `source:` pointer parses into a known token form. A free-text pointer + * (the old "source: accepted App Router guide") is rejected: that is the whole + * point, since a pointer at another doc is circular, not evidence. + * 2. Resolution — a `#file#symbol` token resolves to a real file AND a symbol actually + * declared in it, checked against the TypeScript source via the compiler API + * (see source-symbols.ts). Pointers are SYMBOL-anchored, never line numbers, + * because line ranges drift on every edit while a symbol name does not. + * impl:/concept:/kb: tokens resolve to real files; extern: is trusted (out of repo). + * 3. Coverage — in a per-SDK file, a bullet that states an SDK fact (names a concrete symbol in + * inline code) must carry a pointer; a pointerless fact is a claim, not knowledge. + * 4. Template — per-SDK files match _template.md heading-for-heading, in order, so every SDK is + * documented against the same skeleton. + * + * ## How a pointer is found and checked + * + * Pointers ride on one of two carriers (extractPointers): a prose `source: …` line, or the `source` + * column of a markdown table. Each carrier yields one or more `; `-separated tokens; each token is + * classified and resolved by checkToken. Grammar + resolution + the "no wrapped pointer line" guard + * apply to every fact file (per-SDK and shared/); coverage + template conformance apply only to + * per-SDK files, because shared/ files intentionally mix normative prose with facts. + * + * ## Scope of files + * + * - META_FILES (README.md, _template.md) — document the format and hold placeholder/illustrative + * pointers; exempt from all checks so the format's own docs are not graded as data. + * - shared/** — grammar + resolution only (mixed prose + facts). + * - everything else (per-SDK files) — all four checks. + * + * Failures accumulate into `problems` and are printed sorted by file:line; a non-empty set exits 1. + * + * Usage: tsx scripts/validate-sdk-knowledge.ts (or `pnpm knowledge:check`) */ import { readdirSync, readFileSync, statSync } from 'node:fs' @@ -35,29 +70,43 @@ const META_FILES = new Set(['README.md', '_template.md']) /** Directories under the knowledge dir holding shared, non-per-SDK material. */ const SHARED_DIRS = new Set(['shared']) +/** Template conformance compares only `##` section headings (level 2); `#` title and `###` vary. */ const HEADING_LEVEL_SECTION = 2 -/** How many continuation lines below a list item may carry that item's pointer. */ +/** + * How many continuation lines below a list item may carry that item's pointer. A fact may wrap its + * prose across a couple of lines and put `source:` on the last; we look this far ahead for it before + * concluding the fact is pointerless. + */ const CONTINUATION_LOOKAHEAD = 3 +/** Max length of a fact excerpt echoed back in a problem message, so reports stay readable. */ const SUMMARY_TRUNCATE = 80 +/** Max length of a `Label:` lead-in stripped when testing whether a bullet is a bare cross-reference. */ const LABEL_LEADIN_MAX = 40 -/** A `##` token has this many `#`-separated parts (symbol optional). */ +/** A `##` token has this many `#`-separated parts (the symbol is optional). */ const TOKEN_PARTS_WITH_SYMBOL = 3 +/** One validation failure, anchored to a file and line for a sorted, clickable report. */ interface Problem { file: string line: number message: string } +/** The pointer tokens found on one carrier line (a `source:` prose line or a table row). */ interface Pointer { line: number tokens: string[] } +// Module-level state, built once and shared across every file check: +// problems — accumulates failures; a non-empty set makes report() exit 1. +// sdkSrcRoots — grammar key → package src/ root, discovered from the workspace (not hardcoded). +// symbolCache — per-file declared-symbol sets, so a file parsed once serves many pointers. const problems: Problem[] = [] const sdkSrcRoots = discoverSdkSrcRoots() const symbolCache = new Map>() +// Entry point: validate every markdown file in the base, then print the sorted report and exit. for (const file of listMarkdownFiles(knowledgeDir)) { validateFile(file) } @@ -102,6 +151,11 @@ function validateFile(absPath: string): void { * Pulls source pointers out of a fact file. Two carriers, per the grammar: * - a prose line `source: ` (the pointer list runs to end of line) * - a table row whose `source` column holds `` (headed by a `| … | source |` row) + * + * Tables need per-row state: a `source` column can sit at any index, and different tables in the + * same file can put it at different indices. `sourceColumnIndex` remembers where the current table's + * `source` column is; it is set when a header row is seen and cleared when the table ends (a blank or + * non-table line), so a bare `source` word in unrelated prose never gets treated as a column. */ function extractPointers(lines: string[]): Pointer[] { const pointers: Pointer[] = [] @@ -117,7 +171,7 @@ function extractPointers(lines: string[]): Pointer[] { return } - // A blank or non-table line ends the current table's column mapping. + // A blank or non-table line ends the current table, so its column mapping no longer applies. if (line === '' || !line.includes('|')) { sourceColumnIndex = undefined } @@ -131,7 +185,14 @@ function extractPointers(lines: string[]): Pointer[] { return pointers } -/** Updates the tracked source-column index and records a row's pointers. Returns the new index. */ +/** + * Processes one table row, threading the `source`-column index through the table's rows. Three cases: + * - Header row (has a `source` cell): remember that column index for the rows that follow. + * - Divider row (`| --- |`), or a body row before any header was seen: nothing to record. + * - Body row with a known source column: record that cell's pointers (unless it is an empty/`—` + * placeholder cell, which legitimately carries no pointer). + * Returns the (possibly updated) source-column index for the next row. + */ function handleTableRow( columns: string[], sourceColumnIndex: number | undefined, @@ -189,16 +250,22 @@ function looksLikePointerToken(segment: string): boolean { return /^[a-z0-9-]+#[^\s]/u.test(segment) } -/** Returns the pointer text of a `source: …` prose line, or undefined if the line is not one. */ +/** + * Returns the pointer text after `source:` on a prose line, or undefined if the line is not one. + * The `(?:^|\s)` guard means only a real `source:` token matches, not a substring like `datasource:`. + * The everything-to-end-of-line capture is why the grammar forbids wrapping a pointer list (a wrapped + * tail would be dropped here — checkDanglingPointerLines catches that). + */ function matchProseSource(line: string): string | undefined { const match = /(?:^|\s)source:\s*(.+)$/u.exec(line) if (match?.[1] === undefined) { return undefined } - // Strip a trailing sentence period that terminates the fact, not a pointer. + // Strip a trailing sentence period that terminates the fact, not part of the last pointer. return match[1].trim().replace(/\.$/u, '') } +/** Splits a `; `-separated pointer list into individual tokens, stripping backtick code spans. */ function splitTokens(pointerText: string): string[] { return pointerText .split(';') @@ -208,6 +275,10 @@ function splitTokens(pointerText: string): string[] { // --- token checking -------------------------------------------------------------------------- +/** + * Classifies and checks one pointer token. Prefixed forms (extern:/concept:/kb:/impl:) are handled + * first; anything else must be the bare `#[#]` source form. + */ function checkToken(token: string, file: string, line: number): void { const prefixedCheck = checkPrefixedToken(token, file, line) if (prefixedCheck) { @@ -216,7 +287,11 @@ function checkToken(token: string, file: string, line: number): void { checkSourceToken(token, file, line) } -/** Handles the `extern:` / `concept:` / `kb:` / `impl:` forms. Returns true if one matched. */ +/** + * Handles the `extern:` / `concept:` / `kb:` / `impl:` forms. Returns true if the token had a known + * prefix (whether or not it was valid), so the caller does not also treat it as an `#…` pointer. + * extern: is trusted (out-of-repo fact) as long as it has text; the rest must resolve to a real file. + */ function checkPrefixedToken(token: string, file: string, line: number): boolean { if (token.startsWith('extern:')) { if (token.slice('extern:'.length).trim() === '') { @@ -245,6 +320,7 @@ function checkPrefixedToken(token: string, file: string, line: number): boolean return false } +/** Resolves an `impl:#` token to a file under implementations//. */ function checkImplToken(token: string, file: string, line: number): void { const [name, relPath] = splitOnce(token.slice('impl:'.length), '#') if (name === undefined || relPath === undefined) { @@ -254,7 +330,15 @@ function checkImplToken(token: string, file: string, line: number): void { checkFile(path.join(implementationsDir, name, relPath), token, file, line) } -/** Handles the `#[#]` form. */ +/** + * Resolves the core `#[#]` form in three gates, reporting at the first failure: + * 1. Arity + shape — exactly `#` or `##` (2 or 3 parts). A + * free-text pointer or a mistyped prefix lands here and gets the full grammar reminder. + * 2. Known SDK — `` must be a discovered package key; the error lists the valid keys. + * 3. File, then symbol — the file must exist, and (if a symbol is given) that symbol must actually + * be declared in it. The symbol gate is what turns "the file is named right" into "the fact is + * still true"; it is the check that catches a renamed or moved export. + */ function checkSourceToken(token: string, file: string, line: number): void { const parts = token.split('#') const [sdk, relPath, symbol] = parts @@ -288,6 +372,7 @@ function checkSourceToken(token: string, file: string, line: number): void { } } +/** Reports a problem unless `candidate` is an existing file. Shared by the impl:/concept:/kb: forms. */ function checkFile(candidate: string, label: string, file: string, line: number): void { if (!fileExists(candidate)) { addProblem(file, line, `${label} → no such file (${rel(candidate)})`) @@ -315,6 +400,12 @@ function checkPointerCoverage(lines: string[], file: string): void { }) } +/** + * True if the list item at `index` has its `source:` on a following indented continuation line + * (a fact may wrap across a few lines and end in `source:`). Scans up to CONTINUATION_LOOKAHEAD + * lines, stopping early at a blank line or the next list item — either means this item has ended, + * so a later `source:` belongs to something else. + */ function itemHasPointerNearby(lines: string[], index: number): boolean { for (let offset = 1; offset <= CONTINUATION_LOOKAHEAD; offset += 1) { const { [index + offset]: next } = lines @@ -344,6 +435,15 @@ function looksLikeFact(line: string): boolean { return !isCrossReference(line) } +/** + * True if a bullet is a pure cross-reference — it just points the reader at another doc ("Model: see + * ../shared/concepts.md#…") rather than stating a fact of its own. Such a bullet inherits its source + * from the referenced section and must NOT be required to carry a pointer. + * + * Detection: it must contain the word "see", and after stripping the list marker, an optional short + * "Label:" lead-in, a leading "see", every markdown link, and every inline-code span, nothing but + * punctuation may remain. A real fact leaves prose words behind and so is not treated as a reference. + */ function isCrossReference(line: string): boolean { if (!/\bsee\b/iu.test(line)) { return false @@ -358,6 +458,11 @@ function isCrossReference(line: string): boolean { // --- template conformance -------------------------------------------------------------------- +/** + * Requires a per-SDK file's `##` section headings to equal _template.md's, in the same order, so + * every SDK is documented against the same skeleton (an empty section keeps its heading with `None.`). + * Reports the expected vs. actual heading lists on mismatch, anchored to the file's first heading. + */ function checkTemplateConformance(lines: string[], file: string): void { const expected = sectionHeadings(readFileSync(templatePath, 'utf8').split('\n')) const fileHeadings = sectionLevel(headingsOf(lines)) @@ -374,10 +479,12 @@ function checkTemplateConformance(lines: string[], file: string): void { } } +/** The `##` section heading texts of a markdown file, in document order. */ function sectionHeadings(lines: string[]): string[] { return sectionLevel(headingsOf(lines)).map((heading) => heading.text) } +/** Keeps only level-2 (`##`) headings. */ function sectionLevel(headings: Heading[]): Heading[] { return headings.filter((heading) => heading.level === HEADING_LEVEL_SECTION) } @@ -396,7 +503,13 @@ function fileDeclaresSymbol(filePath: string, symbol: string): boolean { // --- workspace discovery --------------------------------------------------------------------- -/** Maps each package directory basename (the `` grammar key) to its `src/` root. */ +/** + * Maps each package directory basename (the `` grammar key) to its `src/` root, by scanning the + * workspace for package.json files with a sibling src/. Deriving the keys instead of hardcoding them + * means every SDK family is covered automatically and a new package is usable as a key the moment it + * exists. A basename must be unique (it is the grammar key), so a collision throws rather than + * silently shadowing one package with another; an empty result also throws (the scan is misrooted). + */ function discoverSdkSrcRoots(): Map { const roots = new Map() @@ -424,6 +537,7 @@ function discoverSdkSrcRoots(): Map { return roots } +/** Recursively finds every package.json under `dir`, skipping node_modules and dotfolders. */ function findPackageManifests(dir: string): string[] { const manifests: string[] = [] const walk = (current: string): void => { @@ -445,6 +559,7 @@ function findPackageManifests(dir: string): string[] { // --- file helpers ---------------------------------------------------------------------------- +/** Every `.md` file under `dir`, recursively, sorted so the report order is stable. */ function listMarkdownFiles(dir: string): string[] { const files: string[] = [] const walk = (current: string): void => { @@ -461,6 +576,11 @@ function listMarkdownFiles(dir: string): string[] { return files.sort((left, right) => left.localeCompare(right)) } +/** + * True for a per-SDK fact file — one that must pass coverage + template conformance. False for the + * meta files (README/_template) and for anything under a shared/ directory, which mixes normative + * prose with facts and so is held only to grammar + resolution. + */ function isPerSdkFile(absPath: string): boolean { const relInside = path.relative(knowledgeDir, absPath) const [firstSegment] = relInside.split(path.sep) @@ -486,6 +606,12 @@ function directoryExists(candidate: string): boolean { } } +/** + * Splits on the FIRST occurrence of `separator` (unlike String.split, which splits on all). Returns + * `[before, after]`; if the separator is absent, `after` is undefined and `before` is the whole value + * (or undefined when empty) — so `impl:foo` (no `#`) yields a defined name and undefined relpath, + * which the caller reports as a malformed pointer. + */ function splitOnce(value: string, separator: string): [string | undefined, string | undefined] { const index = value.indexOf(separator) if (index === -1) { @@ -494,24 +620,33 @@ function splitOnce(value: string, separator: string): [string | undefined, strin return [value.slice(0, index), value.slice(index + separator.length)] } +/** Element-wise string-array equality (used to compare heading lists against the template). */ function arraysEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } +/** A repo-relative path for readable problem messages. */ function rel(absPath: string): string { return path.relative(rootDir, absPath) } +/** Shortens a fact excerpt for a problem message, adding an ellipsis when clipped. */ function truncate(value: string, max = SUMMARY_TRUNCATE): string { return value.length > max ? `${value.slice(0, max - 1)}…` : value } // --- reporting ------------------------------------------------------------------------------- +/** Records one failure; collected across all files and emitted together by report(). */ function addProblem(file: string, line: number, message: string): void { problems.push({ file, line, message }) } +/** + * Prints the outcome and sets the process exit code: a success line (exit 0) when clean, or the + * problems sorted by file then line with a pointer to the grammar docs (exit 1) otherwise. Exit 1 is + * what makes CI and the pre-commit path fail on knowledge-base drift. + */ function report(): void { if (problems.length === 0) { console.log('✓ SDK knowledge base: all source pointers resolve and templates conform.') From 8c76604c82e5447392a5612537bc904ae645ffa9 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 11:57:05 +0200 Subject: [PATCH 17/42] =?UTF-8?q?=F0=9F=94=A5=20refactor(sdk-knowledge):?= =?UTF-8?q?=20Delete=20consistency-notes.md;=20fix=20drift,=20don't=20log?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consistency-notes.md was a persisted work-log — "Open items to reconcile when guides 2 & 3 land", "Resolved when guides 2 & 3 closed", "Backport candidate" — the in-progress scratchpad from authoring the web guides in #358, committed as if it were knowledge. A file that catalogs known-broken/divergent things instead of fixing them is an anti-pattern, and it duplicated wording already owned by vocabulary.md/concepts.md. - Delete the file. Its one durable fact (control-variant selectedOptimization nuance) is already recorded in web/web.md; the "preview-panel" entry was never real drift (correct per-framework prefixes), now stated as a plain fact in the pages-router KB row. - Remove the "log cross-guide drift here" instruction from the sdk-knowledge-maintenance skill — the policy that produced the file — and replace it with "fix drift when you find it; shared wording lives once in shared/ and guides reuse it." Update the finish-checklist item to match. - Drop the file from the README org tree and the dead kb: pointer in the pages-router KB. knowledge:check passes; no dangling references remain. Follow-up (noted for the docs workflow): enforce cross-guide consistency with a deterministic check that greps the canonical shared phrases across a guide family, rather than any prose record. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/README.md | 1 - .../sdk-knowledge/shared/consistency-notes.md | 79 ------------------- .../sdk-knowledge/web/nextjs-pages-router.md | 12 +-- skills/sdk-knowledge-maintenance/SKILL.md | 22 +++--- 4 files changed, 19 insertions(+), 95 deletions(-) delete mode 100644 documentation/internal/sdk-knowledge/shared/consistency-notes.md diff --git a/documentation/internal/sdk-knowledge/README.md b/documentation/internal/sdk-knowledge/README.md index dba37ee1f..06d715a0b 100644 --- a/documentation/internal/sdk-knowledge/README.md +++ b/documentation/internal/sdk-knowledge/README.md @@ -29,7 +29,6 @@ documentation/internal/sdk-knowledge/ shared/ vocabulary.md # canonical term → one-line meaning, used verbatim across web guides concepts.md # SDK-neutral shared concepts - consistency-notes.md # where guides must share language; drift logged for review web/ # web family nextjs-app-router.md nextjs-pages-router.md diff --git a/documentation/internal/sdk-knowledge/shared/consistency-notes.md b/documentation/internal/sdk-knowledge/shared/consistency-notes.md deleted file mode 100644 index 5b9afd3d2..000000000 --- a/documentation/internal/sdk-knowledge/shared/consistency-notes.md +++ /dev/null @@ -1,79 +0,0 @@ -# Cross-guide consistency notes (web family) - -Where App Router / Pages Router / React Web / Web must share language, and any drift spotted for -main's final consistency pass. Terse; running log. - -## Language that must match across web guides - -- Intro four-sentence explainer of variant / experience / Experience API / resolving / baseline - fallback. Keep the wording aligned (see [`vocabulary.md`](./vocabulary.md)). -- The **entry-source boundary** framing (managed-or-manual). See [`concepts.md`](./concepts.md). - Every web guide states it the same way: "you own the Contentful client; the SDK can fetch through - it (managed) OR you fetch yourself (manual); both are supported." The guide must not assert the - SDK never fetches, and must keep the manual path first-class. -- Entry-wrap cast guidance: render prop returns a base `Entry`; use `resolved as YourType`; mention - `as unknown as YourType` only for genuinely disjoint types. -- Baseline-fallback contract phrasing (denied consent / no variant / unresolved links / all-locale). -- `## Before you start` shape and the authored-variant gotcha (all-visitors experience for a first - test). -- consent vs persistenceConsent one-line definitions. -- No app-authored helper is named after a real SDK method — notably `fetchOptimizedEntry`, which is - an SDK method. A guide's own example fetch helper uses a distinct name (React Web uses - `fetchPageEntry`). -- The two Next.js guides share one verbatim troubleshooting row: symptom "Next.js 15 reports - unsupported `export *` in a client boundary" / cause "A `'use client'` module re-exports with - `export *` — in your app, or a package that does" / check "If the error points to your own code, - remove `export *` re-exports from your Client Components; if it points to a dependency's client - entry, use one whose client entry uses named exports." Mechanism-only, no version framing. Keep - the App Router and Pages Router rows aligned. - -## Drift spotted (for review) - -- **Preview-panel enable flag name.** Next.js guide uses - `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` (real Next.js browser-var convention). The - `nextjs-sdk_pages-router` reference impl uses a bespoke `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` - (no `NEXT_` prefix; non-standard). Guide deliberately diverges from the impl to teach the correct - convention. Confirm the React Web / Web guides frame their own preview flag as reader-owned with - each framework's real browser-var convention rather than copying the impl's `PUBLIC_` prefix. - source: impl:nextjs-sdk_pages-router#lib/config.ts - -## Open items to reconcile when guides 2 & 3 land - -- Duplicate-page-event control differs by SDK surface (App Router tracker prop vs Pages Router - `initialPageEvent` from server props vs React Web / Web). Ensure each guide names the mechanism - its SDK actually uses; keep the _concept_ wording shared (see `concepts.md#page-events`). -- Live-updates opt-in wording: confirm App Router (bound root includes provider internally) vs Pages - Router / React Web (explicit `LiveUpdatesProvider` / `globalLiveUpdates`) is described per-SDK - accurately while keeping the shared concept sentence identical. - -## Resolved when guides 2 & 3 closed (2026-07) - -- **Both closed.** Guide 2 (React Web) + guide 3 (Web) verified against source with file:line - evidence; KB `web/react-web.md` and `web/web.md` written from those facts. No cross-guide language - drift introduced: guide 3 reuses the shared four-sentence explainer, the you-fetch/SDK-resolves - sentence, the cast guidance, and the baseline-fallback phrasing verbatim. -- **Deliberate consistency hold (guide 3):** step-3 verify wording ("author a variant … target all - visitors") and the lifecycle-section opener were left as-is because they mirror the accepted, - already-closed guides 1/2. Two readability suggestions to reword them were DISCARDED to avoid - making guide 3 inconsistent with the closed guides. -- **`Copy this` vs `Follow this pattern` for partial-constructor snippets:** guide 3 now labels - merge-into-your-existing-`new ContentfulOptimization({...})` excerpts as `Follow this pattern:` - (matches how React Web labels its `trackEntryInteraction={{ hovers: false }}` excerpt). Only - self-contained pure-value-substitution blocks keep `Copy this:`. Good pattern for future guides. -- **Preview-panel flag (guide 3).** The Web guide's preview snippet uses - `import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` as a reader-owned, environment-gated - flag (an illustrative Vite-shaped convention). The Web SDK is bundler-agnostic, so there is no - single "correct" prefix here; it is framed as reader-owned. Consistent with the earlier drift note - — matches React Web's approach; does not copy the impl as an SDK constant. - -## Backport candidate for main's cross-guide pass (NEW precision, not in the closed guides yet) - -- **Control-variant `selectedOptimization` precision.** Guide 3 / `web/web.md` now state that - `selectedOptimization` is `undefined` ONLY when no experience matched; when an experience assigns - the CONTROL/baseline variant (`variantIndex: 0`) it is DEFINED while the resolved entry still - equals the baseline — so `selectedOptimization === undefined` must not be read as "showing - baseline content." source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver. - The React Web, App Router, and Pages Router guides describe the baseline-fallback contract but do - NOT surface this control-variant nuance. Consider whether to backport a one-line note to those - guides (the render helpers already guard on presence, so it is a prose-precision improvement, not - a bug). diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index d27d633c6..0b61c3d64 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md @@ -104,12 +104,12 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en ## Identifier ownership -| Identifier | Owner | Notes | source | -| -------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by server props helper via `Set-Cookie`; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader` | -| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `server.consent` and personalizes on the result | `impl:nextjs-sdk_pages-router#lib/config.ts`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts` | -| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | -| preview-panel enable flag | reader | Guide uses `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL`; ref impl uses non-standard `PUBLIC_...` — DRIFT | `impl:nextjs-sdk_pages-router#lib/config.ts`; `kb:shared/consistency-notes.md` | +| Identifier | Owner | Notes | source | +| -------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by server props helper via `Set-Cookie`; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader` | +| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `server.consent` and personalizes on the result | `impl:nextjs-sdk_pages-router#lib/config.ts`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts` | +| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | +| preview-panel enable flag | reader | Reader-owned, gated on a browser env var; the guide uses the standard `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` prefix (the ref impl's bare `PUBLIC_...` is non-standard) | `impl:nextjs-sdk_pages-router#lib/config.ts` | ## Events & tracking diff --git a/skills/sdk-knowledge-maintenance/SKILL.md b/skills/sdk-knowledge-maintenance/SKILL.md index 8f4b9a2bd..792ee9b6a 100644 --- a/skills/sdk-knowledge-maintenance/SKILL.md +++ b/skills/sdk-knowledge-maintenance/SKILL.md @@ -5,8 +5,8 @@ description: >- of verified SDK facts (symbols, props, cookies, config keys, return shapes) that each carry a source pointer into packages/**/src. Covers the three-artifact split (guides teach, this KB records verified facts, the authoring skill holds principles — facts never go in a skill), the - _template.md skeleton every per-SDK file copies, capturing shared facts once in shared/, and - logging cross-guide drift in shared/consistency-notes.md. Use when adding or editing a per-SDK + _template.md skeleton every per-SDK file copies, and capturing shared facts once in shared/ so + guide families reuse one canonical wording. Use when adding or editing a per-SDK knowledge file, recording an SDK API you just verified against source while doing other work, reading the KB before re-grepping the SDK, keeping it in sync after the SDK changes, or editing any file under documentation/internal/sdk-knowledge/. Triggers on "sdk knowledge", "internal @@ -53,9 +53,7 @@ These are the transferable behaviors this skill exists to preserve. "this changed because…". State the fact plainly in the present tense. When the SDK changes, _edit the fact in place_ so it reads as if it were always true — do not append a note about the change or strike through the old value. (Genuine runtime conditionals — "after reading headers the route - cannot use ISR" — are present-tense behavior, not history, and are fine.) The same rule governs - `shared/consistency-notes.md`: it records the status quo of what language must match across a - guide family, not a log of merges or fixes. + cannot use ISR" — are present-tense behavior, not history, and are fine.) - **While the SDK is pre-release/alpha, record no SDK-version deltas.** One moving version means there is nothing to compare — do not note what a prior SDK version did, "upgrade to the fixed version", or version-to-version differences of this SDK. Record the single current version's facts @@ -82,9 +80,14 @@ These are the transferable behaviors this skill exists to preserve. do not wait for a follow-up. - **Capture shared facts once, in `shared/`.** SDK-neutral concepts go in `shared/concepts.md`; canonical terms in `shared/vocabulary.md`. Per-SDK files link to them instead of restating them. -- **Log cross-guide drift in `shared/consistency-notes.md`.** When you spot language, an API name, - or a value that must match across a guide family but does not, record it there for the consistency - pass rather than silently reconciling it. + This single canonical wording is _how_ a guide family stays consistent: guides reuse it rather than + each paraphrasing the same fact. +- **Fix cross-guide drift when you find it; do not log it.** If a term, API name, or value that must + match across a guide family has diverged, reconcile it now — correct the guide, and if the shared + wording was missing, add it once to `shared/`. Do not keep a running list of known-broken things to + reconcile later; an unfixed divergence is a bug, not a record. (A per-framework difference that is + _correct_ — e.g. `NEXT_PUBLIC_` vs a Vite-style prefix — is not drift; state it as a fact in the + relevant per-SDK file, not as a divergence to resolve.) ## Adding a new SDK file @@ -118,6 +121,7 @@ These are the transferable behaviors this skill exists to preserve. place, not appended as history. - New per-SDK files match `_template.md` heading-for-heading, with `None.` for empty sections. - Shared facts live once in `shared/` and are linked, not restated. -- Any cross-guide drift you noticed is logged in `shared/consistency-notes.md`. +- Any cross-guide drift you noticed is fixed now (guide corrected, shared wording added to + `shared/` if it was missing), not deferred. - No SDK fact leaked into a skill; no reader-facing prose leaked into the base. - Formatting is clean: `pnpm format:fix documentation/internal/sdk-knowledge`. From f9f2e3548b96ca90a6f3b9ccfcd3f0004d752b10 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:04:05 +0200 Subject: [PATCH 18/42] =?UTF-8?q?=E2=9C=A8=20feat(skills):=20Add=20/author?= =?UTF-8?q?-guide=20workflow=20with=20new=20+=20refresh=20entry=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-generation workflow, driven by a slash command chaining the three role agents (lowest machinery, human-in-the-loop). One command, two entry points: - /author-guide classifies the target: an existing guide (or an SDK that already has one) → refresh; an SDK with no guide → new. Both paths converge on the same review loop, so quality is identical either way. - Draft (guide-writer) → review loop (delegates to /review-guide: newcomer + source verification, concurrent) → gate (blockers fixed, claims corrected, knowledge:check + format pass) → funnel learnings back into the skill (principles) or KB (facts), fixing cross-guide issues rather than logging them. Back the new capabilities in the agents: - guide-writer now documents the refresh path (diff against the current skill; the tells of a pre-current guide — no Quick start / Before you start, monolithic flow, numbered headings, setup table, missing copy/adapt labels — and restructure preserving content). - guide-source-verifier now creates a KB family file (node/, native/) from _template.md when none exists, and flags that #symbol resolution is TS-only (Swift/Kotlin deferred). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-source-verifier.md | 8 +++ .claude/agents/guide-writer.md | 10 ++++ .claude/commands/author-guide.md | 72 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 .claude/commands/author-guide.md diff --git a/.claude/agents/guide-source-verifier.md b/.claude/agents/guide-source-verifier.md index daa4aa13a..265ae0a53 100644 --- a/.claude/agents/guide-source-verifier.md +++ b/.claude/agents/guide-source-verifier.md @@ -19,3 +19,11 @@ grammar (following `sdk-knowledge-maintenance`), and confirm `pnpm knowledge:che finish. Return a per-claim verdict (confirmed/wrong/imprecise/unverifiable) with evidence and the KB action taken; hand wrong/imprecise claims to the writer with what the source actually does. Do not rewrite the guide. + +When the SDK you are verifying has **no knowledge-base file yet** (e.g. a first Node, React Native, +or native guide), create it: copy `_template.md` into the right family directory — making a new +sibling family dir like `node/` or `native/` when the family does not exist yet — and fill its +sections with the facts you verify. A new guide grows the base. Note: the pointer grammar's +`#symbol` resolution is checked via the TypeScript compiler, so it applies to TS-source SDKs (Node, +React Native). For SDKs whose source is Swift or Kotlin, the `#symbol` check does not apply yet; +raise it rather than inventing an unverifiable anchor. diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md index ebed1c49d..3e798431c 100644 --- a/.claude/agents/guide-writer.md +++ b/.claude/agents/guide-writer.md @@ -14,6 +14,16 @@ block, and self-review checklist are your source of truth. Ground every example reference implementation under `implementations/`, and check the internal knowledge base (`documentation/internal/sdk-knowledge/`) for facts already verified against source before re-grepping. +You handle two jobs: + +- **New guide** — draft from the matching template. +- **Refresh an existing guide** — first diff it against the current skill and bring it up to the + present archetype. The fastest tells that a guide predates the current approach: no `## Quick start` + or no `## Before you start`, a monolithic `## The integration flow` / `## Required steps` section, + numbered headings, a required-setup inventory table instead of a prerequisites list, or missing + `**Copy this:**` / `**Adapt this to your use case:**` labels. Restructure to the current archetype + while preserving content that is still correct; do not throw away accurate specifics. + You draft; you do not sign off. After your pass the guide goes to the `guide-newcomer-review` and `guide-source-verification` roles. When they hand back findings, apply the fixes and fold any durable lesson back into the authoring skill (principles only — never SDK facts, which belong in the guide or diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md new file mode 100644 index 000000000..b3dc04ff6 --- /dev/null +++ b/.claude/commands/author-guide.md @@ -0,0 +1,72 @@ +--- +description: Author a new SDK guide or refresh an existing one end-to-end — writer, then newcomer + source-verification review, then funnel learnings back +argument-hint: '[SDK/runtime for a new guide, or an existing guide path to refresh]' +--- + +Drive the full guide lifecycle for: `$ARGUMENTS` (if empty, ask which SDK/runtime to document or +which guide to refresh). + +This is one workflow with two entry points. First **classify the target**, then run the matching +path. Both paths end in the same review loop, so quality is identical whether a guide is new or +refreshed. + +## 0. Classify: new vs. refresh + +- If `$ARGUMENTS` names an existing file under `documentation/guides/`, or an SDK that already has a + guide there → **refresh**. +- If it names an SDK/runtime with no guide yet → **new**. +- If ambiguous, list the guides in `documentation/guides/` and ask. + +State which path you're taking and the target guide path before proceeding. + +## 1. Draft (guide-writer) + +Launch the `guide-writer` agent with the target and the path (new/refresh). + +- **New:** it drafts the guide from the `optimization-guide-authoring` templates, grounded in the + matching reference implementation under `implementations/` and reusing verified facts from the + knowledge base (`documentation/internal/sdk-knowledge/`). +- **Refresh:** it first diffs the guide against the current authoring skill — the fastest tells are a + missing `## Quick start` or `## Before you start`, a monolithic flow section, numbered headings, or + missing `**Copy this:** / **Adapt this to your use case:**` labels — then brings the guide up to the + current archetype and structure, preserving correct content. + +The writer returns the edited guide path and a summary of what it changed. + +## 2. Review loop (delegate to /review-guide) + +Run the `review-guide` command on the target guide. It fans out the two independent reviews +concurrently and consolidates: + +- **guide-newcomer-reviewer** — reads the guide cold as an average developer; reports undefined + jargon, skim-mode, unperformable steps, dishonest labels. +- **guide-source-verifier** — proves every load-bearing SDK claim against source, reuses KB facts, + and records newly verified facts back into the knowledge base. For an SDK whose KB family file does + not exist yet, it CREATES it from `_template.md` as it verifies (following the + `sdk-knowledge-maintenance` skill) — a new guide grows the KB. + +## 3. Gate before finishing + +Do not call the guide done until all of these hold; loop back to step 1 (writer) for anything unmet: + +- Every newcomer **blocker** is fixed; friction items are fixed or consciously accepted with a reason. +- Every source-verifier **wrong/imprecise** claim is corrected against what the source actually does. +- `pnpm knowledge:check` passes (new/updated KB facts resolve). +- `pnpm format:fix` leaves the guide clean and its TOC anchors resolve. + +## 4. Funnel learnings back (the loop that improves the system) + +For each finding that reflects a durable rule rather than a one-off, route it to the right artifact — +never leave it as a persisted TODO: + +- a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles only, + never SDK facts); +- a newly verified SDK fact → the knowledge base via `sdk-knowledge-maintenance`; +- a cross-guide consistency issue → **fix it now** in the affected guides and, if shared wording was + missing, add it once to `shared/` (do not record it as drift to reconcile later). + +## 5. Report + +Summarize: new vs. refresh, what the writer changed, the findings from each reviewer and how each was +resolved, what was funneled into the skill vs. the KB, and the validation result. Note anything +consciously deferred and why. From aac0cfdba65338eddeece54821a23ee4e3bb507f Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:36:26 +0200 Subject: [PATCH 19/42] =?UTF-8?q?=E2=9C=A8=20feat(sdk-knowledge):=20Add=20?= =?UTF-8?q?KB=E2=86=92guide=20dependency=20direction=20(feeds-guides)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph had only the source→KB direction (a fact's source: pointer). For incremental docs updates we also need the downstream direction: given a changed KB fact, which guide must be recomposed. Each per-SDK file now declares it with a `` marker under the title, and knowledge:check requires the marker and that its targets resolve. This is the missing edge that lets an incremental update scope a KB change to the exact guide(s) that consume it, instead of recomposing the whole family. Added markers to the four web KB files and the template; documented in the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/README.md | 5 ++ .../internal/sdk-knowledge/_template.md | 2 + .../sdk-knowledge/web/nextjs-app-router.md | 2 + .../sdk-knowledge/web/nextjs-pages-router.md | 2 + .../internal/sdk-knowledge/web/react-web.md | 2 + .../internal/sdk-knowledge/web/web.md | 2 + scripts/validate-sdk-knowledge.ts | 46 +++++++++++++++++-- 7 files changed, 58 insertions(+), 3 deletions(-) diff --git a/documentation/internal/sdk-knowledge/README.md b/documentation/internal/sdk-knowledge/README.md index 06d715a0b..2a1e315c7 100644 --- a/documentation/internal/sdk-knowledge/README.md +++ b/documentation/internal/sdk-knowledge/README.md @@ -41,6 +41,11 @@ Future families get sibling dirs (e.g. `native/`, `node/`). Do not create empty ## Rules - Every fact carries a `source:` pointer in the [grammar below](#source-pointer-grammar). +- Every per-SDK file declares the guide(s) its facts compose into, with a + `` marker under the title (comma-separate + multiple). This is the KB→guide direction of the dependency graph: a `source:` pointer says which + source a fact came from; `feeds-guides` says which guide must be recomposed when the fact changes. + `knowledge:check` requires the marker and that its targets resolve. - Per-SDK files conform to [`_template.md`](./_template.md) exactly — same sections, same order. If a section has no entries, keep the heading and write `None.` - Per-SDK files link to [`shared/concepts.md`](./shared/concepts.md) and diff --git a/documentation/internal/sdk-knowledge/_template.md b/documentation/internal/sdk-knowledge/_template.md index ae19c8829..f04a06f6e 100644 --- a/documentation/internal/sdk-knowledge/_template.md +++ b/documentation/internal/sdk-knowledge/_template.md @@ -7,6 +7,8 @@ shared/concepts.md and shared/vocabulary.md instead of restating shared material # — SDK knowledge + + > Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified > against packages/\*\*/src during the guide rewrite. diff --git a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md index 59787c624..429884d20 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -1,5 +1,7 @@ # Next.js App Router (`@contentful/optimization-nextjs`) — SDK knowledge + + > Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified > against packages/\*\*/src during the guide rewrite. diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index 0b61c3d64..6a5e0e5bb 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md @@ -1,5 +1,7 @@ # Next.js Pages Router (`@contentful/optimization-nextjs`) — SDK knowledge + + > Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified > against packages/\*\*/src during the guide rewrite. diff --git a/documentation/internal/sdk-knowledge/web/react-web.md b/documentation/internal/sdk-knowledge/web/react-web.md index b8b493a07..698ee9df4 100644 --- a/documentation/internal/sdk-knowledge/web/react-web.md +++ b/documentation/internal/sdk-knowledge/web/react-web.md @@ -1,5 +1,7 @@ # React Web (`@contentful/optimization-react-web`) — SDK knowledge + + > Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified > against packages/\*\*/src during the guide rewrite. diff --git a/documentation/internal/sdk-knowledge/web/web.md b/documentation/internal/sdk-knowledge/web/web.md index 4a03bbfde..1346d4c0e 100644 --- a/documentation/internal/sdk-knowledge/web/web.md +++ b/documentation/internal/sdk-knowledge/web/web.md @@ -1,5 +1,7 @@ # Web (`@contentful/optimization-web`) — SDK knowledge + + > Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified > against packages/\*\*/src during the guide rewrite. diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index 48e0b81ab..9e079375a 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -137,11 +137,12 @@ function validateFile(absPath: string): void { // validation entirely. Prose continuations ("The React Web guides…") have no such shape. checkDanglingPointerLines(lines, relPath) - // Coverage and template conformance apply only to per-SDK files, whose _template.md shape makes - // "every bullet/row is a sourced fact" a firm rule. shared/ files mix normative prose with facts. + // Coverage, template conformance, and the KB→guide link apply only to per-SDK files: shared/ files + // mix normative prose with facts and feed a family rather than a single guide. if (isPerSdkFile(absPath)) { checkPointerCoverage(lines, relPath) checkTemplateConformance(lines, relPath) + checkFeedsGuides(lines, relPath) } } @@ -479,6 +480,43 @@ function checkTemplateConformance(lines: string[], file: string): void { } } +/** + * Validates the KB→guide dependency link. Every per-SDK file declares the guide(s) its facts compose + * into via a `` marker; the target guides must exist. This is the + * downstream direction of the graph: given a changed KB fact, it names which guides must be recomposed + * (the mirror of the source→KB direction that a fact's `source:` pointer gives). A per-SDK file with + * no marker cannot be scoped incrementally, so it is required. + */ +function checkFeedsGuides(lines: string[], file: string): void { + const markerLine = lines.findIndex((line) => line.includes('feeds-guides:')) + if (markerLine === -1) { + addProblem( + file, + 1, + `per-SDK file must declare which guide(s) it feeds: add ` + + ``, + ) + return + } + + const match = /feeds-guides:\s*([^>]+?)\s*-->/u.exec(lines[markerLine] ?? '') + const targets = (match?.[1] ?? '') + .split(',') + .map((target) => target.trim()) + .filter((target) => target !== '') + + if (targets.length === 0) { + addProblem(file, markerLine + 1, `feeds-guides: marker lists no guide`) + return + } + + for (const target of targets) { + if (!fileExists(path.join(rootDir, target))) { + addProblem(file, markerLine + 1, `feeds-guides: → no such guide (${target})`) + } + } +} + /** The `##` section heading texts of a markdown file, in document order. */ function sectionHeadings(lines: string[]): string[] { return sectionLevel(headingsOf(lines)).map((heading) => heading.text) @@ -649,7 +687,9 @@ function addProblem(file: string, line: number, message: string): void { */ function report(): void { if (problems.length === 0) { - console.log('✓ SDK knowledge base: all source pointers resolve and templates conform.') + console.log( + '✓ SDK knowledge base: source pointers resolve, templates conform, and guide links are valid.', + ) return } From 4dc710ee0c1d803bd3586ff1f12c818543cc4f59 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:38:12 +0200 Subject: [PATCH 20/42] =?UTF-8?q?=E2=9C=A8=20feat(skills):=20Add=20sdk-kno?= =?UTF-8?q?wledge-authoring=20=E2=80=94=20the=20source=E2=86=92KB=20compre?= =?UTF-8?q?hension=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline was missing the role that derives facts FROM source. Verification was guide-triggered (read a guide, re-derive its claims from source), which re-comprehends code per guide — the expensive path on every change. This skill makes the knowledge base a first-class derivation target from source, updated independently of any guide: - BOOTSTRAP: read an SDK's exported surface once into a new KB file. Expensive, once per SDK. - INCREMENTAL: a source change scopes the work — re-verify only facts whose source: pointer hits a changed file (knowledge:check finds broken pointers), capture new exports in that area, leave untouched facts alone. This is the cheap common case. The KB memoizes comprehension so guides and reviews read facts, not code. Ships the skill + sdk-knowledge-author agent. It reports which guides each change affects (via feeds-guides) so the composition step can scope the guide update. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/sdk-knowledge-author.md | 29 +++++++ skills/sdk-knowledge-authoring/SKILL.md | 95 +++++++++++++++++++++ skills/sdk-knowledge-authoring/package.json | 9 ++ 3 files changed, 133 insertions(+) create mode 100644 .claude/agents/sdk-knowledge-author.md create mode 100644 skills/sdk-knowledge-authoring/SKILL.md create mode 100644 skills/sdk-knowledge-authoring/package.json diff --git a/.claude/agents/sdk-knowledge-author.md b/.claude/agents/sdk-knowledge-author.md new file mode 100644 index 000000000..ced9269b4 --- /dev/null +++ b/.claude/agents/sdk-knowledge-author.md @@ -0,0 +1,29 @@ +--- +name: sdk-knowledge-author +description: >- + Derive verified SDK facts from packages source into the internal knowledge base — the comprehension + step of the docs pipeline, and the only step that reads source. Bootstrap a new SDK's KB file, or + incrementally reconcile the base against a source change (re-verify only the facts the diff touches). + Use when packages/**/src changes, a new SDK needs a KB file, or a guide/review escalates a missing + fact. +tools: Read, Grep, Glob, Edit, Write, Bash +--- + +You are the SDK knowledge author. Follow the **`sdk-knowledge-authoring`** skill: read +`packages/**/src` and write what is true into `documentation/internal/sdk-knowledge/`, so the base +memoizes comprehension and everything downstream reads facts instead of re-reading code. Shape and +point facts per the `sdk-knowledge-maintenance` skill. + +Pick your mode from the trigger: + +- **Bootstrap** (new SDK / no KB file yet) — read the exported public surface once, create the KB + file from `_template.md` in the right family dir, set its `feeds-guides` marker, fill sections with + facts + grammar pointers. +- **Incremental** (source changed — the common case) — let the diff bound the work: re-verify only + facts whose `source:` pointer names a changed file (use `pnpm knowledge:check` to find pointers + that no longer resolve), capture genuinely new exports in the changed area, and leave every + untouched fact alone. Do NOT re-comprehend unrelated code. + +Confirm `pnpm knowledge:check` passes before finishing. Report which facts you added/changed/removed +and — via each touched file's `feeds-guides` marker — which guides now need recomposing, so the +pipeline can scope the guide update. You do not write guide prose and you do not review guides. diff --git a/skills/sdk-knowledge-authoring/SKILL.md b/skills/sdk-knowledge-authoring/SKILL.md new file mode 100644 index 000000000..d5f38f0b4 --- /dev/null +++ b/skills/sdk-knowledge-authoring/SKILL.md @@ -0,0 +1,95 @@ +--- +name: sdk-knowledge-authoring +description: >- + Derive verified SDK facts from source into the internal knowledge base under + documentation/internal/sdk-knowledge/ — the comprehension step of the docs pipeline. This is the + one expensive step that reads packages/**/src; the knowledge base memoizes it so guides and reviews + read facts instead of re-comprehending code. Two modes: BOOTSTRAP (read an SDK's whole public + surface into a new KB file, once) and INCREMENTAL (a source change → re-verify only the facts whose + pointers hit the changed files, plus capture new/removed exports in that area). Use when a source + PR touches packages/**/src, when a new SDK needs a KB file, or when a guide/review escalates a + missing fact. Triggers on "update the knowledge base from source", "source changed", "bootstrap KB", + "which facts changed", "capture new exports". Not guide prose (optimization-guide-authoring), not + the format rules (sdk-knowledge-maintenance), not reader review (guide-newcomer-review). +argument-hint: '[SDK to bootstrap, or changed source paths to reconcile]' +paths: packages/**/src/** +--- + +# Authoring SDK knowledge from source + +You perform the **comprehension** step of the docs pipeline: reading `packages/**/src` and writing +what is true into the knowledge base. Think of it as compiling source into an intermediate +representation — the knowledge base is that IR, and it exists so the expensive act of understanding +the code happens **once and is memoized**, not repeated by every guide author and every reviewer. + +``` + SOURCE ───▶ [ you: comprehension ] ───▶ KNOWLEDGE BASE ───▶ guides, reviews (read facts, not code) + packages/src (expensive) (verified facts) (cheap) +``` + +You own the source→KB direction only. You do not write guide prose, you do not own the pointer +grammar (that is `sdk-knowledge-maintenance` — follow it for how a fact is shaped and pointed), and +you do not review guides. You produce and update facts. + +## Two modes — pick by the trigger + +### Bootstrap (a new SDK, or an SDK with no KB file yet) + +Read the SDK's public surface once and record it. This is expensive and happens once per SDK; every +later change is incremental against what you leave behind. + +1. Find the package and its `src/` root; read its `package.json` exports to learn the public entry + points (the reader can only import what is exported — start there, not at internal files). +2. Walk the exported surface: factory/init functions and their config keys, components/hooks and + their props/returns, identifiers (cookies, headers, storage keys, env vars) and who owns them, + events and their semantics, consent/persistence model, runtime quirks, failure/fallback behavior. + The `_template.md` sections are the checklist of what to capture. +3. Copy `_template.md` into the right family dir (making a new sibling like `node/` or `native/` when + the family is new), fill each section with facts + grammar pointers, set the `feeds-guides` marker + to the guide(s) these facts compose into, and mark empty sections `None.` +4. Capture SDK-neutral facts in `shared/` once and link to them; do not restate them per SDK. + +### Incremental (a source change — the common, cheap case) + +The diff bounds the work. Do NOT re-read the whole SDK. + +1. **Scope from the diff.** Take the changed files under `packages/**/src`. The facts at risk are + exactly those whose `source:` pointer names a changed file — `pnpm knowledge:check` resolves every + pointer, so a pointer that now fails to resolve is a renamed/removed symbol you must fix. Grep the + KB for pointers into the changed files to find the rest. +2. **Re-verify only those facts** against the new source, and edit them in place (present tense, no + change-ledger language — see `sdk-knowledge-maintenance`). A behavior that changed gets its fact + rewritten; a symbol that moved gets its pointer re-anchored; a removed export gets its fact deleted. +3. **Capture what is genuinely new in the changed area** — a new exported config key, prop, or event + that a guide would need to mention. Judge newness against the changed surface, not the whole SDK; + do not open a net-new comprehension pass of unrelated code. +4. **Leave everything else untouched.** A fact whose pointer does not touch the diff is still true; + re-verifying it is wasted comprehension, which is the cost this whole design exists to avoid. + +## What a "fact" is (and is not) + +A fact is a verified statement about the current SDK, carrying a grammar pointer into the source that +proves it. It is terse, present-tense, and reader-relevant. It is NOT: guide prose, an example, a +tutorial step, a rationale, or a history of how the code got here. If it names a concrete +symbol/prop/cookie/config key/return shape, it belongs here; if it teaches or persuades, it belongs +in a guide. + +## Handing off + +- When you finish, the affected guides (named by each touched file's `feeds-guides` marker) may need + recomposing. That is the guide-composition step's job, not yours — report which guides your changes + affect so the pipeline can scope the guide update. +- Non-TypeScript SDKs: `#symbol` resolution is checked via the TypeScript compiler, so it applies to + TS-source SDKs (Node, React Native, web). For Swift (iOS) or Kotlin (Android) source, use + file-level `#` pointers (no `#symbol`) and say so — do not invent an unverifiable + symbol anchor. + +## Before you finish + +- Every fact you added or changed has a resolvable grammar pointer; `pnpm knowledge:check` passes. +- Incremental: you touched only facts in the diff's blast radius; you did not re-verify unrelated + facts or open an unscoped comprehension pass. +- Bootstrap: the new file matches `_template.md` heading-for-heading, has a `feeds-guides` marker, and + marks empty sections `None.` +- You reported which guides the change affects (via `feeds-guides`) for the composition step. +- No guide prose leaked into the base; no fact leaked into a skill. diff --git a/skills/sdk-knowledge-authoring/package.json b/skills/sdk-knowledge-authoring/package.json new file mode 100644 index 000000000..79c8b1ceb --- /dev/null +++ b/skills/sdk-knowledge-authoring/package.json @@ -0,0 +1,9 @@ +{ + "name": "@contentful/skill-sdk-knowledge-authoring", + "version": "0.1.0", + "description": "Derive verified SDK facts from packages source into the internal knowledge base", + "license": "MIT", + "files": [ + "SKILL.md" + ] +} From d83760a6f7fdede23ad4e73dddadc763c9ebe543 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:39:41 +0200 Subject: [PATCH 21/42] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(skills):=20?= =?UTF-8?q?Make=20guide=20verification=20a=20KB=20lookup,=20not=20a=20sour?= =?UTF-8?q?ce=20re-derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guide-source-verification re-read packages/**/src to prove each guide claim, which re-comprehends code per guide — the expensive path the knowledge base exists to avoid. Reshape it into a consistency check against the KB: - A claim is confirmed when it matches a KB fact and knowledge:check passes (fast path). - A claim that contradicts a fact is a guide bug → correction to the writer. - A claim with no backing fact is ESCALATED to sdk-knowledge-author (which owns reading source) → the base gains the fact or the claim leaves the guide. The verifier no longer reads source itself (Edit dropped from its tools; it reports, it does not mutate). Bootstrap (SDK with no KB file) still runs after sdk-knowledge-authoring has created the file, so this review is always guide-against-KB, never guide-against-source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-source-verifier.md | 40 +++++------ skills/guide-source-verification/SKILL.md | 88 ++++++++++++----------- 2 files changed, 65 insertions(+), 63 deletions(-) diff --git a/.claude/agents/guide-source-verifier.md b/.claude/agents/guide-source-verifier.md index 265ae0a53..db72c7949 100644 --- a/.claude/agents/guide-source-verifier.md +++ b/.claude/agents/guide-source-verifier.md @@ -1,29 +1,27 @@ --- name: guide-source-verifier description: >- - Verify every load-bearing SDK claim in a documentation guide against packages source, then record - verified facts into the internal knowledge base — the third authoring role. Use after a guide is - drafted and newcomer-reviewed, before it ships, or to fact-check a specific claim. -tools: Read, Grep, Glob, Edit, Bash + Verify every load-bearing SDK claim in a documentation guide traces to a verified fact in the + internal knowledge base — the third authoring role. A cheap consistency lookup, not a re-derivation + from source: a claim with no backing fact is escalated to the sdk-knowledge-author, not re-verified + here. Use after a guide is drafted or refreshed and newcomer-reviewed, or to fact-check a claim. +tools: Read, Grep, Glob, Bash --- You are the technical-foundation reviewer for Optimization SDK guides. Follow the -**`guide-source-verification`** skill: prove every load-bearing claim (hook, prop, config key, -factory field, context field, return shape, cookie, event name, behavioral assertion) true against -`packages/**/src`, with `file:symbol` evidence. +**`guide-source-verification`** skill: confirm every load-bearing claim (hook, prop, config key, +factory field, context field, return shape, cookie, event name, behavioral assertion) traces to a +verified fact in the knowledge base (`documentation/internal/sdk-knowledge/`). -Check the internal knowledge base (`documentation/internal/sdk-knowledge/`) first and rely on facts -it already holds whose pointers resolve; verify the rest against source, reading implementation for -behavior, not just names. Record each newly verified fact into the knowledge base using the pointer -grammar (following `sdk-knowledge-maintenance`), and confirm `pnpm knowledge:check` passes before you -finish. Return a per-claim verdict (confirmed/wrong/imprecise/unverifiable) with evidence and the KB -action taken; hand wrong/imprecise claims to the writer with what the source actually does. Do not -rewrite the guide. +In steady state this is a lookup, not source archaeology. The knowledge base already holds +source-verified facts, each with a resolvable pointer, so a claim is **confirmed** when it matches a +fact and `pnpm knowledge:check` passes. A claim that **contradicts** a fact is a guide bug — the base +is the authority; hand the correction to the writer. A claim with **no backing fact** is escalated to +the **`sdk-knowledge-author`** (which owns reading `packages/**/src`) — do NOT re-derive it from +source yourself; either the base is missing a fact it should hold, or the claim is unfounded and comes +out of the guide. -When the SDK you are verifying has **no knowledge-base file yet** (e.g. a first Node, React Native, -or native guide), create it: copy `_template.md` into the right family directory — making a new -sibling family dir like `node/` or `native/` when the family does not exist yet — and fill its -sections with the facts you verify. A new guide grows the base. Note: the pointer grammar's -`#symbol` resolution is checked via the TypeScript compiler, so it applies to TS-source SDKs (Node, -React Native). For SDKs whose source is Swift or Kotlin, the `#symbol` check does not apply yet; -raise it rather than inventing an unverifiable anchor. +You do not read source to verify (the knowledge author does that), you do not edit the knowledge base +or the guide. Return a per-claim verdict (confirmed / contradicts-KB / no-backing-fact) with the +backing fact as evidence, guide corrections routed to the writer, and fact gaps routed to the +knowledge author. diff --git a/skills/guide-source-verification/SKILL.md b/skills/guide-source-verification/SKILL.md index 017c55520..8eb2d6ebf 100644 --- a/skills/guide-source-verification/SKILL.md +++ b/skills/guide-source-verification/SKILL.md @@ -1,71 +1,75 @@ --- name: guide-source-verification description: >- - Verify that every load-bearing SDK claim in a documentation guide is true against the SDK source - under packages/**/src — the technical-foundation review role. Confirms each hook, prop, config key, - context field, return shape, cookie, and event name a guide asserts actually exists and behaves as - described, with file:symbol evidence, then records what it verified into the internal knowledge base - so the next guide reuses it. Use as the third authoring role (writer → newcomer reviewer → - technical-foundation reviewer), after a guide is drafted or rewritten, or to fact-check a specific - claim. Triggers on "technical review", "verify against source", "fact-check the guide", "is this API - real", "foundation review". Not reader-experience review (guide-newcomer-review) and not prose + Verify that every load-bearing SDK claim in a documentation guide traces to a verified fact in the + internal knowledge base — the technical-foundation review role. In steady state this is a cheap + lookup against documentation/internal/sdk-knowledge/, NOT a re-derivation from source: the knowledge + base already holds source-verified facts, so a guide claim is trustworthy when it matches one. A + claim with no backing fact is escalated to sdk-knowledge-authoring (which owns reading source), not + re-verified here. Use as the third authoring role (writer → newcomer reviewer → technical-foundation + reviewer), after a guide is drafted or refreshed, or to fact-check a specific claim. Triggers on + "technical review", "verify against the knowledge base", "fact-check the guide", "does this trace to + a fact", "foundation review". Not reader-experience review (guide-newcomer-review) and not prose authoring (optimization-guide-authoring). argument-hint: '[guide file or claim to verify]' paths: documentation/guides/** --- -# Verifying a guide against source +# Verifying a guide against the knowledge base -Every claim a guide makes about the SDK must be true against `packages/**/src`. Your job is to prove -it — API by API — and to leave behind verified facts the next guide can reuse instead of re-deriving. -You are the last line before a guide ships: a plausible-looking name is not proof it exists, and the -reference implementation proving one path works can still hide nuance. +Every load-bearing claim a guide makes about the SDK must trace to a verified fact in the knowledge +base (`documentation/internal/sdk-knowledge/`). That base already holds facts checked against source, +each with a resolvable pointer — so your job in steady state is a **consistency lookup**, not a +re-derivation. You confirm the guide says what the base says. You do not re-read `packages/**/src` +yourself; comprehension is the knowledge author's job, and re-doing it here is exactly the wasted work +the knowledge base exists to prevent. ## Method 1. **List the load-bearing claims.** Every hook, component, prop, config key, factory field, context - field, return shape, cookie/identifier, event name, and behavioral assertion ("denied consent - falls back to baseline", "server rendering forces dynamic") the guide states or shows in a snippet. -2. **Check the knowledge base first.** `documentation/internal/sdk-knowledge/` records facts already - verified against source, each with a resolvable `source:` pointer. If a claim is already recorded - and its pointer still resolves (`pnpm knowledge:check` passes), you can rely on it — do not - re-grep. This is the point of the base: verification is not repeated. -3. **Verify the rest against source.** For each remaining claim, find the symbol in `packages/**/src` - and confirm it exists and behaves as the guide says. Grep the export/type; read the implementation - for behavior, not just the name. The matching reference implementation under `implementations/` - shows one working path but can hide a factory field it does not use or a provider a bound component - renders internally — the source is the authority. -4. **Resolve source-vs-impl disagreements toward source.** When the reference impl and the source - seem to disagree, read the source and reconcile; the guide must match reality, and note the nuance. -5. **Record what you newly verified into the knowledge base.** As a byproduct — not a separate - project — add each newly confirmed fact to the right per-SDK file (or `shared/`) using the pointer - grammar, following the `sdk-knowledge-maintenance` skill. This is what closes the loop: the fact - you verified for this guide is now reusable, and `pnpm knowledge:check` guards it against drift. + field, return shape, cookie/identifier, event name, and behavioral assertion the guide states or + shows in a snippet. +2. **Trace each to a KB fact.** Find the fact in the relevant per-SDK file (or `shared/`) that backs + the claim. A claim is **confirmed** when the base holds a matching fact and `pnpm knowledge:check` + passes (its pointer resolves, so it is current). This is the fast path and should cover almost + everything for an SDK whose KB file exists. +3. **Compare wording against the fact.** If the guide asserts something narrower, broader, or simply + different from the fact (a prop that does not exist in the fact, a return shape stated wrong, a + behavior the fact contradicts), that is a **guide bug** — the base is the authority. Flag it for + the writer with the correct fact. +4. **Escalate a claim with no backing fact.** If a load-bearing claim has no corresponding KB fact, + do NOT verify it against source yourself. Escalate to **`sdk-knowledge-authoring`**: either the + base is missing a fact it should hold (the author adds it from source), or the claim is unfounded + (the author confirms nothing backs it, and it comes out of the guide). Record the escalation as a + finding; the guide is not "verified" until the fact exists or the claim is removed. + +The one time you legitimately drive source comprehension is a **bootstrap** guide whose SDK has no KB +file yet — there is nothing to look up. In that case this review runs after `sdk-knowledge-authoring` +has created the KB file, so you are still checking guide-against-KB, not guide-against-source. ## How to report Return a verdict per load-bearing claim: - **Claim** — the exact assertion, with the guide location. -- **Verdict** — confirmed / wrong / imprecise / unverifiable. -- **Evidence** — `file:symbol` in `packages/**/src` (or the KB entry you relied on). For a wrong or - imprecise claim, state what the source actually does. -- **KB action** — recorded new fact `X` in `web/.md` (with pointer) / already held / n/a. +- **Verdict** — confirmed (matches a KB fact) / contradicts-KB / no-backing-fact. +- **Evidence** — the KB fact and file that backs it (or "none found"). +- **Action** — for contradicts-KB: the correction, handed to the writer. For no-backing-fact: an + escalation to `sdk-knowledge-authoring` naming the claim. -Flag wrong and imprecise claims to the writer to fix in the guide. Do not rewrite the guide yourself -— hand corrections back to `optimization-guide-authoring`. +Hand guide corrections to the writer (`optimization-guide-authoring`); hand fact gaps to the +knowledge author. Do not rewrite the guide and do not edit the knowledge base yourself. ## Before you finish -- Every load-bearing claim has a verdict backed by `file:symbol` evidence or a KB entry. -- Every fact you newly verified is recorded in the knowledge base with a grammar pointer, and - `pnpm knowledge:check` passes. -- Wrong/imprecise claims are handed to the writer with what the source actually does. +- Every load-bearing claim is confirmed against a KB fact, corrected against one, or escalated. +- No claim was silently re-verified against source here — gaps went to `sdk-knowledge-authoring`. +- `pnpm knowledge:check` passes (so every fact you relied on is current). ## Not in scope +- **Reading source to derive facts** — that is `sdk-knowledge-authoring`. Escalate, don't re-derive. - **Reader-experience review** (undefined jargon, skim mode, performable steps) — that is `guide-newcomer-review`. - **Prose authoring and structure** — that is `optimization-guide-authoring`. -- **Maintaining the knowledge base's format rules** — that is `sdk-knowledge-maintenance`; this skill - uses it, and records facts, but does not own the grammar. +- **The knowledge base's format rules** — that is `sdk-knowledge-maintenance`. From cafaa8a131f566c556f44aab2154921b485c9c08 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:46:43 +0200 Subject: [PATCH 22/42] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(skills):=20?= =?UTF-8?q?Make=20guide=20composition=20KB-first,=20escalate=20for=20missi?= =?UTF-8?q?ng=20facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writer's step 5 and checklist told authors to verify every API "against the SDK source" — re-grepping packages/**/src per guide. Reshape composition to read the knowledge base as the source of truth: - Every SDK claim in a guide must trace to a verified fact in the KB; compose from facts, do not re-grep source. The reference impl is for shape/"adapt" starting points, not truth. - A missing fact is escalated to sdk-knowledge-authoring (which owns reading source), then composed from the fact it adds — comprehension stays in one place. - Updated the authoring skill step 5, the authoring-checklist source-verification item, and the guide-writer agent to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-writer.md | 10 +++++--- skills/optimization-guide-authoring/SKILL.md | 24 +++++++++---------- .../references/authoring-checklist.md | 14 ++++++----- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md index 3e798431c..92575ab3b 100644 --- a/.claude/agents/guide-writer.md +++ b/.claude/agents/guide-writer.md @@ -10,9 +10,13 @@ tools: Read, Edit, Write, Grep, Glob, Bash You are the docs writer for the Optimization SDK Suite. Author or revise the requested guide under `documentation/guides/` following the **`optimization-guide-authoring`** skill — its archetypes, teach-first quick-start-then-deepen structure, copy-vs-adapt example labels, `## Before you start` -block, and self-review checklist are your source of truth. Ground every example in the matching -reference implementation under `implementations/`, and check the internal knowledge base -(`documentation/internal/sdk-knowledge/`) for facts already verified against source before re-grepping. +block, and self-review checklist are your source of truth. + +Compose every SDK claim from the internal knowledge base (`documentation/internal/sdk-knowledge/`), +which holds facts already verified against source — read facts, do not re-grep `packages/**/src`. Use +the matching reference implementation under `implementations/` for real-shaped patterns and "adapt" +starting points. If the base is missing a fact you need, escalate to `sdk-knowledge-authoring` to add +it from source, then compose from that fact; do not verify source yourself. You handle two jobs: diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index e7f548979..9ffe33f44 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -148,18 +148,18 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. 4. **Fill feature sections in order**, each with its integration-category line and numbered procedure. Cover the shared concern checklist or mark concerns not-applicable (see [references/authoring-checklist.md](references/authoring-checklist.md)). -5. **Ground every example in reality, then verify it against source.** Prefer patterns and values - from the matching reference implementation under `implementations/`, and for "adapt" snippets - model the reader's real starting shape so they can locate where SDK code slots in. Then confirm - every API used — hook, prop, config key, context field, return shape — exists in the SDK source - under `packages/**/src`, not only in the reference impl. The reference impl proves one path works - but can hide nuance (a factory field it does not use, a provider the bound component renders - internally), and a plausible-looking name is not proof it exists. - **Check the internal knowledge base first** (`documentation/internal/sdk-knowledge/`): it records - SDK facts already verified against source, each with a resolvable pointer, so you reuse them - instead of re-grepping. When you verify a new fact the base does not yet hold, record it there - (with its grammar pointer) as a byproduct — see the `sdk-knowledge-maintenance` skill. That is how - the next guide avoids re-deriving what this one established. +5. **Compose every SDK claim from the knowledge base, not from source.** The internal knowledge base + (`documentation/internal/sdk-knowledge/`) is the source of truth for what the SDK does — facts + already verified against `packages/**/src`, each with a resolvable pointer. Every API you use — a + hook, prop, config key, context field, return shape — must come from a fact there; read the base, + do not re-grep source. Use the matching reference implementation under `implementations/` for + real-shaped patterns and "adapt" starting points, but the base, not the impl, is what makes a claim + true (the impl proves one path works and can hide nuance). **If the base is missing a fact you + need, do not verify it from source yourself — escalate to `sdk-knowledge-authoring`**, which reads + source and records the fact; then compose from the fact it adds. That keeps comprehension in one + place and means the next guide reuses what this one needed. (When you author a guide for an SDK + whose KB file does not exist yet, that whole file is bootstrapped by `sdk-knowledge-authoring` + first — see the workflow command.) 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. 7. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 49033b803..13537c7d4 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -25,12 +25,14 @@ add per-archetype checks. endpoints, and paths match reality, or a NOTE explains the discrepancy. - [ ] No placeholder-only snippet is labeled `**Copy this:**` when it actually needs relocation or structural change (that is an adapt). -- [ ] **Every API in a code example is verified against the SDK source, not just the reference - implementation.** Confirm each hook, prop, config key, context field, and return shape exists - in `packages/**/src` (grep the type/export). The reference impl shows one working path but can - hide nuance (a factory field the impl does not use, a provider the bound component renders - internally); a plausible-looking field name such as `isEnabled` or `hasResults` is not proof - it exists. When source and reference impl seem to disagree, read the source and reconcile. +- [ ] **Every API in a code example traces to a fact in the knowledge base.** Each hook, prop, + config key, context field, and return shape must be backed by a verified fact in + `documentation/internal/sdk-knowledge/` (which holds facts already checked against + `packages/**/src`). Compose from the base, do not re-grep source; a plausible-looking field + name such as `isEnabled` or `hasResults` is not proof it exists. If a needed fact is missing, + escalate to `sdk-knowledge-authoring` to add it from source, then compose from that fact — do + not verify from source in the guide. The reference impl shows one working path for shape and + "adapt" starting points, but the base, not the impl, is what makes a claim true. - [ ] Code comments explain only meaningful SDK-specific lines; no obvious-syntax narration. - [ ] **Every magic-value identifier states who owns it.** For each cookie name, env var, header, storage key, or config string in a snippet, the guide states whether the reader must match the From c1fb3343e21a0845937c9977812aaceb262c6b75 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 12:48:50 +0200 Subject: [PATCH 23/42] =?UTF-8?q?=E2=9C=A8=20feat(commands):=20Reframe=20d?= =?UTF-8?q?ocs=20commands=20around=20the=20full=20SDLC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the two lifecycle modes into distinct commands so the cheap path is the default: - /author-guide is now the BOOTSTRAP path only — comprehend source into a new KB file (sdk-knowledge-author) FIRST, then compose the guide from those facts (guide-writer), then review. Knowledge first, guide second. Expensive, once per SDK. - /refresh-docs is the new INCREMENTAL path — the steady-state default. A source diff scopes the work: deterministically find affected KB facts (source: pointers into changed files) and the guides that consume them (feeds-guides), reconcile only those facts (sdk-knowledge-author incremental), recompose only the dependent guide passages, review only what changed. Never re-comprehends an SDK or rewrites whole guides. - /review-guide updated: the verifier does a KB lookup (confirmed/contradicts-KB/ no-backing-fact) and escalates fact gaps to sdk-knowledge-author instead of re-deriving from source or recording facts itself. This makes the knowledge base a compiler IR: comprehension is memoized once, and small source changes cost a scoped reconcile + recompose, not an 800k-token rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/author-guide.md | 95 +++++++++++++++----------------- .claude/commands/refresh-docs.md | 63 +++++++++++++++++++++ .claude/commands/review-guide.md | 21 ++++--- 3 files changed, 119 insertions(+), 60 deletions(-) create mode 100644 .claude/commands/refresh-docs.md diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md index b3dc04ff6..204c8677d 100644 --- a/.claude/commands/author-guide.md +++ b/.claude/commands/author-guide.md @@ -1,72 +1,65 @@ --- -description: Author a new SDK guide or refresh an existing one end-to-end — writer, then newcomer + source-verification review, then funnel learnings back -argument-hint: '[SDK/runtime for a new guide, or an existing guide path to refresh]' +description: Bootstrap docs for an SDK from scratch — comprehend source into the knowledge base, compose the guide from it, then review +argument-hint: '[SDK/runtime to document, or a guide path to (re)author from scratch]' --- -Drive the full guide lifecycle for: `$ARGUMENTS` (if empty, ask which SDK/runtime to document or -which guide to refresh). +Bootstrap the full docs for: `$ARGUMENTS` (if empty, ask which SDK/runtime to document). -This is one workflow with two entry points. First **classify the target**, then run the matching -path. Both paths end in the same review loop, so quality is identical whether a guide is new or -refreshed. +This is the **from-scratch path**: an SDK with no knowledge-base file yet, or a guide that must be +(re)authored from the ground up. It runs the expensive comprehension step once — reading source into +the knowledge base — then composes the guide from that base. For an SDK whose KB file already exists +and whose source merely changed, use **`/refresh-docs`** instead (it is far cheaper — it does not +re-comprehend the whole SDK). -## 0. Classify: new vs. refresh +The order matters: **knowledge first, guide second.** The guide is composed from verified facts, so +the facts must exist before the prose. -- If `$ARGUMENTS` names an existing file under `documentation/guides/`, or an SDK that already has a - guide there → **refresh**. -- If it names an SDK/runtime with no guide yet → **new**. -- If ambiguous, list the guides in `documentation/guides/` and ask. +## 1. Comprehend source → knowledge base (sdk-knowledge-author) -State which path you're taking and the target guide path before proceeding. +Launch the `sdk-knowledge-author` agent in **bootstrap** mode for the SDK. It reads the SDK's +exported public surface under `packages/**/src`, creates the KB file from `_template.md` in the right +family dir (making a new sibling like `node/` or `native/` if needed), sets its `feeds-guides` marker +to the target guide, and fills every section with facts + grammar pointers. This is the one step that +reads source. It returns when `pnpm knowledge:check` passes for the new file. -## 1. Draft (guide-writer) +## 2. Compose the guide from the knowledge base (guide-writer) -Launch the `guide-writer` agent with the target and the path (new/refresh). +Launch the `guide-writer` agent for the target guide. It composes from the KB facts created in step 1 +(reading facts, not re-grepping source) following the `optimization-guide-authoring` skill — +archetype, quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in the +matching reference implementation under `implementations/` for shape. If it finds it needs a fact the +base does not hold, it escalates back to `sdk-knowledge-author` rather than reading source itself. -- **New:** it drafts the guide from the `optimization-guide-authoring` templates, grounded in the - matching reference implementation under `implementations/` and reusing verified facts from the - knowledge base (`documentation/internal/sdk-knowledge/`). -- **Refresh:** it first diffs the guide against the current authoring skill — the fastest tells are a - missing `## Quick start` or `## Before you start`, a monolithic flow section, numbered headings, or - missing `**Copy this:** / **Adapt this to your use case:**` labels — then brings the guide up to the - current archetype and structure, preserving correct content. +## 3. Review loop (delegate to /review-guide) -The writer returns the edited guide path and a summary of what it changed. +Run the `review-guide` command on the guide. Two independent reviews run concurrently: -## 2. Review loop (delegate to /review-guide) +- **guide-newcomer-reviewer** — reads it cold as an average developer; reports undefined jargon, + skim-mode, unperformable steps, dishonest labels. +- **guide-source-verifier** — checks every load-bearing claim traces to a KB fact (a lookup, not a + source re-derivation). A claim with no backing fact is escalated to `sdk-knowledge-author`. -Run the `review-guide` command on the target guide. It fans out the two independent reviews -concurrently and consolidates: +## 4. Gate before finishing -- **guide-newcomer-reviewer** — reads the guide cold as an average developer; reports undefined - jargon, skim-mode, unperformable steps, dishonest labels. -- **guide-source-verifier** — proves every load-bearing SDK claim against source, reuses KB facts, - and records newly verified facts back into the knowledge base. For an SDK whose KB family file does - not exist yet, it CREATES it from `_template.md` as it verifies (following the - `sdk-knowledge-maintenance` skill) — a new guide grows the KB. +Do not call it done until all hold; loop back to the responsible step for anything unmet: -## 3. Gate before finishing - -Do not call the guide done until all of these hold; loop back to step 1 (writer) for anything unmet: - -- Every newcomer **blocker** is fixed; friction items are fixed or consciously accepted with a reason. -- Every source-verifier **wrong/imprecise** claim is corrected against what the source actually does. -- `pnpm knowledge:check` passes (new/updated KB facts resolve). +- Every newcomer **blocker** is fixed; friction items fixed or consciously accepted with a reason. +- Every verifier **contradicts-KB** claim is corrected; every **no-backing-fact** claim is resolved + (fact added by the knowledge author, or claim removed). +- `pnpm knowledge:check` passes. - `pnpm format:fix` leaves the guide clean and its TOC anchors resolve. -## 4. Funnel learnings back (the loop that improves the system) +## 5. Funnel learnings back -For each finding that reflects a durable rule rather than a one-off, route it to the right artifact — -never leave it as a persisted TODO: +Route each durable finding to the right artifact — never leave a persisted TODO: -- a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles only, - never SDK facts); -- a newly verified SDK fact → the knowledge base via `sdk-knowledge-maintenance`; -- a cross-guide consistency issue → **fix it now** in the affected guides and, if shared wording was - missing, add it once to `shared/` (do not record it as drift to reconcile later). +- a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles only); +- a fact → the knowledge base (via `sdk-knowledge-author` / `sdk-knowledge-maintenance`); +- a cross-guide consistency issue → **fix it now** in the affected guides; if shared wording was + missing, add it once to `shared/`. -## 5. Report +## 6. Report -Summarize: new vs. refresh, what the writer changed, the findings from each reviewer and how each was -resolved, what was funneled into the skill vs. the KB, and the validation result. Note anything -consciously deferred and why. +Summarize: the KB file created and its facts, what the guide covers, each reviewer's findings and how +they resolved, what was funneled into the skill vs. the KB, and the validation result. Note anything +consciously deferred (e.g. Swift/Kotlin `#symbol` resolution for native SDKs). diff --git a/.claude/commands/refresh-docs.md b/.claude/commands/refresh-docs.md new file mode 100644 index 000000000..3e79afc36 --- /dev/null +++ b/.claude/commands/refresh-docs.md @@ -0,0 +1,63 @@ +--- +description: Incrementally update docs after a source change — reconcile only the affected knowledge-base facts, then recompose only the guides that consume them +argument-hint: '[changed source paths, a base git ref to diff, or empty to use the working-tree diff]' +--- + +Incrementally refresh the docs for a source change: `$ARGUMENTS` (if empty, use the current diff +against `main` — `git diff --name-only main...HEAD` plus unstaged changes under `packages/**/src`). + +This is the **steady-state path**, and it is deliberately cheap. It does NOT re-comprehend an SDK or +recompose whole guides. The diff bounds the work: only the knowledge-base facts the change touches +get re-verified, and only the guides that consume those facts get recomposed. If a change turns out +to need a brand-new KB file (a new SDK, or a guide that was never authored the current way), stop and +use **`/author-guide`** instead — that is the bootstrap path. + +## 1. Scope from the diff (deterministic — no agent) + +Determine the changed files under `packages/**/src`. Then find what depends on them, in both +directions of the graph: + +- **Affected KB facts** — facts whose `source:` pointer names a changed file. `pnpm knowledge:check` + resolves every pointer, so run it first: any pointer that now fails to resolve is a + renamed/removed/moved symbol the change broke. Also grep the KB for pointers into the changed files + to catch facts whose behavior changed even though the symbol still resolves. +- **Affected guides** — the `feeds-guides` marker of each KB file that holds an affected fact names + the guide(s) that consume it. That is the recompose set. + +State the changed files, the affected facts, and the affected guides before proceeding. If nothing is +affected (the change touched no documented surface), say so and stop — there is nothing to refresh. + +## 2. Reconcile the knowledge base (sdk-knowledge-author, incremental mode) + +Launch the `sdk-knowledge-author` agent in **incremental** mode, scoped to the changed files. It +re-verifies only the affected facts against the new source, edits them in place (present tense, no +change-ledger language), captures genuinely new exports in the changed area, and deletes facts for +removed exports. It leaves untouched facts alone. It returns when `pnpm knowledge:check` passes and +reports which facts changed and — via `feeds-guides` — which guides that affects. + +## 3. Recompose only the affected guides (guide-writer) + +For each affected guide, launch `guide-writer` to update **only the passages that depend on a changed +fact**, composing from the reconciled KB (not re-reading source, not rewriting the whole guide). +A fact that changed shape/behavior means the snippet or sentence that used it changes; an unaffected +section is left as-is. + +## 4. Review only what changed (delegate to /review-guide, scoped) + +Run `review-guide` on each recomposed guide, but focus the reviewers on the changed passages: + +- **guide-source-verifier** — confirm the changed claims now trace to the reconciled KB facts. +- **guide-newcomer-reviewer** — confirm the changed passages still read cleanly for a newcomer (a + changed snippet can reintroduce undefined jargon or a broken step). + +## 5. Gate + +- `pnpm knowledge:check` passes. +- Every changed claim traces to a current KB fact; no newcomer blocker in the changed passages. +- `pnpm format:fix` leaves the touched guides clean; TOC anchors still resolve. + +## 6. Report + +Summarize: the changed source, the facts reconciled (added/changed/removed), the guides recomposed +and which passages, the review outcome, and validation. Explicitly list any documented surface you +concluded was NOT affected, so the reader can trust the scoping was deliberate, not missed. diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md index d6fade1a8..040f20db1 100644 --- a/.claude/commands/review-guide.md +++ b/.claude/commands/review-guide.md @@ -12,24 +12,27 @@ Do this in order: cold as an average developer and returns reader-experience findings (undefined jargon, skim-mode, unperformable steps, dishonest labels), each with severity. -2. **Technical-foundation review.** Launch the `guide-source-verifier` agent on the guide. It proves - every load-bearing SDK claim against `packages/**/src` with `file:symbol` evidence, relies on the - internal knowledge base for facts already verified, and records newly verified facts back into the - KB. It returns a per-claim verdict. +2. **Technical-foundation review.** Launch the `guide-source-verifier` agent on the guide. It checks + that every load-bearing SDK claim traces to a verified fact in the knowledge base (a lookup, not a + source re-derivation), and returns a per-claim verdict: confirmed / contradicts-KB / no-backing-fact. + A claim with no backing fact is escalated to the `sdk-knowledge-author` (which owns reading source), + not verified against source here. - Run these two reviews concurrently — they are independent (one reads for the reader, one reads the - source). + Run these two reviews concurrently — they are independent (one reads for the reader, one checks the + guide against the knowledge base). 3. **Consolidate and fix.** Collect both agents' findings. Apply the guide fixes via the `guide-writer` agent (or directly, following `optimization-guide-authoring`): reader-experience - fixes from the newcomer pass, and correctness fixes for every claim the verifier marked wrong or - imprecise (using what the source actually does). + fixes from the newcomer pass, and corrections for every claim the verifier marked contradicts-KB + (using the KB fact as the authority). For each **no-backing-fact** claim, resolve it: launch + `sdk-knowledge-author` to add the fact from source if the base should hold it, then recompose the + claim from that fact — or remove the claim if nothing backs it. 4. **Funnel learnings back.** For each finding that reflects a durable rule — not a one-off — fold it into the right artifact: - a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles only, never SDK facts), - - a newly verified SDK fact → the internal knowledge base via `sdk-knowledge-maintenance`. + - a missing or corrected SDK fact → the knowledge base via `sdk-knowledge-author`. 5. **Validate.** Run `pnpm knowledge:check` (KB must pass) and `pnpm format:fix` on the touched files, and confirm the guide's TOC anchors resolve. From 2c3060854f667a90ada8aeccd136dc53f959065b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 14:07:05 +0200 Subject: [PATCH 24/42] =?UTF-8?q?=F0=9F=93=9D=20docs(node):=20Refresh=20No?= =?UTF-8?q?de=20guide=20to=20current=20archetype=20+=20bootstrap=20node/?= =?UTF-8?q?=20KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run of the docs workflow (/author-guide) on a real target. Refreshes the Node integration guide from the pre-current structure (a `## Required setup` inventory table, change-narration prose) to the current archetype: plain-language five-sentence intro defining variant/experience/Experience API/resolving/baseline fallback/profile, two-milestone framing, `## Before you start` prerequisites with the authored-variant gotcha, a performable verify step, and a source-grounded `## Troubleshooting`. Bootstraps the first Node knowledge-base file (documentation/internal/sdk-knowledge/node/node.md) from _template.md: the stateless ContentfulOptimization + forRequest()→CoreStatelessRequest model, default ['identify','page'] allow-list, consent/canPersistProfile axes, trackView profile contract, locale precedence, request-scoped options, and the reader-owned ctfl-opt-aid cookie — each with a resolvable source pointer, feeds-guides set to this guide. Review: all 41 load-bearing claims traced to source; 2 newcomer blockers + 15 friction items fixed. knowledge:check and format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrating-the-node-sdk-in-a-node-app.md | 273 +++++++++++++----- .../internal/sdk-knowledge/node/node.md | 186 ++++++++++++ 2 files changed, 385 insertions(+), 74 deletions(-) create mode 100644 documentation/internal/sdk-knowledge/node/node.md diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index 776b56879..0238f58a7 100644 --- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md @@ -1,17 +1,65 @@ # Integrating the Optimization Node SDK in a Node app -Use this guide when you want to implement server-side personalization in a Node runtime such as -Express, a custom SSR server, or a server-side function using `@contentful/optimization-node`. - -The examples below use Express, but the same request-scoped flow applies to any Node request -handler. +Use this guide to add Contentful personalization to a Node server you already have — an Express app, +a custom SSR server, or a server-side function — using `@contentful/optimization-node`. By the end +of the quick start, one route will emit a page event and report the profile the Experience API +returns for that request, without changing how your server fetches or renders content. + +**New to personalization?** Here is the whole idea in five sentences: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- On each request, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state the + SDK uses to keep the same visitor's personalization consistent across requests. +- Your server fetches Contentful entries and turns them into a response, and the SDK sits at that + hand-off: it gives you the resolved variant instead of the original — or the original entry when + no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it to the + SDK, or give the SDK your Contentful client and let it fetch by ID; either way the client stays + yours. +- You render whatever the SDK hands back exactly as you render entries today. + +That is enough to start. You do not need audiences, traffic allocation, or interaction events yet; +this guide introduces each idea at the point you need it. + +You will get there in two milestones: + +- **Milestone 1 — an accepted page event and a profile for the request (the quick start below).** A + page event is _accepted_ when your consent policy allows it and the Experience API responds; the + response carries the profile and the request's variant selections, and the route reports the + profile ID. This is complete and shippable on its own. +- **Milestone 2 — a resolved entry in the response (later).** You fetch an entry, resolve it against + the request's selections, and render the returned variant or baseline. See + [Fetch and resolve Contentful entries](#fetch-and-resolve-contentful-entries). + +This guide uses the `ContentfulOptimization` class from `@contentful/optimization-node`. You create +one instance for the whole process, then bind each incoming request to its own consent, locale, and +page context with `forRequest()`. Your app keeps ownership of its Contentful client, consent policy, +sessions, cookies, identity, routing, caching, and rendering — the Node SDK holds no per-visitor +state between requests. + +The examples use Express, but the same request-scoped flow applies to any Node request handler. If +you also want the browser to continue personalization after the server renders, see +[Share continuity with the Web SDK](#share-continuity-with-the-web-sdk). For a browser-only app, use +the [Web SDK guide](./integrating-the-web-sdk-in-a-web-app.md) instead. ## Quick start -Install the Node SDK and Express, create one process-level SDK instance, bind request-scoped consent -and page context with `forRequest()`, call `page()` from a route, and start a local server. This -quick start assumes your application policy permits Optimization by default and no end-user consent -UI is rendered. +Most Node + Contentful apps share one shape: a request handler builds a response and, somewhere in +it, an entry becomes rendered output. This quick start assumes that shape and proves the smallest +server-side result: **one route reports an accepted page event and the profile the Experience API +returned for that request.** It creates one process-level SDK instance, binds request-scoped consent +and page context with `forRequest()`, and calls `page()` from a route. + +This quick start assumes your application policy permits Optimization by default and renders no +end-user consent UI. Consent has two independent parts you bind per request: `events` (may the SDK +send events and personalize this request) and `persistence` (may your app store the profile ID so +the same visitor stays consistent). The shorthand `consent: true` sets both to `true`; the object +form `{ events, persistence }` sets them separately. If personalization must wait for a consent +decision, keep this structure and add the [Apply consent policy](#apply-consent-policy) step before +you ship. **Copy this:** @@ -103,13 +151,18 @@ In another terminal, verify the route. curl http://localhost:3000/ ``` -The JSON response contains a `profileId` when `page()` is accepted. +The JSON response contains a `profileId` when `page()` is accepted — the Experience API evaluated +the request and returned a profile. An accepted event does not by itself prove a variant was chosen: +until you author a variant attached to an experience (see [Before you start](#before-you-start)), +every request resolves to the baseline. To see a variant later, author an experience that targets +all visitors so every request matches it automatically, then resolve an entry as shown in +[Fetch and resolve Contentful entries](#fetch-and-resolve-contentful-entries).
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [Install and initialize the Node SDK](#install-and-initialize-the-node-sdk) - [Bind request context and locale](#bind-request-context-and-locale) @@ -128,42 +181,45 @@ The JSON response contains a `profileId` when `page()` is accepted. - [Control pre-consent event admission and request options](#control-pre-consent-event-admission-and-request-options) - [Keep caches safe for personalized rendering](#keep-caches-safe-for-personalized-rendering) - [Production checks](#production-checks) +- [Troubleshooting](#troubleshooting) - [Reference implementations to compare against](#reference-implementations-to-compare-against)
-## Required setup - -Use this setup inventory before you move beyond the quick start: - -| Setup item | Category | Required for quick start | Where to configure | -| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------ | -| `@contentful/optimization-node` package | Required for first integration | Yes | Node app package dependencies | -| Express package for the quick-start route | Required for first integration | Yes | Quick-start app dependencies, or your equivalent Node request framework | -| Optimization client ID | Required for first integration | Yes | `ContentfulOptimization({ clientId })` from environment configuration | -| Optimization environment and API endpoints | Required for first integration | Conditional | `environment` and `api` SDK options when not using defaults or local mocks | -| Application Contentful delivery client | Required for first integration | Conditional | App-owned `contentful.js`, REST, or GraphQL client; SDK-managed fetching can use `contentful.js` | -| Single-locale Contentful entry payloads with optimization links | Required for first integration | Conditional | CDA request options such as `locale: appLocale` and `include` depth | -| Request route or handler integration | Required for first integration | Yes | Express routes, server functions, or custom Node request handlers | -| Request event context | Required for first integration | Yes | `forRequest({ eventContext })` per incoming request | -| Application locale decision | Required for first integration | Yes | Router, i18n layer, request policy, CDA requests, and `forRequest({ locale })` | -| Consent policy | Common but policy-dependent | Yes | Application policy, consent cookie, CMP callback, session, or preference store | -| Profile ID persistence | Common but policy-dependent | Conditional | Application session or first-party cookie such as `ANONYMOUS_ID_COOKIE` | -| Known-user identity source | Common but policy-dependent | No | Authentication middleware, session, JWT, or account service used before `identify()` | -| Rich Text merge-tag renderer | Optional | No | Application Rich Text rendering pipeline | -| Custom Flag reads | Optional | No | Server render logic that consumes `getFlag()` | -| Server-side interaction or business event tracking | Optional | No | App-owned event collector, route action, or rendered-entry exposure path | -| Third-party analytics forwarding | Optional | No | Server-side analytics, customer-data, or tag-management integration | -| `@contentful/optimization-web` package and continuity | Optional | No | Browser package dependencies, shared anonymous-ID cookie, and browser SDK initialization | -| Strict pre-consent allowlist and request options | Advanced or production-only | No | SDK `allowedEventTypes`, `experienceOptions`, `insightsOptions`, and `onEventBlocked` | -| Personalized response caching policy | Advanced or production-only | No | Application cache keys, CDN rules, and render cache boundaries | - -The Node SDK is stateless. It does not manage cookies, sessions, consent state, long-lived profile -state, Contentful credentials, or HTML rendering. Your application owns the Contentful delivery -client and its credentials; when configured with that client, the SDK can call `client.getEntry()` -for managed entry fetching. Your application provides request inputs, and the SDK evaluates or emits -events, fetches configured entries, resolves variants, and returns request-local data. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **A Node server** you can add a route handler to, and its own Contentful fetching already working. + Install `express` only if you are following the quick start verbatim; any Node request framework + works. Install `contentful` if you want the SDK to fetch entries by ID and you do not already have + a Delivery API client. +- **Contentful delivery credentials** — space ID, delivery token, and environment — read from your + server's runtime configuration, never shipped to the browser. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant the integration still runs, but every request resolves to the baseline, so you + cannot tell personalization from a bug. For your first test, an experience that targets all + visitors is the easiest to verify because every request matches it automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. The Experience API (which picks variants) and the Insights API (which receives event and + interaction delivery) each have a base URL that defaults correctly; you only set them for mocks or + non-default hosts (see [Install and initialize the Node SDK](#install-and-initialize-the-node-sdk)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, identity, +tracking, caching — is introduced by the section that needs it. The Node SDK holds no per-visitor +state between requests: it does not manage cookies, sessions, consent, long-lived profiles, or +rendering. Your app owns those and passes request inputs in; the SDK evaluates the request, resolves +entries, and returns request-local data. + +> [!NOTE] +> +> Read the SDK config from your server's runtime configuration. This guide's examples read +> `process.env` values, and the reference implementations use `PUBLIC_...` names because they run +> against shared mock defaults; use whatever environment variable convention your deployment already +> uses and keep it consistent. Ship only the Contentful **delivery** token to any client, never a +> Management API token. ## Core integration @@ -174,6 +230,13 @@ events, fetches configured entries, resolves variants, and returns request-local Create the SDK once for the Node process or module, then reuse that singleton across requests. Bind request-specific inputs later with `forRequest()`. +From here on, the guide's examples are TypeScript, because the SDK's request and result types are +most of the value in a server codebase. Run them with a TypeScript toolchain (for example `tsx`, +`ts-node`, or a build step) — or drop the type annotations to get back to plain `.mjs` like the quick +start. The quick start inlined the SDK inside `server.mjs`; the sections below factor initialization +into a small shared module (call it `optimization.ts`) that each route handler imports, so there is +still exactly one SDK instance per process. + 1. Install `@contentful/optimization-node` and `contentful` when the SDK will fetch entries by ID. 2. Read the Optimization client ID and environment from your runtime configuration. 3. Configure default locale and API endpoint overrides only when your app needs them. @@ -185,7 +248,9 @@ request-specific inputs later with `forRequest()`. pnpm add @contentful/optimization-node contentful ``` -**Copy this:** +**Adapt this to your use case:** this is a new `optimization.ts` module you create; the route +handlers in later sections `import { optimization }` from it. Replace the env-var reads with your +app's configuration mechanism. ```ts import ContentfulOptimization from '@contentful/optimization-node' @@ -207,7 +272,7 @@ const contentfulClient = contentful.createClient({ space: required('CONTENTFUL_SPACE_ID'), }) -// Create this once per process; use forRequest() inside route handlers. +// Create this once per process; route handlers import it and call forRequest() per request. export const optimization = new ContentfulOptimization({ clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'), contentful: { @@ -229,9 +294,9 @@ export const optimization = new ContentfulOptimization({ }) ``` -The reference implementations in this repository use `PUBLIC_...` environment variable names because -they run against shared mock defaults. Consumer applications can use any environment variable names -that fit their deployment setup. +Set `api.experienceBaseUrl` and `api.insightsBaseUrl` only for mock servers or non-default hosts; +they default correctly otherwise. See the env-var note in [Before you start](#before-you-start) for +naming conventions. ### Bind request context and locale @@ -288,6 +353,10 @@ function getRequestContext(req: Request, appLocale: string): UniversalEventBuild } ``` +Later snippets call `getAppLocale(req)` — that is your app's per-request locale decision (from step +1), which can be as simple as a single `const APP_LOCALE = 'en-US'` if your app is single-locale. +Wherever you see `getAppLocale(req)`, substitute your own locale source. + For the full locale model, see [Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). @@ -298,6 +367,11 @@ For the full locale model, see Consent belongs to your application layer. The Node SDK accepts the request-scoped decision through `forRequest({ consent })`. +The snippets here and in the next section call `getProfileFromRequest(req)` and `persistProfile(res, +id)` — the read and write halves of profile continuity. They are defined in +[Persist profile identity between requests](#persist-profile-identity-between-requests); if you are +building top to bottom, treat them as `undefined`/no-ops until you reach that section. + 1. If application policy permits Optimization by default and no end-user consent UI is rendered, bind each request with `{ events: true, persistence: true }`. 2. If consent depends on user choice, read that decision from your consent cookie, session, CMP, or @@ -339,22 +413,35 @@ const requestOptimization = optimization.forRequest({ }) ``` -By default, the Node SDK still allows request-bound `identify()` and `page()` before event consent -is granted, and labels those events with `context.gdpr.isConsentGiven: false`. Configure +By default, the Node SDK admits request-bound `identify()` and `page()` before event consent is +granted, and labels those events with `context.gdpr.isConsentGiven: false`. Configure `allowedEventTypes: []` if your policy requires strict opt-in before all stateless events. ### Evaluate route requests with `page()` **Integration category:** Required for first integration -Call `page()` for the server route or request that needs profile evaluation, selected optimizations, -or Custom Flag changes. Render from the accepted event result for the current request. +Call `page()` for the server route or request that needs profile evaluation, variant selections, or +Custom Flag changes. Render from the accepted event result for the current request. + +An accepted `page()` returns three things your render logic uses: + +- **`profile`** — the anonymous per-visitor identity and state (defined in the intro). +- **`selectedOptimizations`** — the whole set of variant selections the Experience API made for this + request, one per matched experience. You pass this set into entry resolution. +- **`changes`** — the computed **Custom Flag** values for this request. A Custom Flag is a named + value you author against an experience in Contentful (a feature toggle, a string, a number) and + read at runtime, instead of swapping a whole entry. See [Read Custom Flags](#read-custom-flags). 1. Create a request-bound SDK client inside the route handler. 2. Call `page()` before resolving personalized entries for that response. 3. Use `result.accepted` and `result.data` to handle consent-blocked or unavailable data paths. 4. Pass returned `profile`, `selectedOptimizations`, and `changes` to downstream render logic. +This route is the quick start's handler with three additions: it gates consent on the app's real +decision (`allowed`), persists the profile only when `canPersistProfile` is `true`, and returns +`selectedOptimizations` and `changes` alongside the profile. + **Adapt this to your use case:** ```ts @@ -478,9 +565,11 @@ function clearOptimizationIdentity(res: Response): void { } ``` -Use the shared `ANONYMOUS_ID_COOKIE` cookie when the same app also runs the Web SDK in the browser. -Do not mark that cookie `HttpOnly` in a hybrid Node + Web SDK app because browser-side SDK code must -read it. In a server-only app, a session store or a stricter cookie policy can be valid. +`ANONYMOUS_ID_COOKIE` is an SDK-defined constant that resolves to the cookie name `ctfl-opt-aid`; +import it rather than hardcoding the string, and do not rename it — the browser Web SDK reads the +same name. Use this shared cookie when the same app also runs the Web SDK in the browser, and do not +mark it `HttpOnly` in a hybrid Node + Web SDK app because browser-side SDK code must read it. In a +server-only app, a session store or a stricter cookie policy can be valid. For the lower-level mechanics, see [Profile synchronization between client and server](../concepts/profile-synchronization-between-client-and-server.md). @@ -493,18 +582,28 @@ Your app owns the Contentful delivery client, credentials, and delivery policy. Contentful path passes an app-owned `contentful.js` client to the SDK, then calls the request-bound `requestOptimization.fetchOptimizedEntry(entryId)` helper after `page()` or `identify()`. -After verifying the first `profileId` response, this section is where you add Contentful rendering: -call `requestOptimization.fetchOptimizedEntry(entryId)` before rendering the response. The helper -fetches the baseline entry, resolves the selected variant, and uses the latest accepted Experience -response selections when `selectedOptimizations` is omitted. +This is Milestone 2: the quick start proved an accepted event and a profile; here you turn that into +a rendered variant. The one genuinely new call is `requestOptimization.fetchOptimizedEntry(entryId)`, +which fetches the baseline entry, resolves the selected variant, and uses the latest accepted +Experience response selections when you omit `selectedOptimizations`. + +Two similarly named values appear from here on, and the one-letter difference is intentional: -1. Configure the SDK with `contentful: { client, defaultQuery?, cache? }`. +- **`selectedOptimizations`** (plural) is the request's whole set of selections from `page()` — what + you pass _into_ resolution. +- **`selectedOptimization`** (singular) is the one selection the resolver returns _for a specific + entry_: which experience and variant index applied to it. Use it for tracking and analytics. + +1. Configure the SDK with `contentful: { client, defaultQuery?, cache? }` (done once in your + `optimization.ts` module from [Install and initialize the Node SDK](#install-and-initialize-the-node-sdk)). 2. Call `page()` or `identify()` before resolving entries for the response. 3. Call `requestOptimization.fetchOptimizedEntry(entryId)` inside the request handler. 4. Render the returned `entry`. If resolution cannot find a matching optimization or variant, the resolver returns the baseline entry. -**Adapt this to your use case:** +**Adapt this to your use case:** the SDK config below repeats the `optimization.ts` module from the +install section so the `contentful.client` requirement is visible in one place — reuse your existing +instance rather than creating a second one. The route handler is the new part. ```ts import * as contentful from 'contentful' @@ -515,6 +614,7 @@ const contentfulClient = contentful.createClient({ space: required('CONTENTFUL_SPACE_ID'), }) +// This is the same singleton from optimization.ts; shown here so contentful.client is visible. const optimization = new ContentfulOptimization({ clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'), contentful: { @@ -559,9 +659,8 @@ Use `requestOptimization.fetchOptimizedEntry()` in request handlers. If you call `optimization.fetchOptimizedEntry()` on the singleton for personalized content, pass `selectedOptimizations` explicitly. -Manual baseline-entry fetching plus `resolveOptimizedEntry()` remains supported when the app needs -custom delivery behavior, GraphQL, REST without `contentful.js`, or an already-fetched baseline -entry: +Use manual baseline-entry fetching plus `resolveOptimizedEntry()` when the app needs custom delivery +behavior, GraphQL, REST without `contentful.js`, or an already-fetched baseline entry: **Adapt this to your use case:** @@ -587,7 +686,10 @@ CDA `locale=*` responses. The resolver expects direct single-locale field values **Integration category:** Optional -Use this helper when your Contentful content contains MergeTag entries. +Use this helper when your Contentful content contains MergeTag entries. A MergeTag is an authored +placeholder embedded in Rich Text (for example a visitor's first name or city) that the SDK fills in +from the request's `profile` at render time, falling back to a default value when the profile has no +value for it. 1. Detect MergeTag entries in your Rich Text renderer. 2. Resolve each MergeTag entry against the current request's `profile`. @@ -629,9 +731,14 @@ Use this helper when your Experience response includes Custom Flag changes. 2. Render the server response from the returned flag value. 3. Emit a flag-view event explicitly when your reporting policy needs a flag exposure. +The flag name you pass to `getFlag()` and `componentId` (`'new-navigation'` below) must match the +Custom Flag key you authored in Contentful — it is not a free-choice label. `getFlag()` returns the +authored value, so type its use to your flag (here, comparing against `true` for a boolean flag). + **Copy this:** ```ts +// 'new-navigation' must match the Custom Flag key authored in Contentful. // Pass request-local changes; stateless getFlag() does not emit flag-view tracking. const showNewNavigation = optimization.getFlag('new-navigation', pageResponse?.changes) === true ``` @@ -670,6 +777,10 @@ real interaction. 4. Bind a profile for Insights-only calls such as non-sticky `trackView()`, `trackClick()`, `trackHover()`, and `trackFlagView()`. +Unlike a Custom Flag key, the `track()` `event` name and its `properties` are your own free-choice +labels — pick names that fit your analytics plan; they do not need to match anything authored in +Contentful. + **Adapt this to your use case:** ```ts @@ -682,6 +793,7 @@ if (appPolicyAllowsOptimizationEvent(req) && pageResponse?.profile) { profile: pageResponse.profile, }) + // 'quote_requested' and its properties are your own analytics labels, not authored IDs. await requestOptimization.track({ event: 'quote_requested', properties: { @@ -851,19 +963,19 @@ cache varies on every personalization input. 3. Resolve variants from the current request's `selectedOptimizations`, or use `requestOptimization.fetchOptimizedEntry()` so the request-bound helper supplies them. 4. Render MergeTags against the current request's `profile`. -5. Do not memoize `page()`, `identify()`, `screen()`, `track()`, or `trackView()` results as if they - were pure reads. +5. Do not memoize `page()`, `identify()`, `track()`, or `trackView()` results as if they were pure + reads. Use this cache-safety table when planning production caching: -| Artifact | Shared-cache safe? | Notes | -| -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- | -| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode | -| SDK-managed entry cache | Yes | Caches baseline `getEntry()` results only; configure with `contentful.cache` or disable with `cache: false` | -| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint | -| Merge-tag-rendered rich text | No | Depends on the current request `profile` | -| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs | -| `page()`, `identify()`, `screen()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized | +| Artifact | Shared-cache safe? | Notes | +| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- | +| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode | +| SDK-managed entry cache | Yes | Caches baseline `getEntry()` results only; configure with `contentful.cache` or disable with `cache: false` | +| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint | +| Merge-tag-rendered rich text | No | Depends on the current request `profile` | +| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs | +| `page()`, `identify()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized | ## Production checks @@ -889,6 +1001,19 @@ Before releasing a Node SDK integration, verify these points: `page()`, verify a returned `profile.id`, render an optimized entry, and verify the baseline fallback path by testing without `selectedOptimizations`. +## Troubleshooting + +| Symptom | Likely cause | Check | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Entry always resolves to the baseline | No variant applies, no `selectedOptimizations` passed, the entry is not optimized, or an all-locale payload | Author a variant that targets you, confirm an accepted `page()`/`identify()` ran first, fetch one `locale` with enough `include` | +| The variant never appears even though it is authored | The request does not match the experience's audience, or resolution ran without the request's selections | Target all visitors for a first test or force the variant with the preview panel; pass `selectedOptimizations` or use the bound helper | +| `page()` returns `{ accepted: false }` | Event consent is not granted and the event type is not allow-listed | Bind `consent.events: true` for the request, or confirm `allowedEventTypes` permits `page`; inspect `onEventBlocked` | +| `trackView() requires a request-bound profile id` error thrown | A non-sticky `trackView()`, `trackClick()`, `trackHover()`, or `trackFlagView()` ran without a bound profile | Pass `forRequest({ profile })`, or use sticky `trackView()` which derives the profile from its Experience response | +| Profile does not stay consistent across requests | The returned `profile.id` is not persisted, or `canPersistProfile` was `false` | Persist the ID only when `requestOptimization.canPersistProfile` is `true`, then read it back into `forRequest({ profile })` | +| Hybrid browser sessions start with a different anonymous profile | Server and browser do not share the same readable anonymous-id cookie | Verify the `ctfl-opt-aid` cookie path and same-site settings, and that it is not `HttpOnly` so browser SDK code can read it | +| Merge tags render the fallback for every visitor | No matching profile value, or the profile locale and entry locale differ | Confirm `getMergeTagValue()` receives the request `profile`, and pass one `APP_LOCALE` to both the CDA fetch and `forRequest({ locale })` | +| Personalized output leaks between visitors | A `page()`/`identify()` response or merge-tag-rendered entry was shared through a cache | Cache only raw Contentful payloads; clone before request transforms; never memoize event-method responses | + ## Reference implementations to compare against Use these reference implementations when you want working repository examples instead of guide diff --git a/documentation/internal/sdk-knowledge/node/node.md b/documentation/internal/sdk-knowledge/node/node.md new file mode 100644 index 000000000..3c55e1e39 --- /dev/null +++ b/documentation/internal/sdk-knowledge/node/node.md @@ -0,0 +1,186 @@ +# Node (`@contentful/optimization-node`) — SDK knowledge + + + +> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified +> against packages/\*\*/src during the guide rewrite. + +Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) +and [`../shared/concepts.md`](../shared/concepts.md). This file records only Node-SDK specifics. +Stateless server SDK: a process-level `ContentfulOptimization` class + a request-bound +`CoreStatelessRequest` client created by `forRequest()`. NOT React and NOT stateful; it holds no +per-visitor state between requests. Package source root: `packages/node/node-sdk/src`; shared core: +`packages/universal/core-sdk/src`. The Node class is a thin defaults wrapper over `CoreStateless`. + +## Package & entry points + +| Import path | Purpose | source | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `@contentful/optimization-node` (default export) | Node `ContentfulOptimization` class (extends `CoreStateless`) | node-sdk#index.ts; node-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| `@contentful/optimization-node` (named) | `OPTIMIZATION_NODE_SDK_NAME`, `OPTIMIZATION_NODE_SDK_VERSION`, `OptimizationNodeConfig`, `PublicNodeEventBuilderConfig` | node-sdk#index.ts; node-sdk#constants.ts#OPTIMIZATION_NODE_SDK_NAME; node-sdk#ContentfulOptimization.ts#OptimizationNodeConfig | +| `@contentful/optimization-node/constants` | `ANONYMOUS_ID_COOKIE`, `ANONYMOUS_ID_KEY`, Node SDK name/version | node-sdk#constants.ts; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; core-sdk#constants.ts#ANONYMOUS_ID_KEY | +| `@contentful/optimization-node/core-sdk` | Re-exports all of core-sdk (incl. `UniversalEventBuilderArgs`, `CoreStateless`, `CoreStatelessRequest`) plus `prefetchOptimizedEntries` entry-source helpers | node-sdk#core-sdk.ts; core-sdk#events/EventBuilder.ts#UniversalEventBuilderArgs | +| `@contentful/optimization-node/api-schemas` | Schemas + type guards incl. `isMergeTagEntry` | node-sdk#api-schemas.ts; api-schemas#contentful/typeGuards.ts#isMergeTagEntry | +| `@contentful/optimization-node/api-client` | API client re-export | node-sdk#api-client.ts | +| `@contentful/optimization-node/logger` | logger utilities (default + named) | node-sdk#logger.ts | + +## Setup / factory + +- `new ContentfulOptimization(config)` — extends `CoreStateless extends CoreBase`; stateless. Create + ONE per process/module and reuse across requests. Constructing it does NOT attach to any global and + does NOT throw on a second instance (unlike the stateful Web SDK). Node defaults applied by the + constructor: `eventBuilder.channel: 'server'`, `eventBuilder.library` = Node SDK name/version, + `eventBuilder.getConsent: () => false` (per-request consent is bound with `forRequest()`, never on + the singleton), and `allowedEventTypes` defaults to `['identify', 'page']` when omitted. + source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; node-sdk#ContentfulOptimization.ts#DEFAULT_NODE_ALLOWED_EVENT_TYPES +- Config type `OptimizationNodeConfig` = `Omit` plus `app?: App` + and `eventBuilder?: PublicNodeEventBuilderConfig`; the Node `eventBuilder` override omits `app`, + `getConsent`, and (via `CoreStatelessConfig`) `getLocale`/`getPageProperties`/`getUserAgent`. + source: node-sdk#ContentfulOptimization.ts#OptimizationNodeConfig; node-sdk#ContentfulOptimization.ts#PublicNodeEventBuilderConfig; core-sdk#CoreStateless.ts#CoreStatelessConfig +- Config keys (verified): + - `clientId` (required, `string`), `environment` (optional; API default `'main'`). + source: api-client#ApiClientBase.ts#ApiConfig; api-client#ApiClientBase.ts#GlobalApiConfigProperties + - `locale`, `logLevel` (`'fatal'|'error'|'warn'|'info'|'debug'|'log'`). + source: core-sdk#CoreBase.ts#CoreConfig; api-client#lib/logger/logging.ts#LogLevels + - `api.experienceBaseUrl` / `api.insightsBaseUrl` / `api.enabledFeatures` — base URLs default + correctly; set only for mocks/non-default hosts. + source: core-sdk#CoreApiConfig.ts#CoreSharedApiConfig; core-sdk#CoreApiConfig.ts#CoreStatelessApiConfig + - `app.name` / `app.version` (both required strings when `app` is given; whole object optional). + source: api-schemas#experience/event/properties/App.ts#App + - `allowedEventTypes` (pre-consent allow-list), `onEventBlocked` (blocked-event diagnostics). + source: core-sdk#CoreStateless.ts#CoreStatelessConfig; core-sdk#CoreStateless.ts#CoreStateless + - `contentful?: ContentfulConfig` (managed entry fetching) — same shape/semantics as the web + family; see [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual). + source: core-sdk#CoreBase.ts#ContentfulConfig +- `forRequest(options)` → returns a new `CoreStatelessRequest`. This is the ONLY place event methods + live; the singleton exposes no event methods. `options` = `CoreStatelessForRequestOptions`: + `consent` (required), `locale?`, `profile?: PartialProfile`, + `eventContext?: UniversalEventBuilderArgs`, `experienceOptions?`, `insightsOptions?`. + source: core-sdk#CoreStateless.ts#forRequest; core-sdk#CoreStatelessRequest.ts#CoreStatelessForRequestOptions + +## Components & hooks + +None. Node is an imperative class (`ContentfulOptimization`) plus a request-bound client +(`CoreStatelessRequest`); no React components, hooks, providers, or Web Components. +source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest + +## Render / entry resolution + +- Entry-source boundary (manual vs managed), single-locale requirement, and baseline fallback are + shared: see [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual) + and [`../shared/concepts.md`](../shared/concepts.md#entry-resolution). +- Synchronous singleton `resolveOptimizedEntry(baselineEntry, selectedOptimizations?)` → + `ResolvedData` `{ entry, selectedOptimization?, optimizationContextId?, isEmptyVariant? }`. On the + singleton there is no ambient state: omitting `selectedOptimizations` yields the baseline, so pass + it explicitly for personalized content. + source: core-sdk#CoreBase.ts#resolveOptimizedEntry; core-sdk#resolvers/OptimizedEntryResolver.ts#ResolvedData +- Request-bound `fetchOptimizedEntry(entryId, options?)` → `FetchOptimizedEntryResult` + `{ baselineEntry, entry, selectedOptimization?, optimizationContextId?, isEmptyVariant? }`. When + `options.selectedOptimizations` is omitted it defaults to the latest selections returned by an + accepted request-bound Experience call (`page()`/`identify()`); when neither + `contentful.defaultQuery.locale` nor the per-call `query.locale` is set, the request's `locale` + becomes the managed Contentful query locale. + source: core-sdk#CoreStatelessRequest.ts#fetchOptimizedEntry; core-sdk#CoreStatelessRequest.ts#withRequestContentfulLocale; core-sdk#CoreBase.ts#FetchOptimizedEntryResult +- Request-bound `fetchContentfulEntry(entryId, query?)` fetches the baseline only (same locale + injection); throws if `contentful.client` is not configured. Managed-cache mechanics and the + `defaultQuery`+per-call+locale+`include: 10` merge are shared. + source: core-sdk#CoreStatelessRequest.ts#fetchContentfulEntry; core-sdk#CoreBase.ts#fetchContentfulEntry +- `selectedOptimization` fields: `experienceId`, `variantIndex`, `variants` (baseline-entry-id → + variant-entry-id map), `sticky`. `variants[baselineEntryId]` equal to the baseline id means baseline. + source: api-schemas#experience/optimization/SelectedOptimization.ts#SelectedOptimization +- `getFlag(name, changes?)` → resolves a flag value from a `changes` list; read-only, no side effect. + `getMergeTagValue(mergeTagEntry, profile?)` → resolves a MergeTag value against the request profile, + falling back to the entry's configured fallback. + source: core-sdk#CoreBase.ts#getFlag; core-sdk#CoreBase.ts#getMergeTagValue; core-sdk#resolvers/MergeTagValueResolver.ts#MergeTagValueResolver + +## Identifier ownership + +| Identifier | Owner | Notes | source | +| ----------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (`ANONYMOUS_ID_COOKIE`) | reader | The SDK exports the constant but manages NO cookies in Node; the app reads/writes/clears it and passes the id via `forRequest({ profile })`. Value === `'ctfl-opt-aid'`. Keep non-`HttpOnly` in a hybrid Node+Web app so browser SDK code can read it. | core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; node-sdk#constants.ts | +| request `profile` (`PartialProfile`, `{ id }` + JSON) | reader | Bound per request via `forRequest({ profile })`; the request client's `profile` getter updates after an Experience response. | core-sdk#CoreStatelessRequest.ts#CoreStatelessForRequestOptions; api-schemas#experience/profile/Profile.ts#PartialProfile | +| request `consent` decision | reader | App reads its own CMP/cookie/session; SDK only receives the per-request decision. | core-sdk#CoreStatelessRequest.ts#CoreStatelessRequestConsent | +| env/config values | reader | SDK config is framework-agnostic; app owns the env-var convention (e.g. `PUBLIC_...`, `process.env`). | extern:app owns server runtime config values | + +## Events & tracking + +- Request-bound event methods (on `CoreStatelessRequest`, awaited): Experience methods `page(payload?)` + (payload defaults `{}`), `identify({ userId, traits? })` (`userId` required), `screen({ name, properties })`, + `track({ event, properties? })` (`event` required) → return `EventEmissionResult` + `{ accepted, data? }` where `data` is `OptimizationData` `{ profile, selectedOptimizations, changes }`. + Accepted `page()`/`identify()` update the request client's `profile` and cached + `selectedOptimizations`. + source: core-sdk#CoreStatelessRequest.ts#page; core-sdk#CoreStatelessRequest.ts#identify; core-sdk#CoreStatelessRequest.ts#screen; core-sdk#CoreStatelessRequest.ts#track; core-sdk#events/EventEmissionResult.ts#EventEmissionResult; api-schemas#experience/ExperienceResponse.ts#OptimizationData +- Insights methods: `trackView(payload)` returns `EventEmissionResult`; `trackClick`, `trackHover`, + `trackFlagView` return `void`. `trackView({ componentId, viewId, viewDurationMs, experienceId?, variantIndex?, sticky? })`. + source: core-sdk#CoreStatelessRequest.ts#trackView; core-sdk#CoreStatelessRequest.ts#trackClick; core-sdk#CoreStatelessRequest.ts#trackHover; core-sdk#CoreStatelessRequest.ts#trackFlagView; core-sdk#events/EventBuilder.ts#ViewBuilderArgs +- Sticky `trackView` (`sticky: true`) sends an Experience `component` view FIRST, then reuses that + response's `profile` for the paired Insights view. Non-sticky `trackView`, `trackClick`, + `trackHover`, and `trackFlagView` throw if no request-bound `profile.id` exists (there is no ambient + profile). The thrown text is method-specific (e.g. "trackView() requires a request-bound profile id + when payload.sticky is not true"). + source: core-sdk#CoreStatelessRequest.ts#trackView; core-sdk#CoreStatelessRequest.ts#NON_STICKY_TRACK_VIEW_PROFILE_ERROR; core-sdk#CoreStatelessRequest.ts#TRACK_CLICK_PROFILE_ERROR +- `getFlag()` never emits a flag view. To report a flag exposure, call request-bound `trackFlagView` + (admitted when request event consent is `true` OR `'flag'`/`'component'` is allow-listed; requires a + bound profile). Node's default allow-list does NOT include `'flag'`/`'component'`. + source: core-sdk#CoreBase.ts#getFlag; core-sdk#CoreStatelessRequest.ts#trackFlagView; core-sdk#CoreStatelessRequest.ts#hasConsent +- Consent gate: an event is admitted when the request event consent is `true`, OR its selector is in + the SDK's `allowedEventTypes`. With Node's `['identify', 'page']` default, `page()`/`identify()` are + admitted before event consent and are labeled `context.gdpr.isConsentGiven: false`; setting + `allowedEventTypes: []` blocks them until request consent is `true`. Event-type wire strings: + `identify`, `page`, `screen`, `track`, `component` (view/flag view), `component_click`, + `component_hover`; `flag` is a consent selector only. + source: core-sdk#CoreStatelessRequest.ts#hasConsent; core-sdk#CoreStatelessRequest.ts#withRequestEventConsent; core-sdk#events/EventType.ts#AllowedEventType; node-sdk#ContentfulOptimization.ts#DEFAULT_NODE_ALLOWED_EVENT_TYPES +- Blocked events do NOT throw: Experience methods return `{ accepted: false }`, Insights methods + return without sending; `onEventBlocked({ reason: 'consent', method, args })` fires for diagnostics. + source: core-sdk#CoreStatelessRequest.ts#sendExperienceEvent; core-sdk#CoreStatelessRequest.ts#reportBlockedEvent; core-sdk#events/BlockedEvent.ts#BlockedEvent +- `eventContext` (`UniversalEventBuilderArgs`: `locale?`, `userAgent?`, `page?`, `screen?`, `campaign?`, + `location?`) is merged into every event built through the request client; a per-call payload wins over + it. `page.*` requires `path`, `query`, `referrer`, `search`, `url` (`title` optional). + source: core-sdk#CoreStatelessRequest.ts#withEventContext; core-sdk#events/EventBuilder.ts#UniversalEventBuilderArgs; api-schemas#experience/event/properties/Page.ts#Page + +## Consent & persistence + +- Request-scoped, not stateful. `forRequest({ consent })` accepts `boolean` or + `{ events?, persistence? }` (`CoreStatelessRequestConsent`). `events` gates event emission; + `persistence === true` sets the request client's `canPersistProfile` flag the app checks before + persisting the returned `profile.id`. A boolean sets both axes. + source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequestConsent; core-sdk#CoreStatelessRequest.ts#canPersistProfile +- The SDK stores nothing between requests: no consent cookie, no profile cookie, no CMP record. The + app owns all persistence; the SDK only reads the per-request `consent` and `profile`. General + consent-vs-persistence model: see + [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). + source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest + +## Version / runtime quirks + +- Stateless per request: construct the SDK once, call `forRequest()` per incoming request, then call + event methods / entry fetches on that request client. The request client's `profile` getter is the + only mutable request-local state and updates only from accepted Experience responses. + source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest; core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent +- Locale precedence: when `forRequest()` receives both a top-level `locale` and + `experienceOptions.locale`, the top-level request `locale` wins — the constructor rewrites + `experienceOptions.locale` (and `eventContext.locale`) to the normalized request locale. + source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest; core-sdk#locale.ts#normalizeExplicitLocale +- Request-scoped API options: `experienceOptions` (`CoreStatelessRequestOptions`: `ip`, `locale`, + `plainText`, `preflight`) and `insightsOptions` (`CoreStatelessInsightsOptions`: `beacon`) apply only + to that request's calls. + source: core-sdk#CoreStateless.ts#CoreStatelessRequestOptions; core-sdk#CoreStateless.ts#CoreStatelessInsightsOptions +- SDK config is framework-agnostic; store the instance in a module-level singleton. No browser live + updates or preview UI in Node. + source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization + +## Failure & fallback behavior + +- Baseline fallback on no selections / entry not optimized / no matching optimization entry / no + matching selection / broken variant link / all-locale payload: the resolver returns the baseline + entry. Shared model: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: core-sdk#resolvers/OptimizedEntryResolver.ts#resolveWithContext +- Consent-blocked events fail closed without throwing (see Events & tracking). Insights-only calls + and non-sticky `trackView` without a bound `profile.id` throw a method-specific error; sticky + `trackView` throws only if it cannot derive a profile from its Experience response and none was bound. + source: core-sdk#CoreStatelessRequest.ts#requireInsightsProfile; core-sdk#CoreStatelessRequest.ts#STICKY_TRACK_VIEW_PROFILE_ERROR +- Validation: `pnpm implementation:run -- node-sdk typecheck`; reference implementations exercise + `page()`, `identify()`, `resolveOptimizedEntry()`, `getMergeTagValue()`, and `ANONYMOUS_ID_COOKIE` + cookie sharing. + source: impl:node-sdk#package.json; impl:node-sdk+web-sdk#src/app.ts From c5923d131a7b1c9f928c5449099068cad5563990 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 14:07:10 +0200 Subject: [PATCH 25/42] =?UTF-8?q?=F0=9F=93=9D=20docs(skills):=20Funnel=20N?= =?UTF-8?q?ode-review=20reader-experience=20rules=20into=20the=20authoring?= =?UTF-8?q?=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Node guide review surfaced durable reader-experience rules; encode them as mechanical checklist assertions (principles, not SDK facts) so future guides catch them upfront: result-envelope fields defined at first use; near-identical/shorthand identifiers disambiguated; SDK-constant↔literal-value mapping stated once; helpers defined or forward-pointed; no orphan cross-SDK API in warnings/tables; source-language switches announced with run instructions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/authoring-checklist.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 13537c7d4..f5e4179f0 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -13,6 +13,16 @@ add per-archetype checks. baseline fallback — plus any SDK-owned config key, identifier, or event name the quick start touches). The definition is in prose (or the intro explainer), not only inside a code comment — a reader who skims prose and diffs must still meet the term defined. +- [ ] **Every result-shape field the reader branches on or passes downstream is defined at first + use.** A key the guide tells the reader to check (`accepted`, `data`) or hand to render logic + (`profile`, `selectedOptimizations`, `changes`) gets a plain-prose definition at first use, not + only a code comment. This extends the term rule above from domain nouns to the fields of the + SDK's return envelope. +- [ ] **Near-identical or shorthand identifiers are disambiguated once.** When two names differ by + one letter and mean different things (`selectedOptimization` the single returned selection vs + `selectedOptimizations` the set passed in), or a shorthand form coexists with a longhand one + (`consent: true` vs `consent: { events, persistence }`), the guide states the distinction at + first use so neither reads as a typo or a different feature. - [ ] No section promises "you don't need X" and then requires an undefined X before it is explained. - [ ] No informal filler or hype that reads oddly in a reference doc ("this is the payoff", "the @@ -37,7 +47,23 @@ add per-archetype checks. - [ ] **Every magic-value identifier states who owns it.** For each cookie name, env var, header, storage key, or config string in a snippet, the guide states whether the reader must match the exact name (SDK-defined) or can choose it freely (reader-invented). Neither reads as the - other. + other. When an SDK-exported constant (e.g. `ANONYMOUS_ID_COOKIE`) surfaces elsewhere as its + literal value (e.g. `ctfl-opt-aid` in a Troubleshooting row), the guide states the mapping once + so the reader can connect the code they wrote to the artifact they observe. +- [ ] **Every helper the reader would run is defined before or at first use, or points forward.** + Helper functions used inside `**Adapt this to your use case:**` blocks (`getRequestContext`, + `getProfileFromRequest`) are defined at or before their first appearance, or the guide points + forward to the section that defines them. A helper referenced but defined nowhere in the guide + (a `getAppLocale`-style phantom) is a defect: define it once or inline the expression the guide + already teaches. +- [ ] **No orphan cross-SDK API.** The guide does not name a method a given runtime never calls + (e.g. `screen()` in a Node guide) inside a warning, "do not do X" list, or table without + introducing it in a flow first. Warn only about methods the guide actually teaches. +- [ ] **Source language is consistent, or the switch is announced with run instructions.** A guide + does not silently switch source language (e.g. a `.mjs` quick start run with `node` followed by + TypeScript body snippets) without a prose note stating the switch and how to run the new form. + The run command shown for a `**Copy this:**` block must actually work for that block's + language. - [ ] `pnpm format:fix ` leaves the file unchanged (run it; Prettier owns formatting). - [ ] The collapsible TOC preserves the mtoc markers, omits `## Quick start`, and every anchor resolves to a real heading. From b6d7bf78cd410bbe09e0dd4a7ad16da12dedfedc Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 14:07:51 +0200 Subject: [PATCH 26/42] =?UTF-8?q?=F0=9F=8E=A8=20chore(format):=20Normalize?= =?UTF-8?q?=20link-reference=20wrapping=20under=20proseWrap=20preserve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running proseWrap: preserve, the one file still carrying proseWrap: always drift (wrapped link-reference definitions) is normalized to single-line link defs. Content-free reflow — done once here deliberately so it cannot resurface piecemeal in unrelated diffs (it briefly rode along in the Node docs run before being reverted). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...anagement-in-the-optimization-sdk-suite.md | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md index a38ce4b5d..cc4231f55 100644 --- a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md @@ -461,21 +461,14 @@ Use the same SDK controls after your legal and privacy review maps the applicabl | Brazil LGPD | [ANPD cookie guidance][anpd-lgpd] | Present purpose categories for personalization, analytics, and sharing, then enable only the SDK event and persistence choices those purposes cover. | | India DPDP Act | [Digital Personal Data Protection Act, 2023][india-dpdp] | Sync notice, clear affirmative choice, purpose limits, and withdrawal state into browser, server, and mobile SDK runtimes. | -[edpb-gdpr]: - https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en -[edpb-eprivacy]: - https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en -[ico-pecr]: - https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guide-to-pecr/cookies-and-similar-technologies/ +[edpb-gdpr]: https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en +[edpb-eprivacy]: https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en +[ico-pecr]: https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guide-to-pecr/cookies-and-similar-technologies/ [california-ccpa]: https://oag.ca.gov/privacy/ccpa -[opc-pipeda]: - https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/the-personal-information-protection-and-electronic-documents-act-pipeda/p_principle/principles/p_consent/ -[oaic-privacy]: - https://www.oaic.gov.au/privacy/your-privacy-rights/your-personal-information/consent-to-the-handling-of-personal-information -[anpd-lgpd]: - https://www.gov.br/anpd/pt-br/centrais-de-conteudo/materiais-educativos-e-publicacoes/guia_orientativo_cookies_e_protecao_de_dados_pessoais -[india-dpdp]: - https://www.meity.gov.in/static/uploads/2024/02/Digital-Personal-Data-Protection-Act-2023.pdf +[opc-pipeda]: https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/the-personal-information-protection-and-electronic-documents-act-pipeda/p_principle/principles/p_consent/ +[oaic-privacy]: https://www.oaic.gov.au/privacy/your-privacy-rights/your-personal-information/consent-to-the-handling-of-personal-information +[anpd-lgpd]: https://www.gov.br/anpd/pt-br/centrais-de-conteudo/materiais-educativos-e-publicacoes/guia_orientativo_cookies_e_protecao_de_dados_pessoais +[india-dpdp]: https://www.meity.gov.in/static/uploads/2024/02/Digital-Personal-Data-Protection-Act-2023.pdf ## Engineering checklist From bd049e35a334daa42bb849b9e4e5084e0ba7eb69 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 14:09:16 +0200 Subject: [PATCH 27/42] =?UTF-8?q?=F0=9F=93=9D=20docs(commands):=20Require?= =?UTF-8?q?=20file-scoped=20format:fix=20in=20workflow=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node-run feedback: subagents ran bare `pnpm format:fix`, which reformats the whole tree and pulled an unrelated concept doc into the diff (later reverted). The command gates said bare `format:fix`; make them explicitly pass the touched paths and warn against the bare form. The skills already used the `` form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/author-guide.md | 4 +++- .claude/commands/refresh-docs.md | 3 ++- .claude/commands/review-guide.md | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md index 204c8677d..54919e09f 100644 --- a/.claude/commands/author-guide.md +++ b/.claude/commands/author-guide.md @@ -47,7 +47,9 @@ Do not call it done until all hold; loop back to the responsible step for anythi - Every verifier **contradicts-KB** claim is corrected; every **no-backing-fact** claim is resolved (fact added by the knowledge author, or claim removed). - `pnpm knowledge:check` passes. -- `pnpm format:fix` leaves the guide clean and its TOC anchors resolve. +- `pnpm format:fix ` leaves the guide clean and its TOC anchors resolve. Always pass + the specific files you changed — never a bare `pnpm format:fix`, which reformats the whole tree and + pulls unrelated files into your diff. ## 5. Funnel learnings back diff --git a/.claude/commands/refresh-docs.md b/.claude/commands/refresh-docs.md index 3e79afc36..70f644b10 100644 --- a/.claude/commands/refresh-docs.md +++ b/.claude/commands/refresh-docs.md @@ -54,7 +54,8 @@ Run `review-guide` on each recomposed guide, but focus the reviewers on the chan - `pnpm knowledge:check` passes. - Every changed claim traces to a current KB fact; no newcomer blocker in the changed passages. -- `pnpm format:fix` leaves the touched guides clean; TOC anchors still resolve. +- `pnpm format:fix ` leaves the touched guides clean; TOC anchors still resolve. Pass + the specific files you changed — never a bare `pnpm format:fix`, which reformats the whole tree. ## 6. Report diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md index 040f20db1..5e6ddf083 100644 --- a/.claude/commands/review-guide.md +++ b/.claude/commands/review-guide.md @@ -34,8 +34,9 @@ Do this in order: only, never SDK facts), - a missing or corrected SDK fact → the knowledge base via `sdk-knowledge-author`. -5. **Validate.** Run `pnpm knowledge:check` (KB must pass) and `pnpm format:fix` on the touched files, - and confirm the guide's TOC anchors resolve. +5. **Validate.** Run `pnpm knowledge:check` (KB must pass) and `pnpm format:fix ` — + pass the specific files you changed, never a bare `pnpm format:fix` (it reformats the whole tree + and pollutes the diff) — and confirm the guide's TOC anchors resolve. Report: the findings from each role, what you changed in the guide, what you funneled back into the skill vs. the knowledge base, and the validation result. From 603c6bf06dd8bee2cbff6e0609ce16514863b1e5 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 16:45:21 +0200 Subject: [PATCH 28/42] =?UTF-8?q?=F0=9F=93=9D=20docs(react-native):=20Refr?= =?UTF-8?q?esh=20RN=20guide=20to=20current=20archetype=20+=20bootstrap=20n?= =?UTF-8?q?ative/=20KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second workflow run, and the first exercising the RESHAPED bootstrap (knowledge-first) order: sdk-knowledge-author ran before guide-writer, and the writer escalated a missing fact instead of re-grepping source — proof the read-facts-not-code discipline holds. Bootstraps the first native/ family file (documentation/internal/sdk-knowledge/native/ react-native.md): async ContentfulOptimization.create() factory + OptimizationRoot/Provider, managed-vs-manual entry union, AsyncStorage identifier ownership, screen/viewport/tap tracking with verified defaults (minVisibleRatio 0.8, dwellTimeMs 2000, tap threshold 10), the RN ['identify','screen'] pre-consent allow-list, NetInfo/AppState/Expo quirks, and the SDK-fixed content-model fields (nt_experiences→nt_experience→nt_variants/nt_audience) — each with a resolvable source pointer, feeds-guides set to this guide. Guide: `## Required setup` table → `## Before you start`, teach-first intro, section order fixed to the archetype (22 TOC anchors resolve). Review: 0 contradicts-KB; 7 no-backing-fact claims all resolved by adding the backing fact from source (none unfounded); ~18 newcomer findings fixed. The onOfflineDrop callback shape differs from the iOS/Android sibling guides — left as a legitimate per-SDK difference (separate Swift/Kotlin source, unverified here). Also funnels the RN review's durable reader-experience rules into the authoring checklist (principles, not SDK facts): prose state-nouns defined at first use across sections; SDK-fixed content-model identifiers glossed with ownership; orphan rule extended to event names; render prop defined in prose; every promised proof verified; runtime root component as a diff not a paste-over; native build step inline in the quick start; Core sections open with a bridge not a recap. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-react-native-sdk-in-a-react-native-app.md | 284 ++++++++++------ .../sdk-knowledge/native/react-native.md | 304 ++++++++++++++++++ .../references/authoring-checklist.md | 53 ++- 3 files changed, 535 insertions(+), 106 deletions(-) create mode 100644 documentation/internal/sdk-knowledge/native/react-native.md diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md index 0c25fab1f..9209e0142 100644 --- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md +++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md @@ -1,19 +1,56 @@ # Integrating the Optimization React Native SDK in a React Native app -Use this guide when you want to add mobile personalization, Analytics events, screen tracking, and -optional preview tooling to a React Native or Expo application with -`@contentful/optimization-react-native`. - -The React Native SDK builds on the Optimization Core SDK and adds React Native providers, hooks, -entry rendering, viewport and tap tracking, AsyncStorage persistence, optional offline delivery, and -an in-app preview panel. Your application still owns Contentful Delivery API client configuration, -Contentful locale policy, consent policy, identity policy, navigation, and final rendering. +Use this guide to add Contentful personalization to a React Native or Expo app using +`@contentful/optimization-react-native`. By the end of the quick start, one Contentful entry will +render the personalized variant for the current visitor — or the original entry when none applies — +and one screen event will report the visitor's profile to Contentful. + +**New to personalization?** Here is the whole idea in five sentences: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also builds a **profile**: the anonymous, per-visitor identity and state the + SDK keeps consistent across app launches. +- Your app gets a Contentful entry — you fetch it and hand it to the SDK, or you give the SDK your + Contentful client and let it fetch by ID — and the SDK resolves it, returning the picked variant + or, when no variant applies, the original entry (the **baseline fallback**). +- You render whatever the SDK hands back exactly as you render entries today. + +That is enough to start. You do not need audiences, interaction events, identity, or the preview +panel yet; this guide introduces each idea at the point you need it. + +You will get there in two milestones: + +- **Milestone 1 — one entry resolving and one screen event (the quick start below).** A single screen + mounts `OptimizedEntry`, which renders the resolved variant or the baseline, and reports one screen + event. This is complete and shippable on its own. +- **Milestone 2 — the opt-in layers (later).** Consent handoff, interaction tracking, identity, live + updates, the preview panel, offline delivery, and analytics forwarding, each introduced by the + section that needs it. + +This guide uses `@contentful/optimization-react-native`. You mount one `OptimizationRoot` around your +app; it creates the SDK instance, restores state from AsyncStorage, and provides it to the hooks and +components below it. Your app still owns its Contentful Delivery API client, locale policy, consent +policy, identity policy, navigation, and final rendering. ## Quick start -Use this path when your application policy permits Optimization to start with accepted consent. If -your policy requires an end-user choice first, complete the consent handoff section before sending -events or rendering personalized content. +Most React Native + Contentful apps share one shape: a screen fetches or receives a Contentful +entry, and somewhere in that screen the entry becomes a rendered component. This quick start assumes +that shape and proves the smallest result: **one screen renders a resolved entry — variant or +baseline — and reports one screen event.** It mounts one `OptimizationRoot`, hands the SDK your +Contentful client so it can fetch the entry by ID, and tracks the screen with `useScreenTracking`. + +This quick start assumes your application policy permits Optimization to start with accepted consent +and renders no end-user consent UI. Consent has two independent parts: `events` (may the SDK +personalize and send events) and `persistence` (may the SDK store profile continuity in +AsyncStorage). The shorthand `consent: true` sets both to `true`; the object form +`{ events, persistence }` sets them separately. If personalization must wait for a consent decision, +keep this structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) +step before you ship. 1. Install the React Native SDK, its required AsyncStorage peer dependency, and a Contentful delivery client if your app does not already have one. @@ -24,10 +61,14 @@ events or rendering personalized content. pnpm add @contentful/optimization-react-native @react-native-async-storage/async-storage contentful ``` + AsyncStorage ships native code, so complete your platform's native install step before launching: + run `pod install` in `ios/` for a bare React Native app, or `npx expo prebuild` (a custom dev + build) for Expo, then rebuild the app. + 2. Mount `OptimizationRoot` with your app-owned Contentful client, emit one screen event for profile context, and render one single-locale Contentful entry by ID through `OptimizedEntry`. - **Copy this:** + **Adapt this to your use case:** ```tsx import { Text } from 'react-native' @@ -80,14 +121,24 @@ events or rendering personalized content. } ``` -3. Verify the first run. The app displays a resolved entry ID for either the selected variant or the - baseline fallback. + The `App` and `HomeScreen` scaffolding above is illustrative context to match against your own + app, not a file to paste over yours. Wrap your existing app root in `OptimizationRoot`, add + `useScreenTracking` to a screen you already render, and wrap one entry you already render in + `OptimizedEntry` — keep the rest of your components as they are. + +3. Verify the first run. Launch the app; the screen displays a resolved entry ID for either the + selected variant or the baseline. To confirm the screen event fired, watch your SDK logs or + `states.eventStream` for one accepted `screen` event (see + [Screen and navigation tracking](#screen-and-navigation-tracking)). To see personalization rather than the baseline, author (in + Contentful) a variant of `hero-entry-id` attached to an experience that targets all visitors — + every visitor matches it automatically, so the resolved ID changes to the variant. Without an + authored variant every launch shows the baseline entry, which is expected, not a failure.
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [Install and initialize `OptimizationRoot`](#install-and-initialize-optimizationroot) - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) @@ -113,38 +164,35 @@ events or rendering personalized content.
-## Required setup - -Use this setup inventory before you move beyond the quick start: - -| Setup item | Category | Required for quick start | Where to configure | -| ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------- | -| React Native app with compatible React and React Native peer dependencies | Required for first integration | Yes | Application package dependencies | -| `@contentful/optimization-react-native` package | Required for first integration | Yes | Application package dependencies | -| `@react-native-async-storage/async-storage` peer dependency | Required for first integration | Yes | Application package dependencies and native install flow | -| Contentful Delivery API client | Required for first integration | Yes | Application package dependencies, app-owned Contentful client factory, and SDK config | -| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot`, `OptimizationProvider`, or `ContentfulOptimization.create(...)` | -| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` SDK config for staging, mock, or non-default hosts | -| Contentful space, environment, access token, and CDA host | Required for first integration | Yes | Application-owned Contentful client and SDK `contentful` config | -| Optimized Contentful entries with resolved `nt_experiences` and variants | Required for first integration | Yes | Contentful content model and CDA `include` depth | -| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | Yes | App locale policy, SDK `contentful.defaultQuery` or `entryQuery`, and SDK `locale` | -| `OptimizationRoot` mounted once around SDK consumers | Required for first integration | Yes | React Native app root or navigation root | -| Screen, route, or lifecycle integration | Required for first integration | Yes | `useScreenTracking`, `useScreenTrackingCallback`, or `OptimizationNavigationContainer` | -| Entry rendering through `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver` | Required for first integration | Yes | React Native components that render Contentful entries | -| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | SDK `defaults`, `allowedEventTypes`, and application consent UI or CMP callbacks | -| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `trackEntryInteraction` on `OptimizationRoot` and per-entry tracking props | -| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Authentication, account, or settings flows that call `identify()` and `reset()` | -| React Navigation integration | Optional | No | App navigation dependencies and `OptimizationNavigationContainer` | -| `@react-native-community/netinfo` for offline detection | Optional | No | Application package dependencies and native install flow | -| Preview peer dependencies | Optional | No | `@react-native-clipboard/clipboard` and `react-native-safe-area-context` | -| Merge tag and Custom Flag rendering | Optional | No | App-owned Rich Text, flag, or feature-rendering components | -| Analytics forwarding destination | Optional | No | `onStatesReady` subscriptions and application-owned analytics code | -| Strict pre-consent allowlist, queue policy, and diagnostics | Advanced or production-only | No | SDK `allowedEventTypes`, `queuePolicy`, `onEventBlocked`, and `logLevel` | -| Preview release gating and custom native builds | Advanced or production-only | No | Build flags, Expo custom dev builds, and release configuration | - -Prefer SDK-managed entry fetching by configuring `contentful: { client }` and passing `entryId`. -Manual single-locale `baselineEntry` objects remain supported when your application layer must own -the individual Contentful request. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **A React Native or Expo app** with React and React Native installed, its own Contentful fetching + already working, and the ability to run a native build step (`pod install` for bare React Native, + `npx expo prebuild` for Expo) — the SDK's required AsyncStorage peer dependency ships native code. +- **Contentful delivery credentials** — space ID, delivery token, and environment — read from your + app's runtime configuration. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant the integration still runs, but every visitor sees the baseline, so you cannot + tell personalization from a bug. For your first test, an experience that targets all visitors is + the easiest to verify because you match it automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. The Experience API (which picks variants) and the Insights API (which receives event and + interaction delivery) each have a base URL that defaults correctly; you only set them for mocks or + non-default hosts (see [Install and initialize `OptimizationRoot`](#install-and-initialize-optimizationroot)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, screen +tracking, interaction tracking, identity, live updates, preview, offline delivery — is introduced by +the section that needs it. + +> [!NOTE] +> +> Read the SDK and Contentful config from your app's runtime configuration. This guide's examples +> use inline placeholder strings for clarity; the reference implementation reads `PUBLIC_...` +> environment variables through `@env` because it runs against shared mock defaults. Use whatever +> environment variable convention your React Native tooling already uses and keep it consistent. ## Core integration @@ -152,17 +200,22 @@ the individual Contentful request. **Integration category:** Required for first integration -`OptimizationRoot` is the normal React Native entry point. It creates the SDK instance, waits for -AsyncStorage-backed state setup, runs `onStatesReady` when provided, then renders provider children. -It also composes live-update and interaction-tracking context for descendant components. - -1. Install `@contentful/optimization-react-native` and `@react-native-async-storage/async-storage`. -2. Mount one `OptimizationRoot` around all components that call React Native SDK hooks. -3. Pass `clientId` from runtime configuration. Pass `environment` when you do not use the default - Contentful environment, `main`. -4. Pass `locale` when Experience API responses and event context must use the same app locale as +You mounted `OptimizationRoot` in the quick start; this section covers its full configuration +surface — `onStatesReady`, `api` host overrides, `logLevel`, and reading the SDK instance with +`useOptimization()`. `OptimizationRoot` is the normal React Native entry point: it creates the SDK +instance, waits for AsyncStorage-backed state setup, runs `onStatesReady` when provided, then renders +provider children. It also composes live-update and interaction-tracking context for descendant +components. + +1. Mount one `OptimizationRoot` around all components that call React Native SDK hooks. +2. Pass `clientId` from runtime configuration. Pass `environment` only when you do not use the + default Contentful environment; when you omit it the SDK uses `main`. +3. Pass `locale` when Experience API responses and event context must use the same app locale as your Contentful entry fetches. -5. Pass `api` endpoint overrides only for staging, mocks, or non-default production hosts. +4. Pass `api` endpoint overrides only for staging, mocks, or non-default production hosts. Both base + URLs default correctly otherwise, so most apps omit `api` entirely. +5. Provide `onStatesReady` when app-level code must subscribe to SDK state before child effects run + (see [Analytics forwarding](#analytics-forwarding)). **Adapt this to your use case:** @@ -178,8 +231,9 @@ export function AppRoot({ children }: { children: ReactNode }) { environment="main" locale="en-US" api={{ - experienceBaseUrl: 'https://experience.ninetailed.co/', - insightsBaseUrl: 'https://ingest.insights.ninetailed.co/', + // Set these only for staging, mocks, or non-default hosts; both default correctly otherwise. + experienceBaseUrl: 'https://experience.staging.example.com/', + insightsBaseUrl: 'https://insights.staging.example.com/', }} logLevel="warn" > @@ -241,10 +295,13 @@ function ConsentControls() { ``` By default, React Native permits `identify` and `screen` before event consent is accepted. Entry -views, entry taps, `page`, and custom `track` events are blocked until consent is accepted or their -event types are allow-listed. Boolean consent calls control both event emission and durable profile -continuity. Use `optimization.consent({ events: true, persistence: false })` when events can emit -but profile, selected optimizations, changes, and anonymous identity must stay session-only. +views, entry taps, and any other event type are blocked until consent is accepted or that type is +allow-listed. Boolean consent calls control both event emission and durable profile continuity. Use +`optimization.consent({ events: true, persistence: false })` when events can emit but the stored +profile-continuity state must stay session-only. That state is the anonymous identity, the profile, +the **selected optimizations** (which variant the Experience API picked for each experience), and the +**changes** (the profile-backed flag and merge-tag values the Experience API returns) — all of which +otherwise persist in AsyncStorage across launches. For cross-SDK policy details, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). @@ -262,8 +319,11 @@ direct values, not locale-keyed maps. layer. 2. Configure `contentful.defaultQuery` on the SDK, pass per-entry `entryQuery`, or pass the locale to manual Contentful CDA requests. -3. Request enough link depth for `nt_experiences`, optimization config, and linked variant entries. - `include: 10` is the repository reference implementation's pattern. +3. Request enough link depth for the SDK to resolve variants. `nt_experiences` is a fixed + Optimization field the SDK adds to an optimized entry (an SDK-owned content-model name you do not + choose); it links the experiences, and each experience links its variant entries. Your fetch must + `include` deeply enough to pull those linked entries back in one payload. `include: 10` is the + repository reference implementation's pattern. 4. Pass the same locale to SDK `locale` when Experience API responses and event context must use the same language. 5. Do not pass `contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to @@ -320,12 +380,19 @@ For the broader locale model, see non-optimized entries through unchanged. Invalid, incomplete, or unmatched optimization data falls back to the baseline entry instead of throwing. -1. Pass `entryId` to let the SDK fetch the baseline entry through the configured Contentful client. +The entry source is a discriminated union: pass either `entryId` (managed fetch) or `baselineEntry` +(manual fetch), never both. + +1. Pass `entryId` to let the SDK fetch the baseline entry through the configured Contentful client + (managed fetching, from the [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape) + section). 2. Pass `baselineEntry` when your application already fetched the entry and must keep manual request ownership. 3. Use `loadingFallback`, `errorFallback`, and `onEntryError` when the managed fetch needs visible loading or error handling. -4. Use a render prop when the child needs the resolved baseline or variant entry. +4. Use a render prop — a function passed as the child, which receives the resolved entry and returns + your UI — when the child needs the resolved baseline or variant entry. This is the + `{(resolvedEntry) => ...}` form the quick start used. 5. Use static children only when you need entry tracking but not variant data in the child. 6. Use `useOptimizedEntry()` for the same managed or manual source model without the wrapper component. @@ -355,6 +422,7 @@ function HeroSection() { } function HeroData() { + // isPresentationReady is true once the entry is fetched and resolved and is safe to render. const { entry, isPresentationReady } = useOptimizedEntry({ entryId: 'hero-entry-id' }) if (!isPresentationReady || !entry) return null @@ -371,8 +439,25 @@ function HeroManualData({ baselineEntry }: { baselineEntry: Entry }) { } ``` -The resolved entry has the same field shape as the baseline entry. Downstream renderers can render -the fields without branching on whether the SDK returned a variant. +When you use the `useOptimizedEntry` hook directly, `isPresentationReady` is `true` once the entry +has been fetched (for a managed `entryId`) and resolved, so the returned `entry` is safe to render; +gate your render on it as the example does. `OptimizedEntry` handles this for you and only calls the +render prop when the entry is ready. + +The resolved entry has the same field shape as the baseline entry, handed to the render prop as a +base `contentful` `Entry`. Cast it to your content-type interface in the child +(`resolvedEntry as HeroFields`) when your renderer needs the narrower type; the plain direct cast +works for common narrowed types. Downstream renderers can render the fields without branching on +whether the SDK returned a variant. + +There are two distinct outcomes, and they use different props. The **baseline fallback** is a +resolution outcome on an entry the SDK already has: on denied consent, no matching variant, +unresolved links, or an all-locale payload, the render prop receives the baseline (original) entry +and the UI does not break. A **managed-fetch failure** is different: when `entryId` is used and the +SDK's fetch rejects, there is no entry to resolve, so `onEntryError` fires once and `OptimizedEntry` +renders `errorFallback` instead of the render prop. While a managed `entryId` fetch is unresolved, +`OptimizedEntry` shows `loadingFallback` until the fetch settles; there is no time limit on that +loading window, so provide a `loadingFallback` for any entry the reader waits on. ### Screen and navigation tracking @@ -480,11 +565,17 @@ enabled by default on `OptimizedEntry`. ``` -The default view threshold is 80% visibility for 2000 ms. After the first view event, periodic -duration updates emit every 5000 ms while the entry remains visible. Without +The default view threshold is 80% visibility (`minVisibleRatio` `0.8`) for 2000 ms (`dwellTimeMs`). +After the first view event, periodic duration updates emit every 5000 ms +(`viewDurationUpdateIntervalMs`) while the entry remains visible. Without `OptimizationScrollProvider`, the SDK assumes `scrollY` is `0` and uses the screen height as the viewport, which is appropriate only for non-scrollable or already-visible layouts. +React Native tracks two interactions: entry views and entry taps (there is no hover). A touch counts +as a tap only when the finger moves less than 10 points between touch start and end, so a scroll +gesture that starts on an entry does not register as a tap. On the wire, a tap is delivered as a +`component_click` event. + For event timing, scroll context, tap distance, and backgrounding mechanics, see [React Native SDK interaction tracking mechanics](../concepts/react-native-sdk-interaction-tracking-mechanics.md). @@ -618,8 +709,9 @@ Live-update resolution order is preview panel open, component `liveUpdates` prop **Integration category:** Optional -The preview panel is an in-app authoring and debugging surface. It fetches `nt_audience` and -`nt_experience` entries through the Contentful client you provide, lets users override audiences and +The preview panel is an in-app authoring and debugging surface. It fetches your audience and +experience definitions (`nt_audience` and `nt_experience`, the fixed Optimization content types, not +names you choose) through the Contentful client you provide, lets users override audiences and variants locally, and forces live updates while the panel is open. 1. Install the preview peer dependencies before importing the preview subpath. @@ -659,12 +751,8 @@ function AppContent() { { - // Refresh selected optimizations after local preview overrides change. - void optimization.screen({ - name: 'Preview Refresh', - properties: {}, - screen: { name: 'Preview Refresh' }, - }) + // Re-emit a screen event to refresh selected optimizations after preview overrides. + void optimization.screen({ name: 'Preview Refresh' }) }} /> )} @@ -776,13 +864,17 @@ Use explicit instance ownership only when a framework adapter, test harness, or must create the SDK before React renders. `OptimizationRoot` is the preferred path for normal React Native apps. -1. Create the SDK with `ContentfulOptimization.create(config)`. -2. Pass the instance to `OptimizationProvider sdk={sdk}`. +1. Create the SDK with `await ContentfulOptimization.create(config)`. `create` is `async` and + returns a `Promise` because it reads AsyncStorage before constructing. +2. Pass the resolved instance to `OptimizationProvider sdk={sdk}`. 3. Wrap the tree in the exported `LiveUpdatesProvider` only when preview tooling or global live-update state must work without `OptimizationRoot`. -4. Call `destroy()` from the owner when the instance is no longer needed. `OptimizationProvider` - does not destroy an injected SDK. -5. Do not create a second active React Native SDK instance without destroying the first one. +4. Call `destroy()` from the owner during teardown, once the instance is done. `OptimizationProvider` + does not destroy an injected SDK; `destroy()` clears the singleton so a new instance can be + created. +5. Do not create a second active React Native SDK instance without destroying the first one: + `create` throws `ContentfulOptimization React Native SDK is already initialized. Reuse the +existing instance.` while a live instance exists. 6. Use per-entry `trackViews` and `trackTaps` for interaction policy in injected-provider flows; `trackEntryInteraction` is an `OptimizationRoot` convenience. @@ -818,10 +910,10 @@ behavior beyond the default React Native settings. 2. Use `onEventBlocked` to surface consent or guard failures in diagnostics. 3. Configure `queuePolicy.offlineMaxEvents` and `queuePolicy.onOfflineDrop` when the offline Experience buffer must have explicit bounds and observability. -4. Configure `queuePolicy.flush` only when retry, backoff, or circuit-breaker settings need to - differ from SDK defaults across shared Insights and Experience delivery. -5. Use `api` endpoint overrides and `fetchOptions` for staging, mocks, request timeouts, and retry - callbacks. +4. Configure `queuePolicy.flush` only when flush behavior must differ from SDK defaults across shared + Insights and Experience delivery. +5. Use `api` endpoint overrides and `fetchOptions` for staging, mocks, and request-level options such + as `requestTimeout` and `retries`. **Adapt this to your use case:** @@ -831,18 +923,20 @@ behavior beyond the default React Native settings. clientId="your-optimization-client-id" allowedEventTypes={[]} onEventBlocked={(blocked) => { - diagnostics.log('Optimization event blocked', blocked) + // blocked is { reason, method, args }: the blocked method name and its original arguments. + diagnostics.log('Optimization event blocked', blocked.method) }} queuePolicy={{ // Bound the offline Experience buffer for constrained mobile networks. offlineMaxEvents: 50, onOfflineDrop: ({ droppedCount }) => { + // The callback receives a context object; droppedCount is how many events were dropped. diagnostics.log('Dropped offline Experience events', { droppedCount }) }, }} fetchOptions={{ - requestTimeout: 3000, - retries: 1, + requestTimeout: 3000, // ms before a request times out (default 3000). + retries: 1, // retry attempts on failure (default 1). }} > @@ -859,8 +953,11 @@ React Native platform setup determines which optional SDK behavior is available 2. Use a custom Expo dev build for preview panel dependencies; Expo Go cannot load the required native modules. 3. Keep preview UI out of production builds with build flags or internal distribution channels. -4. For Android local mocks, rewrite host `localhost` URLs to the emulator host alias from - application configuration. +4. When testing against a local mock server on the Android emulator, rewrite `localhost` API hosts + to the emulator host alias `10.0.2.2` in your own app configuration. The SDK has no localhost + rewrite; this is app-config you own. The reference implementation does exactly this in + `env.config.ts` (`localhost` → `10.0.2.2` on Android; the iOS Simulator uses the host network + directly). 5. For release builds, verify that the Contentful CDA host, Experience API host, and Insights API host point to the intended environment. @@ -901,6 +998,7 @@ Before release, verify these checks in the app build and environment that will s | Screen events are missing or duplicated | The app mixes `OptimizationNavigationContainer`, `useScreenTracking`, and manual `screen()` for the same route | Use one screen-tracking path per route and reserve manual `screen()` for custom lifecycle cases | | Preview panel fails to open | Preview peer dependencies are missing, the panel is outside `OptimizationRoot`, or the build uses Expo Go | Install preview peers, mount the panel under `OptimizationRoot`, and use a custom native dev build | | Offline replay does not happen | NetInfo is not installed, the in-memory queue is full, the JavaScript process restarted, or the app is testing against always-online mocks | Install NetInfo, configure queue bounds, and test a real offline-to-online transition without restarting the app process | +| Render prop type error on the entry | The render prop hands back a base `contentful` `Entry`, wider than your content-type interface | Cast the resolved entry to your interface in the child (`resolvedEntry as HeroFields`); the plain direct cast works for common narrowed types | ## Reference implementations to compare against diff --git a/documentation/internal/sdk-knowledge/native/react-native.md b/documentation/internal/sdk-knowledge/native/react-native.md new file mode 100644 index 000000000..bb88179dc --- /dev/null +++ b/documentation/internal/sdk-knowledge/native/react-native.md @@ -0,0 +1,304 @@ +# React Native (`@contentful/optimization-react-native`) — SDK knowledge + + + +> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified +> against packages/\*\*/src during the guide rewrite. + +Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) +and [`../shared/concepts.md`](../shared/concepts.md) (those files are headed "web family"; the +concepts they capture — consent/persistence axes, baseline fallback, entry-source managed-vs-manual, +live updates, single-locale entry contract — apply to the whole suite via the shared `core-sdk`). +This file records only React-Native specifics. The RN SDK subclasses the same `CoreStateful` runtime +as the web SDKs and adds React Native providers/hooks/components, AsyncStorage persistence, optional +NetInfo offline detection, `AppState` background flushing, and an in-app preview panel. Package +source root: `packages/react-native-sdk/src`; shared core: `packages/universal/core-sdk/src`. + +## Package & entry points + +| Import path | Purpose | source | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-react-native` | `OptimizationRoot`, `OptimizationProvider`, `LiveUpdatesProvider`, `OptimizedEntry`, `OptimizationScrollProvider`, `OptimizationNavigationContainer`, all hooks, `ContentfulOptimization` (default-exported class), `OptimizationConfig` type | react-native-sdk#index.ts#OptimizationConfig; react-native-sdk#components/OptimizationRoot.tsx#OptimizationRoot; react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider; react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntry; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| `@contentful/optimization-react-native/preview` | `PreviewPanel` (also default), `PreviewPanelOverlay`, preview sections/primitives/hooks, definition-builder utils, theme | react-native-sdk#preview/index.ts; react-native-sdk#preview/components/PreviewPanelOverlay.tsx#PreviewPanelOverlay; react-native-sdk#preview/components/PreviewPanel.tsx#PreviewPanel | +| `@contentful/optimization-react-native/constants` | `OPTIMIZATION_REACT_NATIVE_SDK_VERSION`, `OPTIMIZATION_REACT_NATIVE_SDK_NAME` | react-native-sdk#constants.ts#OPTIMIZATION_REACT_NATIVE_SDK_VERSION; react-native-sdk#constants.ts#OPTIMIZATION_REACT_NATIVE_SDK_NAME | +| `@contentful/optimization-react-native/core-sdk` | Re-export of `@contentful/optimization-core` | react-native-sdk#core-sdk.ts | +| `@contentful/optimization-react-native/api-client` | Re-export of `@contentful/optimization-core/api-client` | react-native-sdk#api-client.ts | +| `@contentful/optimization-react-native/api-schemas` | Re-export of `@contentful/optimization-core/api-schemas` | react-native-sdk#api-schemas.ts | +| `@contentful/optimization-react-native/logger` | Re-export of `@contentful/optimization-core/logger` (incl. default) | react-native-sdk#logger.ts | +| `@contentful/optimization-react-native/preview-support` | Re-export of `@contentful/optimization-core/preview-support` | react-native-sdk#preview-support.ts | + +## Setup / factory + +- Two entry points: the `OptimizationRoot`/`OptimizationProvider` component path (React creates the + instance) and the `ContentfulOptimization.create(config)` factory (caller creates the instance and + injects it via `OptimizationProvider sdk={sdk}`). `create` is `async` and returns a + `Promise` because it reads AsyncStorage before constructing. + source: react-native-sdk#components/OptimizationRoot.tsx#OptimizationRoot; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Config type is `CoreStatefulConfig` (aliased as `OptimizationConfig`); only `clientId` is + required, and its keys are: `clientId` (required), `environment?`, `fetchOptions?` (from + `api-client` `ApiConfig`); + `locale?`, `logLevel?`, `contentful?`, `eventBuilder?` (from `CoreConfig`); `api?` + (`experienceBaseUrl`, `insightsBaseUrl`, `enabledFeatures`, `ip`, `plainText`, `preflight`), + `allowedEventTypes?`, `defaults?` (`consent`, `persistenceConsent`, `profile`, `changes`, + `selectedOptimizations`), `getAnonymousId?`, `onEventBlocked?`, `queuePolicy?` (`flush`, + `offlineMaxEvents`, `onOfflineDrop`) — all from `CoreStatefulConfig`. + source: react-native-sdk#index.ts#OptimizationConfig; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#CoreBase.ts#CoreConfig; core-sdk#CoreApiConfig.ts#CoreStatefulApiConfig; core-sdk#StatefulDefaults.ts#StatefulDefaults; core-sdk#CoreStateful.ts#QueuePolicy; api-client#ApiClientBase.ts#ApiConfig +- `environment` default: when `environment` is omitted, the API client uses `main` + (`DEFAULT_ENVIRONMENT`) for Experience/Insights/CDA requests. There is no separate SDK-side + default; the fallback lives in `api-client` `ApiClientBase`. + source: api-client#ApiClientBase.ts#DEFAULT_ENVIRONMENT; api-client#ApiClientBase.ts#ApiConfig +- `fetchOptions` shape (`api-client` `ApiConfig`, typed `Omit`): `requestTimeout?` (ms, default `3000`), `retries?` (max retry attempts, default `1`), + `intervalTimeout?` (delay between retries in ms, default `0`), `fetchMethod?` (custom fetch), + `onRequestTimeout?`, `onFailedAttempt?`. source: api-client#ApiClientBase.ts#ApiConfig; api-client#fetch/createProtectedFetchMethod.ts#ProtectedFetchMethodOptions; api-client#fetch/createTimeoutFetchMethod.ts#TimeoutFetchMethodOptions; api-client#fetch/createRetryFetchMethod.ts#RetryFetchMethodOptions +- Queue/blocked callback argument shapes: `queuePolicy.onOfflineDrop(context)` receives an + `ExperienceQueueDropContext` object — `{ droppedCount, droppedEvents (oldest-first), maxEvents, +queuedEvents }` — not a single dropped event. `onEventBlocked(event)` receives a `BlockedEvent` — + `{ reason: 'consent', method: string, args: readonly unknown[] }` (the blocked method name and its + original call arguments). source: core-sdk#CoreStateful.ts#QueuePolicy; core-sdk#queues/ExperienceQueue.ts#ExperienceQueueDropContext; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#events/BlockedEvent.ts#BlockedEvent +- `OptimizationRootProps extends CoreStatefulConfig` and adds `liveUpdates?: boolean` (default + `false`), `trackEntryInteraction?: TrackEntryInteractionOptions` (default `{ views: true, +taps: true }`), `onStatesReady?`, and `children`. `OptimizationRoot` composes + `OptimizationProvider` + `LiveUpdatesProvider` + `InteractionTrackingProvider`; the extra props are + stripped and the rest of the config is forwarded to `OptimizationProvider`. + source: react-native-sdk#components/OptimizationRoot.tsx#OptimizationRootProps; react-native-sdk#components/OptimizationRoot.tsx#OptimizationRoot; react-native-sdk#context/InteractionTrackingContext.tsx#TrackEntryInteractionOptions +- `OptimizationProvider` props are a union: config form (`OptimizationProviderConfigProps extends +OptimizationConfig`, `sdk?: never`) or injected form (`OptimizationProviderSdkProps`, `sdk: +OptimizationSdk`). `onStatesReady(states)` runs once after state init and before children mount; + returning a function registers a teardown cleanup. + source: react-native-sdk#components/OptimizationProvider.tsx#OptimizationProviderConfigProps; react-native-sdk#components/OptimizationProvider.tsx#OptimizationProviderSdkProps; react-native-sdk#components/OptimizationProvider.tsx#OnStatesReady +- `ContentfulOptimization.create(config)` merges RN defaults over the caller config: `eventBuilder` + `channel: 'mobile'` + library name/version; AsyncStorage-backed `defaults` and `getAnonymousId`; + `logLevel` forced to `debug` when the stored debug flag is set; and `allowedEventTypes` defaulting + to `['identify', 'screen']` when the caller omits it. + source: react-native-sdk#ContentfulOptimization.ts#mergeConfig +- Single active instance: `ContentfulOptimization.create` throws `ContentfulOptimization React +Native SDK is already initialized. Reuse the existing instance.` if a live instance already exists. + `destroy()` (or a failed post-init persistence) clears the singleton so a new instance can be + created. source: react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Managed entry fetching: `contentful?: ContentfulConfig` (`{ client, defaultQuery?, cache? }`) on + the root/provider config enables `` / `useOptimizedEntry({ entryId })` to + fetch by ID through the app's `contentful.js` client. See + [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual) for the + managed-vs-manual boundary, cache defaults (`{ maxEntries: 100, ttlMs: 300_000 }`), and query + merge (`defaultQuery` + per-call + SDK-locale fallback + `include: 10`). + source: core-sdk#CoreBase.ts#ContentfulConfig; core-sdk#CoreBase.ts#ContentfulEntryClient; core-sdk#CoreBase.ts#fetchContentfulEntry + +## Components & hooks + +| Name | Kind | Import path | Key props/args | Returns | source | +| -------------------------------------- | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OptimizationRoot` | component | root | `CoreStatefulConfig` + `liveUpdates?`, `trackEntryInteraction?`, `onStatesReady?`, `children` | provider tree element | react-native-sdk#components/OptimizationRoot.tsx#OptimizationRoot | +| `OptimizationProvider` | provider | root | config OR `sdk={instance}`; `onStatesReady?` | `null` while provider-owned init pending, else context provider element | react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider | +| `LiveUpdatesProvider` | provider | root | `globalLiveUpdates?`, `children` | context provider element | react-native-sdk#context/LiveUpdatesContext.tsx#LiveUpdatesProvider | +| `OptimizedEntry` | component | root | discriminated union `baselineEntry` (manual) XOR `entryId` (+`entryQuery?`, managed); render-prop OR static `children`; `loadingFallback?`, `errorFallback?`, `onEntryError?`, `onEntryResolved?`, `liveUpdates?`, `trackViews?`, `trackTaps?`, `onTap?`, `dwellTimeMs?`, `minVisibleRatio?`, `viewDurationUpdateIntervalMs?`, `style?`, `testID?` | `View` element, or `null`/fallback | react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntry; react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntryProps | +| `OptimizationScrollProvider` | provider | root | `ScrollViewProps` + `children` (wraps a `ScrollView`) | `ScrollView` wrapped in scroll context | react-native-sdk#context/OptimizationScrollContext.tsx#OptimizationScrollProvider | +| `OptimizationNavigationContainer` | component | root | render prop receiving `{ ref, onReady, onStateChange }`; `onReady?`, `onStateChange?`, `includeParams?` (default `false`) | rendered children | react-native-sdk#components/OptimizationNavigationContainer.tsx#OptimizationNavigationContainer | +| `useOptimization` | hook | root | — | `OptimizationSdk` instance; **throws** outside a provider, or if init failed / still initializing | react-native-sdk#context/OptimizationContext.tsx#useOptimization | +| `useOptimizedEntry` | hook | root | same union as `OptimizedEntry` + `liveUpdates?`, `onEntryError?`, `onEntryResolved?` | `{ entry, baselineEntry, error?, isLoading, isPresentationReady, isResolved, metadata?, selectedOptimization, resolvedData, selectedOptimizations }` | react-native-sdk#hooks/useOptimizedEntry.ts#useOptimizedEntry; react-native-sdk#hooks/useOptimizedEntry.ts#UseOptimizedEntryResult | +| `useEntryResolver` | hook | root | — | `{ resolveEntry, resolveEntryData, resolveOptimizedEntry }` (manual-only helpers; default `selectedOptimizations` = current SDK state) | react-native-sdk#hooks/useEntryResolver.ts#useEntryResolver; react-native-sdk#hooks/useEntryResolver.ts#UseEntryResolverResult | +| `useScreenTracking` | hook | root | `{ name, properties?, trackOnMount? (default true) }` | `{ trackScreen: () => Promise }` | react-native-sdk#hooks/useScreenTracking.ts#useScreenTracking; react-native-sdk#hooks/useScreenTracking.ts#UseScreenTrackingOptions | +| `useScreenTrackingCallback` | hook | root | — | `(name, properties?) => void` (imperative direct `screen()` emit) | react-native-sdk#hooks/useScreenTracking.ts#useScreenTrackingCallback | +| `useViewportTracking` | hook | root | `{ entry, selectedOptimization?, optimizationContextId?, minVisibleRatio?, dwellTimeMs?, viewDurationUpdateIntervalMs?, enabled? }` | `{ isVisible, onLayout }` | react-native-sdk#hooks/useViewportTracking.ts#useViewportTracking; react-native-sdk#hooks/useViewportTracking.ts#UseViewportTrackingOptions | +| `useTapTracking` | hook | root | `{ entry, selectedOptimization?, optimizationContextId?, enabled, onTap? }` | `{ onTouchStart, onTouchEnd }` (both `undefined` when disabled) | react-native-sdk#hooks/useTapTracking.ts#useTapTracking; react-native-sdk#hooks/useTapTracking.ts#UseTapTrackingOptions | +| `useLiveUpdates` | hook | root | — | `LiveUpdatesContextValue` (`{ globalLiveUpdates, previewPanelVisible, setPreviewPanelVisible }`) or `null` | react-native-sdk#context/LiveUpdatesContext.tsx#useLiveUpdates | +| `useScrollContext` | hook | root | — | `{ scrollY, viewportHeight }` or `null` | react-native-sdk#context/OptimizationScrollContext.tsx#useScrollContext | +| `useInteractionTracking` | hook | root | — | `{ views, taps }` resolved booleans | react-native-sdk#context/InteractionTrackingContext.tsx#useInteractionTracking | +| `PreviewPanel` / `PreviewPanelOverlay` | component | `/preview` | `{ contentfulClient, onRefresh?, showHeader?, style?, onVisibilityChange? }`; overlay adds `fabPosition?` and omits `style` | panel / floating-button + modal element | react-native-sdk#preview/components/PreviewPanel.tsx#PreviewPanel; react-native-sdk#preview/components/PreviewPanelOverlay.tsx#PreviewPanelOverlay; react-native-sdk#preview/types.ts#PreviewPanelProps | + +There is no `useOptimizationConsentState` public hook; consent-state subscription is an internal +helper (`useOptimizationConsentState`) used by the tracking hooks to re-check `hasConsent()` when +consent changes. source: react-native-sdk#hooks/useOptimizationConsentState.ts#useOptimizationConsentState + +## Render / entry resolution + +- Entry source (managed or manual): `OptimizedEntry` / `useOptimizedEntry` take a discriminated + union — `baselineEntry` (app fetched: manual) XOR `entryId` + optional `entryQuery` (SDK fetches + via `contentful.client`: managed), never both. Managed fetch is driven by + `OptimizedEntrySourceController`; without `contentful.client` the managed path has no client. See + [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual). + source: react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntryProps; react-native-sdk#hooks/useOptimizedEntry.ts#UseOptimizedEntryParams; core-sdk#OptimizedEntrySourceController.ts#OptimizedEntrySourceController +- Render prop = `(resolvedEntry: Entry, metadata: OptimizedEntryMetadata) => ReactNode`; + `resolvedEntry` is a base `contentful` `Entry` (cast to a narrower type in app code). `metadata` + carries `baselineEntry`, `baselineEntryId`, `entry`, `entryId`, `optimizationContextId`, + `resolvedData`, `selectedOptimization`, `selectedOptimizations`. Static (non-function) children are + rendered as-is (tracking only, no variant data). + source: react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntryProps; core-sdk#OptimizedEntryMetadata.ts#OptimizedEntryMetadata +- Loading model (RN, no server first paint): while a managed `entryId` fetch is unresolved, + `useOptimizedEntry` returns `entry === undefined` / `metadata === undefined`, so `OptimizedEntry` + renders `loadingFallback`. On resolved data it wraps children in a `View` carrying `onLayout` + (viewport tracking) and `onTouchStart`/`onTouchEnd` (tap tracking). There is no fixed + baseline-reveal timeout (unlike the web SDK). + source: react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntry; react-native-sdk#hooks/useOptimizedEntry.ts#useOptimizedEntry +- Resolution itself: non-optimized entries pass through unchanged; optimized entries + (`isResolvedOptimizedEntry`) resolve via `sdk.resolveOptimizedEntry(baselineEntry, +selectedOptimizations)`. Single-locale CDA contract and baseline fallback are shared: see + [`../shared/concepts.md`](../shared/concepts.md#entry-resolution) and + [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: react-native-sdk#hooks/useOptimizedEntry.ts#useOptimizedEntry; core-sdk#CoreBase.ts#resolveOptimizedEntry; concept:entry-personalization-and-variant-resolution +- `useEntryResolver` returns manual-only helpers (`resolveEntry` → resolved `Entry`; + `resolveEntryData`/`resolveOptimizedEntry` → full `ResolvedData`). When `selectedOptimizations` is + omitted, helpers use the current `sdk.states.selectedOptimizations.current`. + source: react-native-sdk#hooks/useEntryResolver.ts#useEntryResolver +- Include-depth requirement (resolver-driven): the resolver reads SDK-fixed Optimization fields off + the fetched entry, so the fetch (managed or manual) needs `include` deep enough to resolve them. + Chain: the optimized entry's `nt_experiences` array (→ `nt_experience` content-type entries), and + within each of those the linked `nt_variants` array (→ variant entries, the app's own content + type) and `nt_audience` link (→ `nt_audience` content-type entry). `nt_config` is a JSON/object + field on the `nt_experience` entry (not a linked entry, so it does not consume include depth). The + managed path sends `include: 10` by default; a manual fetch must set a comparable depth. + source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver; api-schemas#contentful/OptimizedEntry.ts#OptimizedEntryFields; api-schemas#contentful/OptimizationEntry.ts#OptimizationEntryFields + +## Identifier ownership + +| Identifier | Owner | Notes | source | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `__ctfl_opt_anonymous_id__` (AsyncStorage) | SDK | Anonymous/profile id. Written only when `persistenceConsent === true`; the default `getAnonymousId` reads it only then, else returns `undefined`. Set to `profile.id` on continuity writes. | react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; core-sdk#constants.ts#ANONYMOUS_ID_KEY; react-native-sdk#ContentfulOptimization.ts#mergeConfig | +| `__ctfl_opt_consent__`, `__ctfl_opt_persistence_consent__`, `__ctfl_opt_debug__` (AsyncStorage) | SDK | Consent state; persisted on every consent change (debug flag also toggles `logLevel` to `debug`). | react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; core-sdk#constants.ts#CONSENT_KEY; core-sdk#constants.ts#PERSISTENCE_CONSENT_KEY; core-sdk#constants.ts#DEBUG_FLAG_KEY | +| `__ctfl_opt_profile__`, `__ctfl_opt_changes__`, `__ctfl_opt_selected-optimizations__` (AsyncStorage) | SDK | Profile-continuity cache; loaded/written only when `persistenceConsent === true`, cleared when it is `false`. | react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; core-sdk#constants.ts#PROFILE_CACHE_KEY; core-sdk#constants.ts#CHANGES_CACHE_KEY; core-sdk#constants.ts#SELECTED_OPTIMIZATIONS_CACHE_KEY | +| app-owned anonymous id | reader | Optional `getAnonymousId` config lets the app supply an approved anonymous id instead of the AsyncStorage default. | core-sdk#CoreStateful.ts#CoreStatefulConfig | +| consent record / CMP choice | reader | App records the user's choice and calls `consent(...)`; SDK reflects only what is passed. | concept:consent-management-in-the-optimization-sdk-suite; core-sdk#CoreStateful.ts#consent | +| `nt_experience`, `nt_audience` (Contentful content types); `nt_experiences`, `nt_variants`, `nt_audience`, `nt_config`, `nt_experience_id` (fields) | SDK | SDK-fixed Optimization content-model identifiers the resolver keys off — not reader-chosen. `nt_experiences` is the array field on an optimized entry linking `nt_experience` entries; each `nt_experience` carries `nt_variants` (variant links), `nt_audience` (link to an `nt_audience` entry), `nt_config` (JSON), and `nt_experience_id`. | api-schemas#contentful/OptimizedEntry.ts#OptimizedEntrySkeleton; api-schemas#contentful/OptimizationEntry.ts#OptimizationEntrySkeleton; api-schemas#contentful/AudienceEntry.ts#AudienceEntrySkeleton | + +RN uses AsyncStorage keys (`ctfl-opt-*` prefixed with `__…__`), not the browser `ctfl-opt-aid` +cookie the web SDKs use; there is no built-in cross-platform cookie handoff. +source: core-sdk#constants.ts#ANONYMOUS_ID_KEY; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE + +## Events & tracking + +- Screen events: `useScreenTracking({ name })` auto-tracks on mount (unless `trackOnMount: false`) + via `trackCurrentScreen`, which dedupes by `routeKey` (defaults to `screen.name`/`name`) through + an `AcceptedCurrentStateTracker` — a repeat of the same current screen is skipped. The returned + `trackScreen()` and `useScreenTrackingCallback()` call `screen()` directly (no dedupe). + source: react-native-sdk#hooks/useScreenTracking.ts#useScreenTracking; react-native-sdk#ContentfulOptimization.ts#TrackCurrentScreenPayload; core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker +- `OptimizationNavigationContainer` calls `trackCurrentScreen` on ready and on route-key change; it + builds `routeKey` from the screen name, appending JSON-validated params only when + `includeParams` is true. It skips tracking when `hasConsent('screen')` is false and re-tracks the + current route when consent changes. source: react-native-sdk#components/OptimizationNavigationContainer.tsx#OptimizationNavigationContainer; react-native-sdk#components/OptimizationNavigationContainer.tsx#createScreenTrackingDescriptor +- Entry view tracking (`useViewportTracking`): defaults `minVisibleRatio = 0.8`, `dwellTimeMs = +2000`, `viewDurationUpdateIntervalMs = 5000`. Lifecycle per visibility cycle: initial `trackView` + after accumulated visible time ≥ `dwellTimeMs`, periodic duration updates every + `viewDurationUpdateIntervalMs`, and a final event on visibility end (only if ≥1 event already + fired). Visibility uses `useScrollContext` when present, else screen `Dimensions` (assumes + `scrollY = 0`, screen height as viewport). Time accumulation pauses on `AppState` background/ + inactive; a final event is emitted when backgrounded mid-cycle. Consent-gated by + `hasConsent('trackView')`. source: react-native-sdk#hooks/useViewportTracking.ts#useViewportTracking; core-sdk#tracking/EntryViewTracking.ts#resolveEntryViewTimingOptions; core-sdk#tracking/EntryViewTracking.ts#getRemainingMsUntilNextEntryViewFire +- Entry tap tracking (`useTapTracking`): uses `onTouchStart`/`onTouchEnd` (not a wrapping + `Pressable`) so taps register even when a child handles the gesture; a touch counts as a tap only + when movement `< TAP_DISTANCE_THRESHOLD` (10 points). Emits wire type `component_click` via + `trackClick` when `hasConsent('trackClick')`, then calls `onTap(resolvedEntry)` if provided. + source: react-native-sdk#hooks/useTapTracking.ts#useTapTracking; react-native-sdk#hooks/useTapTracking.ts#TAP_DISTANCE_THRESHOLD +- Interaction defaults/overrides: `OptimizedEntry` view+tap tracking is on by default. Global + opt-out via `OptimizationRoot` `trackEntryInteraction={{ views?, taps? }}` (RN uses `taps`, not + `clicks`, and has no hovers); per-entry `trackViews`/`trackTaps` override, and supplying `onTap` + keeps taps enabled unless `trackTaps` is explicitly `false`. Tracking uses the RESOLVED entry id + and the runtime-owned `optimizationContextId`. + source: react-native-sdk#context/InteractionTrackingContext.tsx#TrackEntryInteractionOptions; react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntry; react-native-sdk#context/InteractionTrackingContext.tsx#EntryInteraction +- `identify({ userId, traits? })`: inherited from Core (`CoreStatefulEventEmitter.identify`), returns + `Promise`. Arg is `IdentifyBuilderArgs & { profile? }`: `userId` (required + string), optional `traits` (record, merged server-side), plus the universal event fields + (`campaign?`, `locale?`, `location?`, `page?`, `screen?`, `userAgent?`). `identify` is in the RN + default pre-consent allow-list, so it emits before event consent. + source: core-sdk#CoreStatefulEventEmitter.ts#CoreStatefulEventEmitter; core-sdk#events/EventBuilder.ts#IdentifyBuilderArgs; core-sdk#events/EventBuilder.ts#UniversalEventBuilderArgs +- Analytics forwarding: subscribe via `onStatesReady` (registers before child effects emit). Read + `states.eventStream` for accepted events (dedupe by `messageId`) and `states.blockedEventStream` + for consent/allow-list diagnostics. Flags: `states.flag(name)` is a reactive observable; + `getFlag(name)` a non-reactive read. source: core-sdk#CoreStateful.ts#CoreStates; core-sdk#CoreStatefulEventEmitter.ts#getFlag + +## Consent & persistence + +- Model: two independent axes `consent` (may personalize + emit events) and `persistenceConsent` + (may store profile-continuity), see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). + Boolean `consent(true|false)` sets both axes; object form `consent({ events?, persistence? })` sets + them independently (accepts `ConsentInput`). `resolveStatefulDefaults` derives + `persistenceConsent` from `defaults.persistenceConsent ?? defaults.consent`. + source: core-sdk#CoreStateful.ts#consent; core-sdk#consent/Consent.ts#ConsentInput; core-sdk#StatefulDefaults.ts#resolveStatefulDefaults +- RN default pre-consent allow-list = `['identify', 'screen']` (set in `mergeConfig`, overriding + Core's fail-closed `DEFAULT_ALLOWED_EVENT_TYPES = []`). Before event consent, `identify` and + `screen` emit; entry views (`component`), entry taps (`component_click`), `page`, and custom + `track` are blocked until consent is accepted or their selectors are allow-listed. `hasConsent` + maps `trackView`→`component`, `trackClick`→`component_click`. + source: react-native-sdk#ContentfulOptimization.ts#mergeConfig; core-sdk#events/EventType.ts#DEFAULT_ALLOWED_EVENT_TYPES; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- AsyncStorage always persists consent state (consent, persistence consent, debug flag). It persists + profile-continuity (anonymous id, profile, changes, selected optimizations) only when + `persistenceConsent === true`, and clears it when `false`. It does NOT persist event queues (queues + are in-memory). `consent(...)` and `reset()`/`destroy()` enqueue the appropriate persistence + writes; `reset()` also clears profile continuity and the current-screen dedupe tracker. + source: react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Persisted consent is decoded via `decodeConsentStorageValue`; persisted persistence-consent falls + back to the persisted event consent through `resolvePersistedPersistenceConsent`. + source: core-sdk#consent/ConsentStorage.ts#decodeConsentStorageValue; core-sdk#consent/ConsentStorage.ts#resolvePersistedPersistenceConsent +- AsyncStorage is read only during startup: `AsyncStorage.multiGet` runs only in + `initializeConsentState` / `initializeProfileContinuity` (each guarded by a one-shot initialized + flag, invoked from `mergeConfig` during `create`). After startup, live SDK state comes from Core + signals (`signals.consent`, `signals.profile`, `signals.selectedOptimizations`, `signals.changes`); + the store's getters read only its in-memory `cache` Map. State changes write through to + AsyncStorage but runtime reads never hit it. + source: react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; react-native-sdk#ContentfulOptimization.ts#mergeConfig; core-sdk#CoreStateful.ts#CoreStates + +## Version / runtime quirks + +- No server first paint: an owned instance is created after React commits (async, reads AsyncStorage + first), so the SDK is not ready on the first render. Provider-owned `OptimizationProvider` returns + `null` (withholds children) until the instance is ready or init errored. An injected `sdk` with no + `onStatesReady` is ready from the first render. + source: react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider +- Instance ownership: `OptimizationProvider` `destroy()`s on unmount only an instance it created + itself (`ownsSdkRef` is set only on the `ContentfulOptimization.create` path). An injected + `sdk={instance}` is NOT destroyed by the provider — the owner that created it must call `destroy()` + (which clears the singleton so a new instance can be created). + source: react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Config is captured on first render EXCEPT `locale`: changing the provider `locale` prop calls + `sdk.setLocale(nextLocale)`, updating Experience API and event locale. The SDK does NOT refetch + Contentful entries or refresh profile state; the app re-fetches and re-emits. Full re-init requires + changing the React `key`. source: react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider; core-sdk#CoreStateful.ts#setLocale +- Live-update precedence: preview panel open → per-entry `liveUpdates` prop → root `liveUpdates` + prop → default locked-to-first-variant. Opening `PreviewPanelOverlay` sets + `previewPanelVisible`, forcing live updates while open. See + [`../shared/concepts.md`](../shared/concepts.md#live-updates). + source: react-native-sdk#hooks/useOptimizedEntry.ts#useOptimizedEntry; react-native-sdk#preview/components/PreviewPanelOverlay.tsx#PreviewPanelOverlay +- Peer dependencies: `@react-native-async-storage/async-storage` is required (imported directly by + the storage adapter); `@react-native-community/netinfo`, `@react-native-clipboard/clipboard`, and + `react-native-safe-area-context` are optional (`peerDependenciesMeta.optional`). + source: react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore; extern:@contentful/optimization-react-native package.json peerDependenciesMeta marks netinfo, clipboard, safe-area-context optional +- Offline detection is opt-in via NetInfo: `createOnlineChangeListener` dynamically imports + `@react-native-community/netinfo`; when present it gates flushing on connectivity + (`isInternetReachable ?? isConnected ?? true`); when absent it logs + `@react-native-community/netinfo not installed. Offline detection disabled.` and no-ops. Offline + replay is in-memory only (no durable outbox). source: react-native-sdk#handlers/createOnlineChangeListener.ts#createOnlineChangeListener +- Background flush: `createAppStateChangeListener` calls the SDK's `flush()` then drains pending + AsyncStorage writes on `AppState` `background`/`inactive`, before the OS can suspend the process. + source: react-native-sdk#handlers/createAppStateChangeListener.ts#createAppStateChangeListener; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Polyfills: importing the package entry runs side-effect imports for `crypto.randomUUID` + (`react-native-get-random-values` + `react-native-uuid`) and ES2025 iterator helpers, plus a + `*.png` module declaration. source: react-native-sdk#index.ts#OptimizationConfig; react-native-sdk#polyfills/crypto.ts +- Preview panel: `PreviewPanelOverlay`/`PreviewPanel` are on the `/preview` subpath, need the + optional clipboard + safe-area peers, and fetch `nt_audience`/`nt_experience` entries through the + supplied `contentfulClient`. Expo apps require a custom dev build (`expo run:ios`/`expo +run:android`) — Expo Go cannot load the native preview modules. + source: react-native-sdk#preview/components/PreviewPanelOverlay.tsx#PreviewPanelOverlay; react-native-sdk#preview/types.ts#PreviewPanelProps; extern:Expo Go cannot load the custom native modules the preview panel needs, so Expo apps require a custom dev build +- Android emulator localhost: the SDK itself has no localhost rewrite; the reference implementation + rewrites `localhost` API hosts to `10.0.2.2` (`ANDROID_LOCALHOST`) only when `Platform.OS === +'android'`. On iOS the URL is left unchanged, because the iOS Simulator shares the host machine's + network and reaches host `localhost` directly. source: impl:react-native-sdk#env.config.ts; extern:the iOS Simulator uses the host machine network directly, so host localhost resolves without a rewrite + +## Failure & fallback behavior + +- Baseline fallback on denied consent / no matching variant / unresolved links / all-locale + payloads: the render prop receives the baseline entry and the UI does not break. See + [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). + source: react-native-sdk#hooks/useOptimizedEntry.ts#useOptimizedEntry; concept:entry-personalization-and-variant-resolution +- Managed-fetch failure: when `entryId` is used and `fetchContentfulEntry` rejects, the source + snapshot carries `error`; `onEntryError(error)` fires once and `OptimizedEntry` renders + `errorFallback` (distinct from baseline fallback, which is a resolution outcome on a present + entry). source: core-sdk#OptimizedEntrySourceController.ts#OptimizedEntrySourceSnapshot; react-native-sdk#components/OptimizedEntry.tsx#OptimizedEntry +- SDK init failure: `OptimizationProvider` renders children with an `error` in context; + `useOptimization()` (and any tracking hook that calls it) throws + `ContentfulOptimization SDK failed to initialize: …`, so consumers must sit under a provider that + handles init failure. source: react-native-sdk#components/OptimizationProvider.tsx#OptimizationProvider; react-native-sdk#context/OptimizationContext.tsx#useOptimization +- `PreviewPanelOverlay` throws `PreviewPanelOverlay must be rendered inside OptimizationRoot, or +inside both OptimizationProvider and LiveUpdatesProvider.` when no `LiveUpdatesContext` is present. + source: react-native-sdk#preview/components/PreviewPanelOverlay.tsx#PreviewPanelOverlay +- AsyncStorage read/write failures are caught and logged; the SDK continues with in-memory state + (schema-invalid cached values are invalidated and removed). + source: react-native-sdk#storage/AsyncStorageStore.ts#AsyncStorageStore diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index f5e4179f0..cf0bd0826 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -14,10 +14,18 @@ add per-archetype checks. touches). The definition is in prose (or the intro explainer), not only inside a code comment — a reader who skims prose and diffs must still meet the term defined. - [ ] **Every result-shape field the reader branches on or passes downstream is defined at first - use.** A key the guide tells the reader to check (`accepted`, `data`) or hand to render logic - (`profile`, `selectedOptimizations`, `changes`) gets a plain-prose definition at first use, not - only a code comment. This extends the term rule above from domain nouns to the fields of the - SDK's return envelope. + use.** A key the guide tells the reader to check (`accepted`, `data`, `isPresentationReady`) or + hand to render logic (`profile`, `selectedOptimizations`, `changes`) gets a plain-prose + definition at first use, not only a code comment. This extends the term rule above from domain + nouns to the fields of the SDK's return envelope. It applies wherever the noun first appears in + prose, even in a section far from the feature that owns it (e.g. `selected optimizations` and + `changes` first surfacing in a Consent or Identity section) — define it there, do not defer to + the owning feature section below. +- [ ] **Every SDK-fixed content-model identifier is glossed and its ownership stated at first use.** + A fixed Contentful content-type or field name the SDK relies on (`nt_experiences`, `nt_audience`, + `nt_experience`) gets a one-line plain-language gloss and a statement that it is SDK-owned, not a + name the reader chooses — so a reader cannot mistake it for reader-invented configuration. This + is the content-model case of the magic-value ownership rule in this group. - [ ] **Near-identical or shorthand identifiers are disambiguated once.** When two names differ by one letter and mean different things (`selectedOptimization` the single returned selection vs `selectedOptimizations` the set passed in), or a shorthand form coexists with a longhand one @@ -56,9 +64,15 @@ add per-archetype checks. forward to the section that defines them. A helper referenced but defined nowhere in the guide (a `getAppLocale`-style phantom) is a defect: define it once or inline the expression the guide already teaches. -- [ ] **No orphan cross-SDK API.** The guide does not name a method a given runtime never calls - (e.g. `screen()` in a Node guide) inside a warning, "do not do X" list, or table without - introducing it in a flow first. Warn only about methods the guide actually teaches. +- [ ] **No orphan cross-SDK API or event name.** The guide does not name a method or event a given + runtime never emits (e.g. `screen()` in a Node guide, or `page` / `clicks` in a React Native + guide that emits `screen` / `taps`) inside a warning, "do not do X" list, blocked-events list, + or table without introducing it in a flow first. Warn only about methods and event names the + guide actually teaches; a blocked-events list names only event types this runtime can emit. +- [ ] **`render prop` is defined in prose at first use.** The function-as-child pattern is named and + explained in plain language the first time prose calls it a "render prop" (React-pattern + vocabulary is not assumed for the target reader), tied back to the `{(entry) => ...}` form the + reader already copied. - [ ] **Source language is consistent, or the switch is announced with run instructions.** A guide does not silently switch source language (e.g. a `.mjs` quick start run with `node` followed by TypeScript body snippets) without a prose note stating the switch and how to run the new form. @@ -77,15 +91,28 @@ add per-archetype checks. - [ ] Every `###` feature section has a correct `**Integration category:**` line, and its category matches its parent `##` (Required/Common under Core; Optional under Optional; Advanced under Advanced). -- [ ] The quick start proves exactly one result and verifies only that result. +- [ ] **A Core section that revisits a quick-start step opens with a bridge, not a verbatim recap.** + The first feature section (typically install/initialize) does not repeat the quick start's + install/mount steps word-for-word; it opens by naming what is genuinely new below (the full + config surface, extra props) so a skimming reader is not invited to skip past new material. +- [ ] The quick start proves exactly one result and verifies only that result. If the intro or quick + start promises more than one observable result (e.g. "one entry resolving and one screen + event"), the verify step confirms every promised result where the reader hits it — or the quick + start is narrowed to a single proof. A promised proof with no matching verification is a defect. - [ ] **The quick start is grounded in a real app shape** — no invented fetch shapes (e.g. a hardcoded array of entry IDs). The most common real shape leads; other shapes are pointed to a feature section. -- [ ] **Files the reader already owns (layout, providers, renderer) are shown as `+`/`-` diffs that - preserve existing content**, not full files to paste over, and not with the additions blended - invisibly into a rewritten file. The `+` lines must be unambiguously the additions. A short - prose note states the surrounding code is illustrative context to match against, not a block - to paste verbatim. +- [ ] **Files the reader already owns (layout, providers, renderer, and the runtime root component + such as `App.tsx`) are shown as `+`/`-` diffs that preserve existing content**, not full files + to paste over, and not with the additions blended invisibly into a rewritten file. A reader's + app root is something they always already own, so a full standalone `App` is never a + `**Copy this:**` paste-over. The `+` lines must be unambiguously the additions. A short prose + note states the surrounding code is illustrative context to match against, not a block to paste + verbatim. +- [ ] **Native integration guides put the native build step inline in the quick start.** For iOS / + Android / React Native targets, the native install step (`pod install`, `npx expo prebuild`, or + equivalent) that must run before the app launches appears in the quick start where the reader + installs, not deferred to `## Before you start` — the reader needs it before the verify step. - [ ] The entry-wrap example shows any cast the render prop requires and states the fallback contract; the cast matches the matching reference implementation (verify against `implementations/`). From 9e121fcd48292dc6d78ca88f06bb6884eb53cbab Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 16:59:29 +0200 Subject: [PATCH 29/42] =?UTF-8?q?=F0=9F=93=9D=20docs(workflow):=20Act=20on?= =?UTF-8?q?=20RN-run=20feedback=20=E2=80=94=20refresh=20path,=20ESCALATE?= =?UTF-8?q?=20convention,=20review=20overlap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React Native run surfaced real workflow-spec gaps; fix them: - Make "refresh an existing guide" a first-class sub-case of /author-guide. The command's premise is "the KB has no file yet" — but the guide often already exists (Node and RN were both 900+ lines). Step 2 now branches new-vs-refresh explicitly so an operator doesn't expect a blank compose. - Formalize the escalation handoff. The writer had invented an ad-hoc marker; define it in the author-guide/guide-writer skills and enforce that none survives to a finished guide — the validator now scans both the KB and documentation/guides/** and fails on any ESCALATE marker (and the CI knowledge filter now includes guides so a guide-only change triggers it). - Resolve the review-loop overlap. /review-guide is the self-contained review/fix/funnel/validate loop; /author-guide and /refresh-docs now delegate to it in ONE pass and only add their mode-specific gate checks, instead of describing the same loop twice. Also clarified it is a skill/command, not a stray file to hunt for. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-writer.md | 9 ++- .claude/commands/author-guide.md | 84 +++++++++++--------- .claude/commands/refresh-docs.md | 13 +-- .claude/commands/review-guide.md | 5 ++ .github/workflows/main-pipeline.yaml | 1 + scripts/validate-sdk-knowledge.ts | 27 +++++++ skills/optimization-guide-authoring/SKILL.md | 12 +-- 7 files changed, 102 insertions(+), 49 deletions(-) diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md index 92575ab3b..8461c35bc 100644 --- a/.claude/agents/guide-writer.md +++ b/.claude/agents/guide-writer.md @@ -15,8 +15,13 @@ block, and self-review checklist are your source of truth. Compose every SDK claim from the internal knowledge base (`documentation/internal/sdk-knowledge/`), which holds facts already verified against source — read facts, do not re-grep `packages/**/src`. Use the matching reference implementation under `implementations/` for real-shaped patterns and "adapt" -starting points. If the base is missing a fact you need, escalate to `sdk-knowledge-authoring` to add -it from source, then compose from that fact; do not verify source yourself. +starting points. + +If the base is missing a fact you need, do not verify source yourself — **escalate** with an inline +marker at the point of use: ``. The +`sdk-knowledge-author` adds it from source; you then compose the claim from that fact and **delete the +marker**. The marker is a transient handoff and must never ship — `pnpm knowledge:check` fails on any +`ESCALATE` marker left in a guide, so none may remain when you finish. You handle two jobs: diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md index 54919e09f..201fff9e4 100644 --- a/.claude/commands/author-guide.md +++ b/.claude/commands/author-guide.md @@ -5,14 +5,18 @@ argument-hint: '[SDK/runtime to document, or a guide path to (re)author from scr Bootstrap the full docs for: `$ARGUMENTS` (if empty, ask which SDK/runtime to document). -This is the **from-scratch path**: an SDK with no knowledge-base file yet, or a guide that must be -(re)authored from the ground up. It runs the expensive comprehension step once — reading source into -the knowledge base — then composes the guide from that base. For an SDK whose KB file already exists -and whose source merely changed, use **`/refresh-docs`** instead (it is far cheaper — it does not -re-comprehend the whole SDK). - -The order matters: **knowledge first, guide second.** The guide is composed from verified facts, so -the facts must exist before the prose. +This is the **bootstrap path**, defined by one thing: **the SDK has no knowledge-base file yet.** The +expensive comprehension step — reading source into the KB — has never run for this SDK, so it runs +here, once. Use this whenever `documentation/internal/sdk-knowledge//.md` does not exist. +For an SDK whose KB file already exists and whose source merely changed, use **`/refresh-docs`** +instead — it is far cheaper and does not re-comprehend the SDK. + +**The guide itself may or may not already exist — handle both (step 2).** A brand-new SDK has no +guide (compose from scratch); a long-standing SDK often has a full guide that simply predates the +knowledge base (Node and React Native were both like this — 900+ lines each). Either way the KB comes +first: **knowledge first, guide second**, because the guide is composed from verified facts and the +facts must exist before the prose. When a guide already exists, step 2 is a reconciliation against the +now-authoritative KB, not a blank compose — do not expect an empty file. ## 1. Comprehend source → knowledge base (sdk-knowledge-author) @@ -22,45 +26,51 @@ family dir (making a new sibling like `node/` or `native/` if needed), sets its to the target guide, and fills every section with facts + grammar pointers. This is the one step that reads source. It returns when `pnpm knowledge:check` passes for the new file. -## 2. Compose the guide from the knowledge base (guide-writer) - -Launch the `guide-writer` agent for the target guide. It composes from the KB facts created in step 1 -(reading facts, not re-grepping source) following the `optimization-guide-authoring` skill — -archetype, quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in the -matching reference implementation under `implementations/` for shape. If it finds it needs a fact the -base does not hold, it escalates back to `sdk-knowledge-author` rather than reading source itself. +## 2. Compose or reconcile the guide from the knowledge base (guide-writer) -## 3. Review loop (delegate to /review-guide) +Launch the `guide-writer` agent for the target guide, working only from the KB facts created in step 1 +(reading facts, not re-grepping source) and the `optimization-guide-authoring` skill — archetype, +quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in the matching +reference implementation under `implementations/` for shape. Two sub-cases: -Run the `review-guide` command on the guide. Two independent reviews run concurrently: +- **Guide does not exist** — compose it from scratch against the template and the KB facts. +- **Guide already exists** (predates the KB) — reconcile it: bring it to the current archetype and + make every SDK claim trace to a step-1 fact, preserving content that is still correct. This is the + writer's "refresh an existing guide" job. Expect a full file, not a blank one. -- **guide-newcomer-reviewer** — reads it cold as an average developer; reports undefined jargon, - skim-mode, unperformable steps, dishonest labels. -- **guide-source-verifier** — checks every load-bearing claim traces to a KB fact (a lookup, not a - source re-derivation). A claim with no backing fact is escalated to `sdk-knowledge-author`. +If the writer needs a fact the base does not hold, it escalates back to `sdk-knowledge-author` rather +than reading source itself, using the escalation marker: an inline +`` HTML comment at the point of +use. The knowledge author adds the fact from source; the writer then composes the claim from it and +**removes the marker**. This is a transient handoff, never shipped — the gate below fails if any +`ESCALATE` marker remains, and so does `pnpm knowledge:check`. -## 4. Gate before finishing +## 3. Review, fix, and funnel back (delegate to the `review-guide` skill) -Do not call it done until all hold; loop back to the responsible step for anything unmet: +Invoke the **`review-guide`** skill (`.claude/commands/review-guide.md`) on the guide. It owns the +whole review loop in one pass — do not re-run it here: -- Every newcomer **blocker** is fixed; friction items fixed or consciously accepted with a reason. -- Every verifier **contradicts-KB** claim is corrected; every **no-backing-fact** claim is resolved - (fact added by the knowledge author, or claim removed). -- `pnpm knowledge:check` passes. -- `pnpm format:fix ` leaves the guide clean and its TOC anchors resolve. Always pass - the specific files you changed — never a bare `pnpm format:fix`, which reformats the whole tree and - pulls unrelated files into your diff. +- runs `guide-newcomer-reviewer` and `guide-source-verifier` concurrently (reader-experience findings; + per-claim confirmed / contradicts-KB / no-backing-fact verdicts); +- consolidates and applies the fixes (guide corrections; no-backing-fact claims resolved by having + `sdk-knowledge-author` add the fact from source, or the claim removed); +- funnels durable findings back — reader/structure rules to the `optimization-guide-authoring` skill, + facts to the knowledge base, cross-guide consistency issues fixed now (never logged); +- validates (`pnpm knowledge:check`; `pnpm format:fix `, never bare). -## 5. Funnel learnings back +## 4. Bootstrap gate -Route each durable finding to the right artifact — never leave a persisted TODO: +`review-guide` runs its own gate; these are the extra checks specific to a from-scratch bootstrap. +Do not finish until they hold: -- a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles only); -- a fact → the knowledge base (via `sdk-knowledge-author` / `sdk-knowledge-maintenance`); -- a cross-guide consistency issue → **fix it now** in the affected guides; if shared wording was - missing, add it once to `shared/`. +- The new KB file conforms: matches `_template.md`, has a `feeds-guides` marker, empty sections marked + `None.`, and `pnpm knowledge:check` passes for it. +- **No `ESCALATE` marker remains** in the guide — every escalation was resolved and its marker removed + (`pnpm knowledge:check` fails on a survivor). +- The guide is on the current archetype (Quick start, Before you start, category-ordered sections), + and its TOC anchors resolve. -## 6. Report +## 5. Report Summarize: the KB file created and its facts, what the guide covers, each reviewer's findings and how they resolved, what was funneled into the skill vs. the KB, and the validation result. Note anything diff --git a/.claude/commands/refresh-docs.md b/.claude/commands/refresh-docs.md index 70f644b10..21dd00b01 100644 --- a/.claude/commands/refresh-docs.md +++ b/.claude/commands/refresh-docs.md @@ -42,9 +42,10 @@ fact**, composing from the reconciled KB (not re-reading source, not rewriting t A fact that changed shape/behavior means the snippet or sentence that used it changes; an unaffected section is left as-is. -## 4. Review only what changed (delegate to /review-guide, scoped) +## 4. Review only what changed (delegate to the `review-guide` skill, scoped) -Run `review-guide` on each recomposed guide, but focus the reviewers on the changed passages: +Invoke the **`review-guide`** skill on each recomposed guide — it owns the review/fix/funnel/validate +loop in one pass; do not re-run those steps yourself. Focus the reviewers on the changed passages: - **guide-source-verifier** — confirm the changed claims now trace to the reconciled KB facts. - **guide-newcomer-reviewer** — confirm the changed passages still read cleanly for a newcomer (a @@ -52,10 +53,12 @@ Run `review-guide` on each recomposed guide, but focus the reviewers on the chan ## 5. Gate -- `pnpm knowledge:check` passes. +`review-guide` runs its own gate; this adds what is specific to an incremental refresh: + - Every changed claim traces to a current KB fact; no newcomer blocker in the changed passages. -- `pnpm format:fix ` leaves the touched guides clean; TOC anchors still resolve. Pass - the specific files you changed — never a bare `pnpm format:fix`, which reformats the whole tree. +- No `ESCALATE` marker remains in any touched guide. +- `pnpm knowledge:check` passes; `pnpm format:fix ` leaves the touched guides clean and + TOC anchors resolve. Pass the specific files you changed — never a bare `pnpm format:fix`. ## 6. Report diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md index 5e6ddf083..78329d7c6 100644 --- a/.claude/commands/review-guide.md +++ b/.claude/commands/review-guide.md @@ -6,6 +6,11 @@ argument-hint: '[guide file under documentation/guides/]' Run the full authoring review loop on the guide: `$ARGUMENTS` (if empty, ask which guide under `documentation/guides/`). +This runs standalone (review a guide that already shipped) and is also the review core that +`/author-guide` and `/refresh-docs` delegate to. When one of those workflows invokes it, this command +owns steps 1–5 in full — the workflow's own gate/funnel steps are the same loop, run once, not a +second pass. Do not review, fix, and funnel twice. + Do this in order: 1. **Newcomer review.** Launch the `guide-newcomer-reviewer` agent on the guide. It reads the guide diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index ea66f77da..1a6b19d68 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -129,6 +129,7 @@ jobs: # invalidate a symbol pointer without touching a fact. knowledge: - 'documentation/internal/sdk-knowledge/**' + - 'documentation/guides/**' - 'packages/**/src/**' - 'implementations/**' - 'scripts/validate-sdk-knowledge.ts' diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index 9e079375a..98cc980c2 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -39,6 +39,8 @@ * pointers; exempt from all checks so the format's own docs are not graded as data. * - shared/** — grammar + resolution only (mixed prose + facts). * - everything else (per-SDK files) — all four checks. + * - documentation/guides/** — NOT graded for facts (guides are prose), but scanned for stray + * ESCALATE markers, since the writer's escalation handoff lives in the guide until resolved. * * Failures accumulate into `problems` and are printed sorted by file:line; a non-empty set exits 1. * @@ -60,6 +62,7 @@ import { collectDeclaredSymbols } from './sdk-knowledge/source-symbols' const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const knowledgeDir = path.join(rootDir, 'documentation/internal/sdk-knowledge') +const guidesDir = path.join(rootDir, 'documentation/guides') const packagesDir = path.join(rootDir, 'packages') const implementationsDir = path.join(rootDir, 'implementations') const conceptsDir = path.join(rootDir, 'documentation/concepts') @@ -111,6 +114,13 @@ for (const file of listMarkdownFiles(knowledgeDir)) { validateFile(file) } +// ESCALATE markers are a transient writer→knowledge-author handoff (see the author-guide workflow). +// They must never ship, so scan both the knowledge base and the guides they can appear in and fail +// on any survivor. This is the only guide-tree check the validator does. +for (const file of [...listMarkdownFiles(knowledgeDir), ...listMarkdownFiles(guidesDir)]) { + checkNoEscalateMarker(file) +} + report() // --- per-file validation --------------------------------------------------------------------- @@ -517,6 +527,23 @@ function checkFeedsGuides(lines: string[], file: string): void { } } +/** + * Fails on a surviving `ESCALATE(...)` marker. The workflow's writer→knowledge-author handoff leaves + * an inline `` comment where a fact is missing; it must be + * resolved and removed before the guide ships. A survivor means an unresolved escalation would go out + * in reader-facing prose, so it is an error wherever it appears. + */ +function checkNoEscalateMarker(absPath: string): void { + const relPath = path.relative(rootDir, absPath) + readFileSync(absPath, 'utf8') + .split('\n') + .forEach((line, index) => { + if (/\bESCALATE\s*\(/u.test(line)) { + addProblem(relPath, index + 1, `unresolved ESCALATE marker must be resolved and removed`) + } + }) +} + /** The `##` section heading texts of a markdown file, in document order. */ function sectionHeadings(lines: string[]): string[] { return sectionLevel(headingsOf(lines)).map((heading) => heading.text) diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index 9ffe33f44..381705909 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -155,11 +155,13 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. do not re-grep source. Use the matching reference implementation under `implementations/` for real-shaped patterns and "adapt" starting points, but the base, not the impl, is what makes a claim true (the impl proves one path works and can hide nuance). **If the base is missing a fact you - need, do not verify it from source yourself — escalate to `sdk-knowledge-authoring`**, which reads - source and records the fact; then compose from the fact it adds. That keeps comprehension in one - place and means the next guide reuses what this one needed. (When you author a guide for an SDK - whose KB file does not exist yet, that whole file is bootstrapped by `sdk-knowledge-authoring` - first — see the workflow command.) + need, do not verify it from source yourself — escalate** with an inline + `` marker at the point of use; the + `sdk-knowledge-authoring` role reads source and records the fact, then you compose the claim from + it and remove the marker. That keeps comprehension in one place and means the next guide reuses + what this one needed. No `ESCALATE` marker may survive to a finished guide — `pnpm knowledge:check` + fails on one. (When you author a guide for an SDK whose KB file does not exist yet, that whole file + is bootstrapped by `sdk-knowledge-authoring` first — see the workflow command.) 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. 7. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). From eace50c4c274415eab3dc536030b3519bc9a2f94 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 9 Jul 2026 16:59:36 +0200 Subject: [PATCH 30/42] =?UTF-8?q?=F0=9F=A7=B9=20chore(sdk-knowledge):=20Mo?= =?UTF-8?q?de-neutral=20verified=20line=20+=20widen=20shared/=20scope=20be?= =?UTF-8?q?yond=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two RN-run hygiene findings: - The template's "Last verified against packages/**/src during the guide rewrite" boilerplate is untrue for a bootstrap (no rewrite happened). Reword mode-neutrally to "each with a source pointer verified against packages/**/src" across the template and all six KB files. - shared/concepts.md and shared/vocabulary.md were titled "web family", but a native/ family now consumes them and the concepts are genuinely SDK-neutral (they live in core-sdk). Widen the stated scope to the families that actually consume them, and drop the prose workaround the RN file had added to explain the mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/internal/sdk-knowledge/_template.md | 4 ++-- .../internal/sdk-knowledge/native/react-native.md | 10 +++++----- documentation/internal/sdk-knowledge/node/node.md | 4 ++-- .../internal/sdk-knowledge/shared/concepts.md | 8 +++++--- .../internal/sdk-knowledge/shared/vocabulary.md | 7 ++++--- .../internal/sdk-knowledge/web/nextjs-app-router.md | 4 ++-- .../internal/sdk-knowledge/web/nextjs-pages-router.md | 4 ++-- documentation/internal/sdk-knowledge/web/react-web.md | 4 ++-- documentation/internal/sdk-knowledge/web/web.md | 4 ++-- 9 files changed, 26 insertions(+), 23 deletions(-) diff --git a/documentation/internal/sdk-knowledge/_template.md b/documentation/internal/sdk-knowledge/_template.md index f04a06f6e..954277d3c 100644 --- a/documentation/internal/sdk-knowledge/_template.md +++ b/documentation/internal/sdk-knowledge/_template.md @@ -9,8 +9,8 @@ shared/concepts.md and shared/vocabulary.md instead of restating shared material -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only what is specific to diff --git a/documentation/internal/sdk-knowledge/native/react-native.md b/documentation/internal/sdk-knowledge/native/react-native.md index bb88179dc..5321ee5a6 100644 --- a/documentation/internal/sdk-knowledge/native/react-native.md +++ b/documentation/internal/sdk-knowledge/native/react-native.md @@ -2,13 +2,13 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) -and [`../shared/concepts.md`](../shared/concepts.md) (those files are headed "web family"; the -concepts they capture — consent/persistence axes, baseline fallback, entry-source managed-vs-manual, -live updates, single-locale entry contract — apply to the whole suite via the shared `core-sdk`). +and [`../shared/concepts.md`](../shared/concepts.md). The concepts they capture — consent/persistence +axes, baseline fallback, entry-source managed-vs-manual, live updates, single-locale entry contract — +are SDK-neutral and apply to the whole suite via the shared `core-sdk`. This file records only React-Native specifics. The RN SDK subclasses the same `CoreStateful` runtime as the web SDKs and adds React Native providers/hooks/components, AsyncStorage persistence, optional NetInfo offline detection, `AppState` background flushing, and an in-app preview panel. Package diff --git a/documentation/internal/sdk-knowledge/node/node.md b/documentation/internal/sdk-knowledge/node/node.md index 3c55e1e39..b8f571b68 100644 --- a/documentation/internal/sdk-knowledge/node/node.md +++ b/documentation/internal/sdk-knowledge/node/node.md @@ -2,8 +2,8 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only Node-SDK specifics. diff --git a/documentation/internal/sdk-knowledge/shared/concepts.md b/documentation/internal/sdk-knowledge/shared/concepts.md index 165f33da1..51cd8de0e 100644 --- a/documentation/internal/sdk-knowledge/shared/concepts.md +++ b/documentation/internal/sdk-knowledge/shared/concepts.md @@ -1,7 +1,9 @@ -# Shared concepts (web family, SDK-neutral) +# Shared concepts (SDK-neutral) -SDK-neutral concepts common to Web, React Web, and both Next.js routers. Per-SDK files reference -these instead of restating them. Terse; not a guide. +SDK-neutral concepts that live in the shared `core-sdk` and so apply across the SDK families that +consume them — currently Web, React Web, both Next.js routers, Node, and React Native. Per-SDK files +reference these instead of restating them. A few entries are described with a web-oriented example +(e.g. a render prop); the underlying contract is the same across runtimes. Terse; not a guide. ## Entry-source boundary (managed or manual) diff --git a/documentation/internal/sdk-knowledge/shared/vocabulary.md b/documentation/internal/sdk-knowledge/shared/vocabulary.md index 51e4d02d0..e93325fcc 100644 --- a/documentation/internal/sdk-knowledge/shared/vocabulary.md +++ b/documentation/internal/sdk-knowledge/shared/vocabulary.md @@ -1,7 +1,8 @@ -# Shared vocabulary (web family) +# Shared vocabulary -Canonical term → one-line meaning. Use verbatim across the web-family guides so they do not drift. -Facts only; source pointers where a term maps to a concrete symbol. +Canonical term → one-line meaning. Use verbatim across the guide families that share these terms +(Web, React Web, both Next.js routers, Node, React Native) so they do not drift. Facts only; source +pointers where a term maps to a concrete symbol. | Term | One-line meaning | source | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | diff --git a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md index 429884d20..7442c99f5 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -2,8 +2,8 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only App-Router specifics. diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index 6a5e0e5bb..602810986 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md @@ -2,8 +2,8 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only Pages-Router specifics. diff --git a/documentation/internal/sdk-knowledge/web/react-web.md b/documentation/internal/sdk-knowledge/web/react-web.md index 698ee9df4..387d0e9e3 100644 --- a/documentation/internal/sdk-knowledge/web/react-web.md +++ b/documentation/internal/sdk-knowledge/web/react-web.md @@ -2,8 +2,8 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only React-Web specifics. diff --git a/documentation/internal/sdk-knowledge/web/web.md b/documentation/internal/sdk-knowledge/web/web.md index 1346d4c0e..c6633f1cc 100644 --- a/documentation/internal/sdk-knowledge/web/web.md +++ b/documentation/internal/sdk-knowledge/web/web.md @@ -2,8 +2,8 @@ -> Internal, verified reference. Not a guide. Facts only, each with a source pointer. Last verified -> against packages/\*\*/src during the guide rewrite. +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) and [`../shared/concepts.md`](../shared/concepts.md). This file records only Web-SDK specifics. From ea37ae1721c0caaa0a870b8f97c8caaa5786abd6 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 09:49:22 +0200 Subject: [PATCH 31/42] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(skills):=20?= =?UTF-8?q?Split=20guide=20facts=20into=20interface=20(read=20from=20types?= =?UTF-8?q?)=20vs=20behavior=20(from=20KB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket "compose from the KB, never read source" rule conflated two different comprehension costs. Reading an INTERFACE — a symbol's existence, signature, prop/config names & types, optionality, union shape, return type, import path — is cheap, deterministic, and self-verifying: the type system is its always-current store, and copying it into the KB just duplicates what the compiler already guarantees. Tracing BEHAVIOR — fallback contracts, dynamic-render forcing, batching/chunking, defaults, identifier ownership, cross-SDK semantics — is the expensive, judgment-heavy work the KB exists to memoize. Refine the rule to: read interfaces directly from source/types; compose behavior from the KB; re-trace behavior never; escalate only a missing BEHAVIORAL fact. - optimization-guide-authoring: step 5 + checklist now source claims by kind, with the interface/behavior boundary spelled out (and the "don't re-trace under cover of a lookup" trap). - guide-writer: reads interface from source, composes behavior from KB, escalates behavioral gaps. - sdk-knowledge-authoring / sdk-knowledge-author: scope narrowed to behavior/ownership/defaults/ cross-SDK semantics — anchored to the interface symbol, but content is behavior, not signatures. - guide-source-verification / guide-source-verifier: two verification paths — interface checked against the types, behavior against the KB; escalate only behavioral no-backing-fact. - review-guide / author-guide + sdk-knowledge-maintenance: aligned wording. No validator change and no KB content change; knowledge:check still passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-source-verifier.md | 39 ++++--- .claude/agents/guide-writer.md | 29 +++-- .claude/agents/sdk-knowledge-author.md | 7 ++ .claude/commands/author-guide.md | 15 +-- .claude/commands/review-guide.md | 21 ++-- skills/guide-source-verification/SKILL.md | 108 ++++++++++-------- skills/optimization-guide-authoring/SKILL.md | 42 ++++--- .../references/authoring-checklist.md | 18 +-- skills/sdk-knowledge-authoring/SKILL.md | 23 ++++ skills/sdk-knowledge-maintenance/SKILL.md | 8 +- 10 files changed, 195 insertions(+), 115 deletions(-) diff --git a/.claude/agents/guide-source-verifier.md b/.claude/agents/guide-source-verifier.md index db72c7949..b2a38a19b 100644 --- a/.claude/agents/guide-source-verifier.md +++ b/.claude/agents/guide-source-verifier.md @@ -1,27 +1,32 @@ --- name: guide-source-verifier description: >- - Verify every load-bearing SDK claim in a documentation guide traces to a verified fact in the - internal knowledge base — the third authoring role. A cheap consistency lookup, not a re-derivation - from source: a claim with no backing fact is escalated to the sdk-knowledge-author, not re-verified - here. Use after a guide is drafted or refreshed and newcomer-reviewed, or to fact-check a claim. + Verify a documentation guide's load-bearing SDK claims — the third authoring role — splitting each + into interface vs. behavior. Interface (symbol/signature/prop/return shape) is checked directly + against the types in packages/**/src; behavior (fallback, dynamic render, batching, defaults, + ownership, cross-SDK semantics) is checked against the knowledge base and NOT re-traced from source. + Behavioral gaps escalate to the sdk-knowledge-author. Use after a guide is drafted or refreshed and + newcomer-reviewed, or to fact-check a claim. tools: Read, Grep, Glob, Bash --- You are the technical-foundation reviewer for Optimization SDK guides. Follow the -**`guide-source-verification`** skill: confirm every load-bearing claim (hook, prop, config key, -factory field, context field, return shape, cookie, event name, behavioral assertion) traces to a -verified fact in the knowledge base (`documentation/internal/sdk-knowledge/`). +**`guide-source-verification`** skill. Split every load-bearing claim into two kinds and check each +against its authority: -In steady state this is a lookup, not source archaeology. The knowledge base already holds -source-verified facts, each with a resolvable pointer, so a claim is **confirmed** when it matches a -fact and `pnpm knowledge:check` passes. A claim that **contradicts** a fact is a guide bug — the base -is the authority; hand the correction to the writer. A claim with **no backing fact** is escalated to -the **`sdk-knowledge-author`** (which owns reading `packages/**/src`) — do NOT re-derive it from -source yourself; either the base is missing a fact it should hold, or the claim is unfounded and comes -out of the guide. +- **Interface** (a symbol's existence, signature, prop/config-key names & types, optionality, union + shape, return type, import path) — verify directly against the types in `packages/**/src`. Reading + source for interface is expected and cheap; a mismatch is a guide bug → correction to the writer. +- **Behavior** (fallback contracts, dynamic-render forcing, batching/chunking, defaults, identifier + ownership, cross-SDK semantics) — confirm against the knowledge base + (`documentation/internal/sdk-knowledge/`); a claim is **confirmed** when a matching fact exists and + `pnpm knowledge:check` passes, **contradicted** when the base says otherwise (guide bug → writer). + Do NOT re-trace behavior from source. A behavioral claim with **no backing fact** escalates to the + **`sdk-knowledge-author`** — either the base is missing a fact it should hold, or the claim is + unfounded and comes out of the guide. (An unbacked interface claim is not an escalation — you just + checked it against the types.) -You do not read source to verify (the knowledge author does that), you do not edit the knowledge base -or the guide. Return a per-claim verdict (confirmed / contradicts-KB / no-backing-fact) with the -backing fact as evidence, guide corrections routed to the writer, and fact gaps routed to the +You do not edit the knowledge base or the guide. Return a per-claim verdict (interface or behavior; +confirmed / contradicted / behavioral-no-backing-fact) with evidence — `file:symbol` for interface, +the KB fact for behavior — guide corrections routed to the writer, behavioral fact gaps to the knowledge author. diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md index 8461c35bc..6ef900004 100644 --- a/.claude/agents/guide-writer.md +++ b/.claude/agents/guide-writer.md @@ -12,16 +12,25 @@ You are the docs writer for the Optimization SDK Suite. Author or revise the req teach-first quick-start-then-deepen structure, copy-vs-adapt example labels, `## Before you start` block, and self-review checklist are your source of truth. -Compose every SDK claim from the internal knowledge base (`documentation/internal/sdk-knowledge/`), -which holds facts already verified against source — read facts, do not re-grep `packages/**/src`. Use -the matching reference implementation under `implementations/` for real-shaped patterns and "adapt" -starting points. - -If the base is missing a fact you need, do not verify source yourself — **escalate** with an inline -marker at the point of use: ``. The -`sdk-knowledge-author` adds it from source; you then compose the claim from that fact and **delete the -marker**. The marker is a transient handoff and must never ship — `pnpm knowledge:check` fails on any -`ESCALATE` marker left in a guide, so none may remain when you finish. +Source each SDK claim by kind: + +- **Interface** (a symbol's existence, signature, prop/config-key names & types, optionality, union + shape, return type, import path) — read it directly from `packages/**/src` or the types. This is a + cheap, self-verifying lookup; just do it when you need the shape. No KB, no escalation for interface. +- **Behavior** (what a call does: fallback contracts, dynamic-render forcing, batching/chunking, + defaults, identifier ownership, cross-SDK semantics) — compose it from the knowledge base + (`documentation/internal/sdk-knowledge/`), which holds behavior already traced and verified. Never + re-trace behavior from source yourself; that is the expensive work the base memoizes. + +Use the matching reference implementation under `implementations/` for real-shaped patterns and +"adapt" starting points. If the line blurs — you want to know what a prop _does_, not just its shape — +that is behavior, not an interface lookup. + +If the base is missing a **behavioral** fact, **escalate** with an inline marker at the point of use: +``. The `sdk-knowledge-author` +traces it from source and records it; you then compose the claim from that fact and **delete the +marker**. (An interface gap you just look up — do not escalate for it.) The marker is a transient +handoff and must never ship — `pnpm knowledge:check` fails on any `ESCALATE` marker left in a guide. You handle two jobs: diff --git a/.claude/agents/sdk-knowledge-author.md b/.claude/agents/sdk-knowledge-author.md index ced9269b4..d1e7cfae9 100644 --- a/.claude/agents/sdk-knowledge-author.md +++ b/.claude/agents/sdk-knowledge-author.md @@ -14,6 +14,13 @@ You are the SDK knowledge author. Follow the **`sdk-knowledge-authoring`** skill memoizes comprehension and everything downstream reads facts instead of re-reading code. Shape and point facts per the `sdk-knowledge-maintenance` skill. +Capture **behavior, not interface.** The type system already holds the interface (signatures, prop +names/types, optionality, unions, import paths) and guide authors read it directly — do not re-copy +it here. Your facts are what the types cannot express: fallback contracts, dynamic-render forcing, +batching/chunking, defaults and their rationale, identifier ownership (SDK-owned vs. reader-invented), +and cross-SDK semantics. Anchor each behavioral fact to the interface symbol it is about (the +`source:` pointer), but its content is the behavior, not a restatement of the signature. + Pick your mode from the trigger: - **Bootstrap** (new SDK / no KB file yet) — read the exported public surface once, create the KB diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md index 201fff9e4..5b82645ca 100644 --- a/.claude/commands/author-guide.md +++ b/.claude/commands/author-guide.md @@ -28,10 +28,11 @@ reads source. It returns when `pnpm knowledge:check` passes for the new file. ## 2. Compose or reconcile the guide from the knowledge base (guide-writer) -Launch the `guide-writer` agent for the target guide, working only from the KB facts created in step 1 -(reading facts, not re-grepping source) and the `optimization-guide-authoring` skill — archetype, -quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in the matching -reference implementation under `implementations/` for shape. Two sub-cases: +Launch the `guide-writer` agent for the target guide. It composes **behavior** from the KB facts +created in step 1 (never re-tracing behavior from source) and reads **interface** (shapes, +signatures, props) directly from the types as needed, following the `optimization-guide-authoring` +skill — archetype, quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in +the matching reference implementation under `implementations/` for shape. Two sub-cases: - **Guide does not exist** — compose it from scratch against the template and the KB facts. - **Guide already exists** (predates the KB) — reconcile it: bring it to the current archetype and @@ -51,9 +52,9 @@ Invoke the **`review-guide`** skill (`.claude/commands/review-guide.md`) on the whole review loop in one pass — do not re-run it here: - runs `guide-newcomer-reviewer` and `guide-source-verifier` concurrently (reader-experience findings; - per-claim confirmed / contradicts-KB / no-backing-fact verdicts); -- consolidates and applies the fixes (guide corrections; no-backing-fact claims resolved by having - `sdk-knowledge-author` add the fact from source, or the claim removed); + per-claim verdicts — interface checked against the types, behavior against the KB); +- consolidates and applies the fixes (guide corrections; behavioral no-backing-fact claims resolved by + having `sdk-knowledge-author` trace and add the fact, or the claim removed); - funnels durable findings back — reader/structure rules to the `optimization-guide-authoring` skill, facts to the knowledge base, cross-guide consistency issues fixed now (never logged); - validates (`pnpm knowledge:check`; `pnpm format:fix `, never bare). diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md index 78329d7c6..6d5aa6809 100644 --- a/.claude/commands/review-guide.md +++ b/.claude/commands/review-guide.md @@ -17,21 +17,22 @@ Do this in order: cold as an average developer and returns reader-experience findings (undefined jargon, skim-mode, unperformable steps, dishonest labels), each with severity. -2. **Technical-foundation review.** Launch the `guide-source-verifier` agent on the guide. It checks - that every load-bearing SDK claim traces to a verified fact in the knowledge base (a lookup, not a - source re-derivation), and returns a per-claim verdict: confirmed / contradicts-KB / no-backing-fact. - A claim with no backing fact is escalated to the `sdk-knowledge-author` (which owns reading source), - not verified against source here. +2. **Technical-foundation review.** Launch the `guide-source-verifier` agent on the guide. It splits + each load-bearing claim into interface vs. behavior: interface (symbol/signature/prop/return shape) + is checked directly against the types in `packages/**/src`; behavior (fallback, dynamic render, + batching, defaults, ownership, cross-SDK semantics) is checked against the knowledge base and not + re-traced from source. It returns per-claim verdicts (confirmed / contradicted / behavioral + no-backing-fact). A behavioral claim with no backing fact is escalated to the `sdk-knowledge-author`. Run these two reviews concurrently — they are independent (one reads for the reader, one checks the - guide against the knowledge base). + guide's facts against the types and the knowledge base). 3. **Consolidate and fix.** Collect both agents' findings. Apply the guide fixes via the `guide-writer` agent (or directly, following `optimization-guide-authoring`): reader-experience - fixes from the newcomer pass, and corrections for every claim the verifier marked contradicts-KB - (using the KB fact as the authority). For each **no-backing-fact** claim, resolve it: launch - `sdk-knowledge-author` to add the fact from source if the base should hold it, then recompose the - claim from that fact — or remove the claim if nothing backs it. + fixes from the newcomer pass, and corrections for every claim the verifier marked contradicted + (against the types for interface, the KB fact for behavior). For each **behavioral no-backing-fact** + claim, resolve it: launch `sdk-knowledge-author` to trace and add the fact if the base should hold + it, then recompose the claim from that fact — or remove the claim if nothing backs it. 4. **Funnel learnings back.** For each finding that reflects a durable rule — not a one-off — fold it into the right artifact: diff --git a/skills/guide-source-verification/SKILL.md b/skills/guide-source-verification/SKILL.md index 8eb2d6ebf..e0e0b6c1a 100644 --- a/skills/guide-source-verification/SKILL.md +++ b/skills/guide-source-verification/SKILL.md @@ -1,74 +1,90 @@ --- name: guide-source-verification description: >- - Verify that every load-bearing SDK claim in a documentation guide traces to a verified fact in the - internal knowledge base — the technical-foundation review role. In steady state this is a cheap - lookup against documentation/internal/sdk-knowledge/, NOT a re-derivation from source: the knowledge - base already holds source-verified facts, so a guide claim is trustworthy when it matches one. A - claim with no backing fact is escalated to sdk-knowledge-authoring (which owns reading source), not - re-verified here. Use as the third authoring role (writer → newcomer reviewer → technical-foundation - reviewer), after a guide is drafted or refreshed, or to fact-check a specific claim. Triggers on - "technical review", "verify against the knowledge base", "fact-check the guide", "does this trace to - a fact", "foundation review". Not reader-experience review (guide-newcomer-review) and not prose - authoring (optimization-guide-authoring). + Verify a documentation guide's load-bearing SDK claims — the technical-foundation review role — + splitting each into interface vs. behavior. Interface claims (symbol existence, signature, prop + names/types, optionality, unions, return shape, import path) are checked directly against the types + in packages/**/src — a cheap, self-verifying lookup. Behavioral claims (fallback contracts, + dynamic-render forcing, batching, defaults, identifier ownership, cross-SDK semantics) are checked + against verified facts in documentation/internal/sdk-knowledge/ and must NOT be re-traced from + source; a behavioral claim with no backing fact is escalated to sdk-knowledge-authoring. Use as the + third authoring role (writer → newcomer reviewer → technical-foundation reviewer), after a guide is + drafted or refreshed, or to fact-check a specific claim. Triggers on "technical review", "verify + against source", "fact-check the guide", "is this API real", "foundation review". Not reader-experience + review (guide-newcomer-review) and not prose authoring (optimization-guide-authoring). argument-hint: '[guide file or claim to verify]' paths: documentation/guides/** --- -# Verifying a guide against the knowledge base +# Verifying a guide: interface against the types, behavior against the knowledge base -Every load-bearing claim a guide makes about the SDK must trace to a verified fact in the knowledge -base (`documentation/internal/sdk-knowledge/`). That base already holds facts checked against source, -each with a resolvable pointer — so your job in steady state is a **consistency lookup**, not a -re-derivation. You confirm the guide says what the base says. You do not re-read `packages/**/src` -yourself; comprehension is the knowledge author's job, and re-doing it here is exactly the wasted work -the knowledge base exists to prevent. +Every load-bearing claim a guide makes splits into two kinds, and each has its own authority: + +- **Interface** — a symbol's existence, signature, prop/config-key names & types, optionality, union + shape, return type, import path. The authority is the **types** — verify it directly against + `packages/**/src` (or an editor's type view). This is a cheap, self-verifying lookup; reading source + for interface is expected here, not forbidden. +- **Behavior** — what a call does: fallback contracts, denied-consent handling, dynamic-render + forcing, batching/chunking, defaults, identifier ownership, cross-SDK semantics. The authority is + the **knowledge base** (`documentation/internal/sdk-knowledge/`), which holds behavior already + traced and verified. Confirm behavioral claims against the base — do **not** re-trace them from + source; re-tracing is the expensive work the base exists to prevent. + +So you read source freely for interface, and you rely on the base for behavior. The distinction is the +whole point: interface is O(1) and machine-checkable; behavior is expensive to derive and must not be +re-derived per review. ## Method -1. **List the load-bearing claims.** Every hook, component, prop, config key, factory field, context - field, return shape, cookie/identifier, event name, and behavioral assertion the guide states or - shows in a snippet. -2. **Trace each to a KB fact.** Find the fact in the relevant per-SDK file (or `shared/`) that backs - the claim. A claim is **confirmed** when the base holds a matching fact and `pnpm knowledge:check` - passes (its pointer resolves, so it is current). This is the fast path and should cover almost - everything for an SDK whose KB file exists. -3. **Compare wording against the fact.** If the guide asserts something narrower, broader, or simply - different from the fact (a prop that does not exist in the fact, a return shape stated wrong, a - behavior the fact contradicts), that is a **guide bug** — the base is the authority. Flag it for - the writer with the correct fact. -4. **Escalate a claim with no backing fact.** If a load-bearing claim has no corresponding KB fact, - do NOT verify it against source yourself. Escalate to **`sdk-knowledge-authoring`**: either the - base is missing a fact it should hold (the author adds it from source), or the claim is unfounded - (the author confirms nothing backs it, and it comes out of the guide). Record the escalation as a - finding; the guide is not "verified" until the fact exists or the claim is removed. +1. **List the load-bearing claims, and tag each interface or behavior.** Every hook, component, prop, + config key, factory field, context field, return shape, cookie/identifier, event name, and + behavioral assertion the guide states or shows in a snippet. +2. **Verify interface claims against the types.** Confirm the symbol exists and its shape (props, + optionality, union, return) matches, directly in `packages/**/src`. A mismatch (a prop that does + not exist, a wrong return shape) is a **guide bug** → correction to the writer. +3. **Verify behavioral claims against the base.** Find the fact in the relevant per-SDK file (or + `shared/`) that backs the claim; it is **confirmed** when a matching fact exists and + `pnpm knowledge:check` passes (its pointer resolves, so it is current). A claim the base + **contradicts** is a guide bug → correction to the writer (the base is the behavioral authority). +4. **Escalate only a behavioral claim with no backing fact.** If a behavioral claim has no + corresponding KB fact, do NOT trace it from source yourself — escalate to + **`sdk-knowledge-authoring`**: either the base is missing a fact it should hold (the author traces + and adds it), or the claim is unfounded (the author confirms nothing backs it, and it comes out of + the guide). An unbacked _interface_ claim is not an escalation — you just checked it against the + types in step 2. Record each escalation as a finding; the guide is not "verified" until the + behavioral fact exists or the claim is removed. -The one time you legitimately drive source comprehension is a **bootstrap** guide whose SDK has no KB -file yet — there is nothing to look up. In that case this review runs after `sdk-knowledge-authoring` -has created the KB file, so you are still checking guide-against-KB, not guide-against-source. +The one time this review still leans on a fresh KB is a **bootstrap** guide whose SDK has no KB file +yet: it runs after `sdk-knowledge-authoring` has created the file, so behavior still checks against the +base, not against a fresh trace of your own. ## How to report Return a verdict per load-bearing claim: -- **Claim** — the exact assertion, with the guide location. -- **Verdict** — confirmed (matches a KB fact) / contradicts-KB / no-backing-fact. -- **Evidence** — the KB fact and file that backs it (or "none found"). -- **Action** — for contradicts-KB: the correction, handed to the writer. For no-backing-fact: an +- **Claim** — the exact assertion, with the guide location, tagged interface or behavior. +- **Verdict** — confirmed / contradicted / no-backing-fact. For interface: confirmed or contradicted + against the types. For behavior: confirmed or contradicted against a KB fact, or no-backing-fact. +- **Evidence** — for interface, the `file:symbol` in `packages/**/src`; for behavior, the KB fact and + file (or "none found"). +- **Action** — contradicted: the correction, handed to the writer. Behavioral no-backing-fact: an escalation to `sdk-knowledge-authoring` naming the claim. -Hand guide corrections to the writer (`optimization-guide-authoring`); hand fact gaps to the -knowledge author. Do not rewrite the guide and do not edit the knowledge base yourself. +Hand guide corrections to the writer (`optimization-guide-authoring`); hand behavioral fact gaps to +the knowledge author. Do not rewrite the guide and do not edit the knowledge base yourself. ## Before you finish -- Every load-bearing claim is confirmed against a KB fact, corrected against one, or escalated. -- No claim was silently re-verified against source here — gaps went to `sdk-knowledge-authoring`. -- `pnpm knowledge:check` passes (so every fact you relied on is current). +- Every interface claim is confirmed (or corrected) against the types; every behavioral claim is + confirmed against a KB fact, corrected against one, or escalated. +- No **behavioral** claim was re-traced from source here — behavioral gaps went to + `sdk-knowledge-authoring`. (Interface lookups against the types are expected and fine.) +- `pnpm knowledge:check` passes (so every behavioral fact you relied on is current). ## Not in scope -- **Reading source to derive facts** — that is `sdk-knowledge-authoring`. Escalate, don't re-derive. +- **Tracing behavior from source to derive new facts** — that is `sdk-knowledge-authoring`. Escalate + behavioral gaps; don't re-trace. (Reading the types to check an interface claim is in scope.) - **Reader-experience review** (undefined jargon, skim mode, performable steps) — that is `guide-newcomer-review`. - **Prose authoring and structure** — that is `optimization-guide-authoring`. diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index 381705909..10ce4be62 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -148,20 +148,34 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. 4. **Fill feature sections in order**, each with its integration-category line and numbered procedure. Cover the shared concern checklist or mark concerns not-applicable (see [references/authoring-checklist.md](references/authoring-checklist.md)). -5. **Compose every SDK claim from the knowledge base, not from source.** The internal knowledge base - (`documentation/internal/sdk-knowledge/`) is the source of truth for what the SDK does — facts - already verified against `packages/**/src`, each with a resolvable pointer. Every API you use — a - hook, prop, config key, context field, return shape — must come from a fact there; read the base, - do not re-grep source. Use the matching reference implementation under `implementations/` for - real-shaped patterns and "adapt" starting points, but the base, not the impl, is what makes a claim - true (the impl proves one path works and can hide nuance). **If the base is missing a fact you - need, do not verify it from source yourself — escalate** with an inline - `` marker at the point of use; the - `sdk-knowledge-authoring` role reads source and records the fact, then you compose the claim from - it and remove the marker. That keeps comprehension in one place and means the next guide reuses - what this one needed. No `ESCALATE` marker may survive to a finished guide — `pnpm knowledge:check` - fails on one. (When you author a guide for an SDK whose KB file does not exist yet, that whole file - is bootstrapped by `sdk-knowledge-authoring` first — see the workflow command.) +5. **Split every SDK claim into interface vs. behavior, and source each from the right place.** These + are two different kinds of fact with two different costs, and the rule differs: + - **Interface** — what you'd see in a `.d.ts` or an editor hover: a symbol's existence, its + signature, prop/config-key names and types, optionality, union shape, return type, import path. + Reading this from `packages/**/src` (or the types) is cheap, deterministic, and self-verifying — + **just look it up when you need the shape.** You do not need the knowledge base for interface, + and you do not escalate for it; the type system is its always-current store. + - **Behavior** — anything you'd only learn by reading a function body or tracing control flow: what + happens on denied consent, when a render forces dynamic, batching/chunking, fallback contracts, + identifier ownership (SDK-owned vs. reader-invented), defaults and their rationale, cross-SDK + semantics. **Never re-trace behavior from source** — compose it from the knowledge base + (`documentation/internal/sdk-knowledge/`), which holds behavioral facts already traced and + verified, each with a pointer. Re-tracing is the expensive, error-prone work the base exists to + memoize. + + So: read interfaces directly; compose behavior from the base. Use the matching reference + implementation under `implementations/` for real-shaped patterns and "adapt" starting points. **If + the base is missing a fact you need, escalate ONLY when it is behavioral** — an interface gap you + just look up. Escalate with an inline `` marker at the point of use; the `sdk-knowledge-authoring` role traces source and records + the behavior, then you compose the claim from it and remove the marker. No `ESCALATE` marker may + survive to a finished guide — `pnpm knowledge:check` fails on one. (When you author a guide for an + SDK whose KB file does not exist yet, that whole file is bootstrapped by `sdk-knowledge-authoring` + first — see the workflow command.) + + The line matters: if you catch yourself wanting to check what a prop _does_, that is behavior — + go to the base or escalate; do not quietly re-trace it under cover of an interface lookup. + 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. 7. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index cf0bd0826..1e86c7d52 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -43,14 +43,16 @@ add per-archetype checks. endpoints, and paths match reality, or a NOTE explains the discrepancy. - [ ] No placeholder-only snippet is labeled `**Copy this:**` when it actually needs relocation or structural change (that is an adapt). -- [ ] **Every API in a code example traces to a fact in the knowledge base.** Each hook, prop, - config key, context field, and return shape must be backed by a verified fact in - `documentation/internal/sdk-knowledge/` (which holds facts already checked against - `packages/**/src`). Compose from the base, do not re-grep source; a plausible-looking field - name such as `isEnabled` or `hasResults` is not proof it exists. If a needed fact is missing, - escalate to `sdk-knowledge-authoring` to add it from source, then compose from that fact — do - not verify from source in the guide. The reference impl shows one working path for shape and - "adapt" starting points, but the base, not the impl, is what makes a claim true. +- [ ] **Every API in a code example is real, sourced by kind — interface vs. behavior.** For + _interface_ (a symbol's existence, signature, prop/config-key names & types, optionality, union + shape, return type, import path): confirm it directly against `packages/**/src` or the types — a + cheap, self-verifying lookup; a plausible-looking field name such as `isEnabled` or `hasResults` + is not proof it exists. For _behavior_ (what a call does: fallback contracts, dynamic-render + forcing, batching, defaults, identifier ownership, cross-SDK semantics): it must trace to a + verified fact in `documentation/internal/sdk-knowledge/`; do not re-trace it from source. If a + needed _behavioral_ fact is missing, escalate to `sdk-knowledge-authoring` (an interface gap you + just look up, no escalation). The reference impl shows one working path for shape and "adapt" + starting points, but the base, not the impl, is what makes a behavioral claim true. - [ ] Code comments explain only meaningful SDK-specific lines; no obvious-syntax narration. - [ ] **Every magic-value identifier states who owns it.** For each cookie name, env var, header, storage key, or config string in a snippet, the guide states whether the reader must match the diff --git a/skills/sdk-knowledge-authoring/SKILL.md b/skills/sdk-knowledge-authoring/SKILL.md index d5f38f0b4..154ad78d7 100644 --- a/skills/sdk-knowledge-authoring/SKILL.md +++ b/skills/sdk-knowledge-authoring/SKILL.md @@ -31,6 +31,29 @@ You own the source→KB direction only. You do not write guide prose, you do not grammar (that is `sdk-knowledge-maintenance` — follow it for how a fact is shaped and pointed), and you do not review guides. You produce and update facts. +## What belongs in the base: behavior, not the interface the types already hold + +The type system is itself an always-current, machine-checked store of the SDK's **interface** — a +symbol's existence, signature, prop/config-key names and types, optionality, union shape, return +type, import path. Guide authors read that directly from source; they do not need the base for it, +and re-copying it here just duplicates something the compiler already guarantees. + +Your job is the layer the types **cannot** express — **behavior** and semantics you can only learn by +reading implementation and control flow: + +- what a call does on the edges: fallback contracts, denied consent, dynamic-render forcing, batching + and chunking, error paths; +- identifier ownership — SDK-owned vs. reader-invented (a cookie name, an env var); +- defaults and their rationale; +- cross-SDK semantics (the same profile id another SDK reads); +- curation — which few config keys a reader actually sets, out of everything the type permits. + +Anchor each such fact to the interface symbol it is about (that is the `source:` pointer, and it is +what lets the validator confirm the symbol still exists). But the fact's _content_ is the behavior, +not a restatement of the signature. When a fact would say nothing the type already says, it does not +belong here — point at the symbol and stop. Escalations you receive from guide authors are behavioral +by contract; if one is really an interface lookup, answer it, but it did not need the base. + ## Two modes — pick by the trigger ### Bootstrap (a new SDK, or an SDK with no KB file yet) diff --git a/skills/sdk-knowledge-maintenance/SKILL.md b/skills/sdk-knowledge-maintenance/SKILL.md index 792ee9b6a..6582b0d25 100644 --- a/skills/sdk-knowledge-maintenance/SKILL.md +++ b/skills/sdk-knowledge-maintenance/SKILL.md @@ -70,9 +70,11 @@ These are the transferable behaviors this skill exists to preserve. doing other work (writing a guide, fixing a bug, answering a question), record what you confirmed here before the context is lost, with its grammar pointer. Do not run net-new verification passes just to fill the base in; do not re-derive a fact the base already holds. -- **Read the base before re-grepping the SDK.** It exists so authors and future regeneration reuse - verified facts instead of re-searching `packages/**/src`. Check here first; only grep to confirm - or extend. +- **Read the base before re-tracing behavior from the SDK.** It exists so authors and future + regeneration reuse verified _behavioral_ facts instead of re-reading implementation in + `packages/**/src`. Check here first for behavior; only trace source to confirm or extend it. + (Interface shape — a signature, prop, or return type — is cheap to look up directly from the types + and does not need the base.) - **Keep it in sync when the SDK changes.** If a symbol, prop, cookie, config key, export path, or return shape you touched is recorded here, update the entry and its `source:` pointer in the same change. Stale facts are worse than missing ones. `pnpm knowledge:check` runs in CI on every change From 425703701a8083dc995f3c399eccb877b4346605 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 10:54:32 +0200 Subject: [PATCH 32/42] =?UTF-8?q?=E2=9C=A8=20feat(authoring):=20Add=20writ?= =?UTF-8?q?er-owned=20recipe/fragment=20composition=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce documentation/authoring/ as the structural source of truth for guides, separate from the authoring skill (voice) and the knowledge base (facts). A recipe per archetype (integration, decision, supplemental) holds the section spine; two fragments (personalization explainer, authored-variant gotcha) hold reusable reader-facing prose with knowledge-base-filled slots. Every recipe/fragment splits into ## Context (agent-only rationale + fill rules, never rendered) and ## Template (the prose that becomes guide output). Fragments are context-free definitions; the recipe is the caller that names which fragments it composes and where. An LLM (guide-writer) instantiates them — verbatim spine for fixed sentences, composed-around-a-fixed-anchor for SDK-shaped bullets. Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/authoring/README.md | 62 +++++ .../fragments/authored-variant-gotcha.md | 26 ++ .../fragments/personalization-explainer.md | 76 ++++++ documentation/authoring/recipes/decision.md | 49 ++++ .../authoring/recipes/integration.md | 231 ++++++++++++++++++ .../authoring/recipes/supplemental-recipe.md | 55 +++++ 6 files changed, 499 insertions(+) create mode 100644 documentation/authoring/README.md create mode 100644 documentation/authoring/fragments/authored-variant-gotcha.md create mode 100644 documentation/authoring/fragments/personalization-explainer.md create mode 100644 documentation/authoring/recipes/decision.md create mode 100644 documentation/authoring/recipes/integration.md create mode 100644 documentation/authoring/recipes/supplemental-recipe.md diff --git a/documentation/authoring/README.md b/documentation/authoring/README.md new file mode 100644 index 000000000..a3741d8ac --- /dev/null +++ b/documentation/authoring/README.md @@ -0,0 +1,62 @@ +# Guide authoring: recipes and fragments + +Writer-owned composition inputs for the public guides under [`../guides/`](../guides/). **Not +published** — these are the source the `guide-writer` agent composes _from_, the reader-facing mirror +of the internal [`../internal/sdk-knowledge/`](../internal/sdk-knowledge/) knowledge base. + +A technical writer shapes guide structure, sequence, and shared wording here — for a whole class of +guides, never one guide surgically, and never by editing agent logic. The composition is done by an +LLM (`guide-writer`), not a deterministic templating engine: recipes and fragments are prose it reads +and instantiates, not code it executes. + +## The layers + +| Layer | Holds | Owner | +| --------------------------------------------- | --------------------------------------------------------------------------- | ---------------------- | +| **Recipe** (`recipes/`) | a guide archetype's editorial spine + which fragments it composes, in order | technical writer | +| **Fragment** (`fragments/`) | reusable reader-facing prose, fixed spine + KB-filled slots | technical writer | +| Knowledge base (`../internal/sdk-knowledge/`) | the values a fragment's slots resolve to (SDK behavior facts) | SDK / knowledge author | +| `guide-writer` agent | renders the recipe + fragments into a guide | the pipeline | + +The `optimization-guide-authoring` skill keeps **voice and workflow** (how to write well, the +authoring loop). Structure lives here, in the recipes — one source of truth, not scattered across the +skill and sibling guides. + +## Every recipe and fragment file has two sections + +- **`## Context`** — addressed to the `guide-writer` agent. Rationale, how to fill each slot, when to + include or skip a fragment, which KB fact backs which slot. **Never rendered into a guide.** +- **`## Template`** — the prose that becomes guide output. For a fragment this is near-verbatim reader + prose with `⟨slot⟩` markers; for a recipe it is the section spine plus fragment references and + local instructions. + +The frontmatter carries only machine identity (`archetype:` / `fragment:`). + +## How composition works + +- A recipe is the **caller**: it names each fragment it composes (as a markdown link) at the section + where it belongs, and may add a local instruction on that line ("include X, but drop the Y clause + here"). Fragments never name their callers — the recipe owns inclusion. +- The `guide-writer` copies an instantiated fragment's Template **verbatim** and fills only its + `⟨slots⟩` from the knowledge base. The verbatim spine is what keeps a guide family consistent, so + the agent does not reword it. +- Per-SDK variation lives in a fragment's **slots**, filled from the KB at compose time — not in the + recipe. The recipe only expresses per-archetype shape. This keeps the writer editing classes, not + individual guides. + +## Files + +``` +recipes/ + integration.md # integrating-*.md + decision.md # choosing-the-right-sdk.md and future decision guides + supplemental-recipe.md # analytics forwarding and similar +fragments/ + personalization-explainer.md # intro explainer (integration guides) + authored-variant-gotcha.md # the mandatory Before-you-start bullet +``` + +Machine enforcement of recipe/fragment usage (a `guides:check` validator) is **deferred**: for now +the `guide-writer` and the newcomer/source reviewers enforce it by following these files. Because +fragment spines are reproduced verbatim, a future check can confirm instantiation by matching the +fixed sentences. diff --git a/documentation/authoring/fragments/authored-variant-gotcha.md b/documentation/authoring/fragments/authored-variant-gotcha.md new file mode 100644 index 000000000..5552835d4 --- /dev/null +++ b/documentation/authoring/fragments/authored-variant-gotcha.md @@ -0,0 +1,26 @@ +--- +fragment: authored-variant-gotcha +--- + +## Context + +The bullet in `## Before you start` that stops a reader mistaking working personalization for a bug: +without an authored variant, the integration runs but everyone sees the baseline, and the reader +concludes it is broken. A guide instantiates this by copying the **Template** verbatim and filling +the two `⟨slots⟩`. Both resolve from the same knowledge-base fact — the SDK's runtime model: + +- **⟨request | visitor⟩** and **⟨every request matches it | you match it⟩** — use the **request** + framing for stateless server SDKs where work is per request (Node); use the **visitor** framing for + client and per-visitor SDKs (web, React, native). Do not invent the framing; read the runtime model + from the KB. + +This bullet is mandatory in every integration guide. It ties directly to the quick start's verify +step — the reader needs an all-visitors experience to see anything happen. + +## Template + +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant the integration still runs, but every ⟨request | visitor⟩ ⟨resolves to | sees⟩ + the baseline, so you cannot tell personalization from a bug. For your first test, an experience + that targets all visitors is the easiest to verify because ⟨every request matches it | you match + it⟩ automatically. diff --git a/documentation/authoring/fragments/personalization-explainer.md b/documentation/authoring/fragments/personalization-explainer.md new file mode 100644 index 000000000..2f70152c6 --- /dev/null +++ b/documentation/authoring/fragments/personalization-explainer.md @@ -0,0 +1,76 @@ +--- +fragment: personalization-explainer +--- + +## Context + +The intro explainer that teaches an average developer — no personalization background — the whole +idea before the quick start uses any of it. A guide instantiates this from the **Template** below. + +Two kinds of bullet, and they instantiate differently: + +- **Verbatim bullets** — the variant/experience bullet, the resolving sentence, and the closing "You + render whatever the SDK hands back…" line are reproduced word-for-word, only their `⟨slots⟩` filled. + These fixed sentences are what keep the family consistent — do not reword them. +- **Composed bullets** — the entry-source hand-off bullet is composed from a knowledge-base fact + around a fixed anchor: the sentence must end with the **baseline-fallback** contract (the exact + clause below), but the lead and the fetch-boundary wording are shaped to the SDK because the + boundary itself is SDK-shaped (manual-only vs. managed vs. both) and some SDKs reorder the sentence. + Keep the anchor exact; compose the rest from the KB entry-source-boundary fact. + +Every slot resolves from a knowledge-base fact — never invent one. + +Slots and where each is filled from: + +- **⟨timing phrase⟩** — when the Experience API picks the variant, in this runtime's terms: + `On each request` (stateless server), `When a page is requested` (server-rendered pages), + `As the visitor uses your app` (browser), `As the app runs` (native). Fill from the KB runtime + model. +- **⟨visitor reference⟩** — how the sentence refers to the visitor after the timing phrase, chosen to + read cleanly with it: `who the visitor is` when the timing phrase has no visitor antecedent + (`On each request`, `As the app runs`), `who they are` when it already names the visitor + (`As the visitor uses your app` — avoids repeating "visitor"), or `the current visitor` for a + request framing that wants an explicit noun (`When a page is requested`). This co-varies with the + timing phrase; it is grammatical agreement, not free choice. +- **⟨profile bullet⟩** — include this bullet **only if the SDK persists a per-visitor profile** + across requests or launches (Node, React Native). SDKs that hold no cross-request identity in the + explainer omit the whole bullet (the web family). Fill the requests-vs-launches wording from the + KB runtime model. +- **entry-source hand-off bullet (composed)** — compose this bullet from the KB entry-source-boundary + fact around one fixed anchor: it must state that the SDK hands back the resolved variant, **or the + original entry when no variant applies, the baseline fallback** (that clause is the anchor — keep + it). The lead names how this app turns Contentful entries into output today (`Your server fetches +Contentful entries and turns them into a response`, `Your app turns Contentful entries into +markup`, `Your app already fetches Contentful entries and turns them into components`). If the SDK + supports **managed fetching** (it can take the reader's client and fetch by ID), add the "fetch it + yourself or give the SDK your client — either way the client stays yours" clause; omit it for + manual-only SDKs. A screen-first SDK (React Native) may reorder the sentence so the hand-off reads + naturally — that is allowed for this composed bullet, as long as the baseline-fallback anchor + survives intact. +- **⟨deferred concepts⟩** — the not-yet list for this SDK, drawn from the sections this guide defers + (audiences, interaction events, identity, preview, live updates…). + +## Template + +**New to personalization?** Here is the whole idea in ⟨four | five⟩ sentences: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- ⟨timing phrase⟩, Contentful's **Experience API** looks at ⟨visitor reference⟩ and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- ⟨profile bullet — only if the SDK persists a profile:⟩ The Experience API also returns a + **profile**: the anonymous, per-visitor identity and state the SDK uses to keep the same visitor's + personalization consistent across ⟨requests | app launches⟩. +- ⟨entry-source hand-off bullet — composed from the KB entry-source-boundary fact; MUST end with the + baseline-fallback anchor "…or the original entry when no variant applies, the **baseline + fallback**." Add the managed-fetch clause only if the SDK fetches by ID; a screen-first SDK may + reorder the sentence. Example (managed, browser): "Your app turns Contentful entries into markup, + and the SDK sits at that hand-off: it gives you the resolved variant instead of the original — or + the original entry when no variant applies, the **baseline fallback**. You can fetch the entry + yourself and hand it to the SDK, or give the SDK your Contentful client and let it fetch by ID — + either way the client stays yours."⟩ +- You render whatever the SDK hands back exactly as you render entries today. + +That is enough to start. You do not need ⟨deferred concepts⟩ yet; this guide introduces each idea at +the point you need it. diff --git a/documentation/authoring/recipes/decision.md b/documentation/authoring/recipes/decision.md new file mode 100644 index 000000000..7cb7d0511 --- /dev/null +++ b/documentation/authoring/recipes/decision.md @@ -0,0 +1,49 @@ +--- +archetype: decision +filename: choosing-the-right-sdk.md and future decision guides +--- + +## Context + +For a guide whose primary reader goal is **choosing** between SDKs, runtimes, patterns, or tradeoffs. +Shorter and rule-light next to an integration guide. The `guide-writer` renders the **Template** +below; it never emits this Context. + +The recommendation must state a clear default ("choose the highest-level SDK that matches the +runtime") rather than listing every option neutrally. Keep runtime jargon defined. Place the TOC +after the reader-goal opening, before the first `##`. + +Structure invariant: intro → TOC → `## Recommendation` → `## Decision table` → `## Alternatives` → +`## Follow-up guides`. Decision-table columns are exactly `Reader need`, `Choose`, `Why`, +`Next guide`; add `Do not choose when` only when a row needs a boundary or warning. + +## Template + +# ⟨Decision guide title⟩ + +Use this guide when ⟨the choice the reader is trying to make⟩. + +
+ Table of Contents + + +
+ +## Recommendation + +⟨The default choice and the principle behind it, in plain language. Name the common cases and what to +pick for each. Keep runtime jargon defined.⟩ + +## Decision table + +| Reader need | Choose | Why | Next guide | +| ----------- | ------ | --- | ---------- | +| … | … | … | … | + +## Alternatives + +⟨Lower-level or adjacent packages and when they apply. One bullet each.⟩ + +## Follow-up guides + +⟨Table or list routing to the matching integration guides.⟩ diff --git a/documentation/authoring/recipes/integration.md b/documentation/authoring/recipes/integration.md new file mode 100644 index 000000000..2d8289526 --- /dev/null +++ b/documentation/authoring/recipes/integration.md @@ -0,0 +1,231 @@ +--- +archetype: integration +filename: integrating-*.md +--- + +## Context + +Write for **an average developer with little or no personalization background**, opening the guide +cold and wanting a working result fast. This recipe is the caller: it names the sections in order, +which fragments to instantiate, and what each section is for. The `guide-writer` agent renders the +**Template** below into a guide; it never emits this Context. + +What you own vs. what the knowledge base owns: + +- **You own sequence, voice, and honesty** — teach a term before using it; prove one result before + revealing the rest; order sections by when a reader needs them; keep example labels truthful. +- **The knowledge base owns the SDK topic inventory** — which behaviors exist, what a call does on + the edges, identifier ownership. You do not decide the topic list, and you do not restate SDK + facts here. Facts flow from the KB into the guide at compose time; the source-verifier flags any + fact feeding this guide that the guide left out. + +Fragments are reusable reader-facing prose with a fixed spine and knowledge-base-filled slots. When +this recipe says to include one, the guide-writer copies that fragment's Template **verbatim** and +fills only its `⟨slots⟩`. That verbatim spine is what keeps the guide family consistent, so do not +reword an instantiated fragment. If a fragment needs a local adjustment for this archetype, say so on +the line that references it. + +Voice: plain and direct, warm but not chatty. No hype or filler that reads oddly in a reference doc +("this is the payoff", "the magic happens here", "boom", gratuitous "just"). Describe the current SDK +in present tense — never narrate change ("no longer", "now supports", "used to", PR/issue numbers). +While the SDK is pre-release, document the single current version; do not compare SDK versions. + +Structure invariants (deterministic — a future `guides:check` will enforce these): + +- Fixed section order: `# H1` → intro → `## Quick start` → collapsible TOC → `## Before you start` → + `## Core integration` → `## Optional integrations` (if any) → `## Advanced integrations` (if any) → + `## Production checks` → `## Troubleshooting` (if any) → `## Reference implementations to compare +against`. +- H1 form: `# Integrating the Optimization SDK in a app`. Next.js variants name + the router (`Next.js App Router app`, `Next.js Pages Router app`). +- No numbered headings at any level. No monolithic flow section (`## The integration flow`, + `## Required steps`, `## Core steps`). Procedures are numbered lists inside `###` sections. +- Every `###` feature section opens with a bold `**Integration category:**` line, exactly one of: + `Required for first integration`, `Common but policy-dependent`, `Optional`, + `Advanced or production-only`. `Required` and `Common but policy-dependent` go under + `## Core integration`; `Optional` under `## Optional integrations`; `Advanced or production-only` + under `## Advanced integrations`. +- Every fenced code block gets exactly one bold intent label immediately before it, from the set in + the Example labels section below. +- Preserve `` / ``. The TOC omits `## Quick start`, includes all + `##` headings and the `###` feature headings under Core/Optional/Advanced, and every anchor + resolves to a real heading. + +## Template + +# Integrating the Optimization ⟨SDK name⟩ SDK in a ⟨runtime⟩ app + +Use this guide to ⟨one-sentence reader goal, phrased as the working result they will have⟩. + +Include [personalization-explainer](../fragments/personalization-explainer.md). + +You will get there in two milestones: + +- **Milestone 1 — ⟨first observable result⟩ (the quick start below).** ⟨why it is shippable alone⟩ +- **Milestone 2 — ⟨the opt-in layer⟩ (later).** See [⟨section⟩](#anchor). + +This guide uses `⟨package⟩`. ⟨one sentence on the entry point and what the app still owns.⟩ If +⟨other router/runtime⟩, use the [⟨other guide⟩](./⟨file⟩.md) instead. + +## Quick start + +⟨One realistic paragraph naming the app shape this assumes, and pointing readers with a different +shape to the feature section. Then one sentence naming the single proof and the consent assumption.⟩ + +1. ⟨step⟩ — **Copy this:** / **Adapt this to your use case:** + one fenced block. +2. … N. Verify the one result. ⟨Concrete, performable verification — including how to make the SDK + actually produce the result, e.g. an all-visitors experience the reader matches automatically, or + forcing a variant via the preview panel.⟩ + +
+ Table of Contents + + +
+ +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- ⟨runtime prerequisite — the framework/app, peer dependencies, that the reader's own Contentful + fetching already works⟩. +- ⟨credentials — delivery/API tokens, space, environment⟩. +- Include [authored-variant-gotcha](../fragments/authored-variant-gotcha.md). +- ⟨Optimization project values — which default, which the reader must set⟩. + +You do not need a setup inventory up front. Everything else is introduced by the section that needs +it. + +> [!NOTE] +> +> ⟨one-line env-var-convention note: match the prefix/convention the app already uses for +> browser-visible variables⟩ + +## Core integration + +### ⟨feature/concept⟩ + +**Integration category:** Required for first integration | Common but policy-dependent + +1. ⟨numbered procedure step⟩ + +## Optional integrations + + + +### ⟨feature/concept⟩ + +**Integration category:** Optional + +## Advanced integrations + + + +### ⟨feature/concept⟩ + +**Integration category:** Advanced or production-only + +## Production checks + +⟨Cover: credentials/runtime config; consent behavior; event delivery; content fallback; +duplicate-tracking prevention; privacy/governance; and a local validation path.⟩ + +## Troubleshooting + + + +## Reference implementations to compare against + +⟨Link only monorepo reference-implementation READMEs — not source files, sample apps, or external +demos. Frame them as maintained comparison and validation targets for SDK behavior.⟩ + +--- + +## Section notes + +Expanded guidance for the Template above. These are the rules a review (human or the future +`guides:check`) leans on most. + +### Intro + +The intro's teaching content is the `personalization-explainer` fragment — do not hand-write a +competing explainer. After it, frame the two milestones and name the package and what the app still +owns. Do not front-load concept links or reference-implementation links before the quick start +unless the reader must understand them before acting. + +### Quick start (the part reviews scrutinize most) + +- One proof, one verification statement, minimum setup. Valid proofs: SDK init + one accepted page + event; one entry resolving to variant or baseline; server content surviving takeover without a + duplicate page event. +- **Ground it in a real app shape, not a test harness.** Do not invent fetch shapes such as a + hardcoded array of entry IDs. Model the reader's most common real shape and show changes as diffs + against their existing files. Point readers with other shapes to the feature section. +- **Never hand over a full file the reader would paste over their own.** For layouts, providers, + renderers, and the runtime root (`App.tsx`), show a `+`/`-` diff so the additions are unambiguous, + keep the reader's existing lines visible around them, and add a one-line note that the surrounding + code is illustrative context to match against — not a block to paste verbatim. +- Keep optional concerns out of the quick start unless one is the chosen proof: interaction-tracking + config, analytics forwarding, identity, preview, live updates, offline queues, caching, strict + event policy, production hardening. Default interaction tracking may exist implicitly via the entry + wrapper. +- **Make verify performable.** The last step tells the reader how to cause the result, not just + "load as a qualifying visitor." +- **Call out version-specific filenames or exports that fail silently.** When a framework resolves a + handler by filename/export and the current vs previous major differ, name both and state the + failure mode (a wrong name silently no-ops). Verify names against the framework and SDK source. +- **Flag runtime-mode side effects.** If wiring the SDK changes a route's rendering mode (e.g. + forcing dynamic rendering, disabling static/ISR), say so in the core path, not only under caching. +- For screen/entry-heavy native runtimes, "init + one accepted screen event" is an acceptable first + proof when entry rendering would make the quick start disproportionately app-specific. +- **Native build step inline.** For iOS/Android/React Native, the native install step (`pod install`, + `npx expo prebuild`, or equivalent) that must run before launch appears in the quick start where + the reader installs, not deferred to `## Before you start`. + +### Before you start + +A prerequisites list, not a setup-inventory table. Include only what the reader gathers from outside +the guide (runtime prerequisites, credentials, authored Contentful data, Optimization project +values). If a sequenced section teaches it, it does not go here. Anti-patterns: a multi-column setup +table; rows that restate quick-start steps; rows that only say "there is a section below"; +`Category` / `Required for quick start` columns; listing values the quick start never uses. The +authored-variant bullet is the `authored-variant-gotcha` fragment — mandatory. + +### Rendering / entry-wrap (the recurring credibility trap) + +- The entry wrapper goes wherever an entry becomes a component. Use app-neutral wording — do not + assume a specific file or symbol name (e.g. "SectionRenderer") that varies between apps. +- If the render prop returns a type wider than the app's schema type, the snippet must show the cast + the reader needs (`resolved as YourType`) and say why. Verify against the SDK source + (`packages/**/src`), not just the reference implementation. Recommend the plain direct cast as the + default; mention `as unknown as YourType` only as a rare escape for genuinely disjoint types. +- State the fallback contract explicitly: on denied consent / no variant / unresolved links / + all-locale payloads, the render prop receives the baseline entry and the UI does not break. +- Define `render prop` in prose at first use — React-pattern vocabulary is not assumed for the target + reader — tied back to the `{(entry) => ...}` form the reader already copied. + +### Example labels + +- `**Copy this:**` — pasteable with only simple value substitution; the values must actually work + against what the guide points to. A path/file the reader must relocate is an adapt, not a copy. +- `**Adapt this to your use case:**` — realistic app-shaped code needing structural change or + placement; name what is the reader's vs the pattern to copy; prefer a `+`/`-` diff of the reader's + likely code. +- `**Follow this pattern:**` — illustrative shape, not drop-in runnable. +- `**Reference excerpt:**` — shortened/copied reference-implementation code. +- Comment only meaningful SDK-specific lines (lifecycle placement, consent, event sequencing, + fallback, duplicate-event prevention). Never narrate obvious syntax. + +### Production checks + +Must cover: credentials/runtime config, consent behavior, event delivery, content fallback, +duplicate-tracking prevention, privacy/governance, and a local validation path. + +### Shared-concern coverage + +Each shared concern is either covered by the guide or explicitly marked not-applicable for this SDK: +install/init/config; consent & privacy handoff; the fetch/SDK boundary; entry resolution + fallback; +page/route/screen events; interaction tracking; identity/profile/reset; routing/lifecycle/request +context; real-time updates/preview/analytics/caching; verification. Coverage is judged against the +knowledge-base facts that feed this guide — the source-verifier flags a fed fact the guide omits. diff --git a/documentation/authoring/recipes/supplemental-recipe.md b/documentation/authoring/recipes/supplemental-recipe.md new file mode 100644 index 000000000..a6f5e0f75 --- /dev/null +++ b/documentation/authoring/recipes/supplemental-recipe.md @@ -0,0 +1,55 @@ +--- +archetype: supplemental-recipe +filename: supplemental recipe guides (e.g. analytics forwarding) +--- + +## Context + +For a guide that **supplements** an SDK integration without replacing it (for example, forwarding +Optimization context to analytics tools). The `guide-writer` renders the **Template** below; it never +emits this Context. + +- `## Quick start` is one minimal viable recipe the reader can apply first — not the full pattern. +- `## Default recipe` explains the reusable pattern behind the quick start. +- `## Runtime or vendor variants` groups by runtime/vendor/destination; each variant states when it + applies. + +Structure invariant: intro → `## Do you need this?` → `## Quick start` → TOC → `## Default recipe` → +`## Runtime or vendor variants` → `## Validate the integration` → `## Governance notes` → +`## Related guides and concepts`. The TOC omits both `## Quick start` and `## Do you need this?` (the +reader has passed them). Every fenced block carries one example-intent label from the integration +recipe's label set. + +## Template + +# ⟨Supplemental recipe guide title⟩ + +Use this guide when ⟨the supplemental task⟩. + +## Do you need this? + +⟨Short qualifier: who needs this recipe and who can skip it.⟩ + +## Quick start + +⟨One minimum viable recipe the reader can apply first, with an example-intent label.⟩ + +
+ Table of Contents + + +
+ +## Default recipe + +⟨The reusable pattern behind the quick start, explained.⟩ + +## Runtime or vendor variants + +⟨Variants grouped by runtime, vendor, or destination. Each variant states when it applies.⟩ + +## Validate the integration + +## Governance notes + +## Related guides and concepts From 90f2bdb4b0d57cb8e79b71016ff84da2b00a654e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 10:54:59 +0200 Subject: [PATCH 33/42] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(skills):=20?= =?UTF-8?q?Repoint=20guide=20pipeline=20at=20recipes;=20migrate=20structur?= =?UTF-8?q?e=20out=20of=20the=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move guide structure out of the optimization-guide-authoring skill and into the new documentation/authoring/ recipes, resolving the two-sources-of-truth problem (skill refs vs. sibling guides). The skill now owns voice, the copy-vs-adapt honesty principle, and workflow only. - guide-writer agent: compose structure from the archetype recipe (authoritative over sibling guides); instantiate fragments verbatim, filling slots from the KB. - author-guide / review-guide commands: read structure from recipes; funnel durable structure/shared-prose findings to recipes/fragments, voice to the skill, facts to the KB. - guides/AGENTS.md: point at recipes for structure, the skill for voice. - Delete the three migrated reference templates (integration-guide-template, decision-and-recipe-templates, before-you-start); repoint authoring-checklist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/guide-writer.md | 33 ++- .claude/commands/author-guide.md | 13 +- .claude/commands/review-guide.md | 10 +- documentation/guides/AGENTS.md | 19 +- skills/optimization-guide-authoring/SKILL.md | 112 +++++----- .../references/authoring-checklist.md | 10 +- .../references/before-you-start.md | 68 ------- .../decision-and-recipe-templates.md | 98 --------- .../references/integration-guide-template.md | 191 ------------------ 9 files changed, 107 insertions(+), 447 deletions(-) delete mode 100644 skills/optimization-guide-authoring/references/before-you-start.md delete mode 100644 skills/optimization-guide-authoring/references/decision-and-recipe-templates.md delete mode 100644 skills/optimization-guide-authoring/references/integration-guide-template.md diff --git a/.claude/agents/guide-writer.md b/.claude/agents/guide-writer.md index 6ef900004..be4386cb3 100644 --- a/.claude/agents/guide-writer.md +++ b/.claude/agents/guide-writer.md @@ -8,9 +8,24 @@ tools: Read, Edit, Write, Grep, Glob, Bash --- You are the docs writer for the Optimization SDK Suite. Author or revise the requested guide under -`documentation/guides/` following the **`optimization-guide-authoring`** skill — its archetypes, -teach-first quick-start-then-deepen structure, copy-vs-adapt example labels, `## Before you start` -block, and self-review checklist are your source of truth. +`documentation/guides/`. You compose from two source-of-truth layers: + +- **The recipe** for the guide's archetype, under `documentation/authoring/recipes/` + (`integration.md`, `decision.md`, `supplemental-recipe.md`) — the structural source of truth. Its + `## Template` is the section spine; its `## Context` is the rationale and is for you, never emitted + into the guide. The recipe is authoritative over any sibling guide: match the recipe, do not copy a + sibling's structure. +- **The `optimization-guide-authoring` skill** — the teaching voice, the copy-vs-adapt honesty + principle, and the authoring workflow. + +**Instantiate fragments, do not re-derive them.** Where a recipe references a fragment under +`documentation/authoring/fragments/` (the personalization explainer, the authored-variant gotcha), +open that fragment and copy its `## Template` **verbatim** into the guide, filling only its `⟨slot⟩` +markers from the knowledge base — the fixed sentences are what keep the guide family consistent, so +do not reword them. Honor any local instruction the recipe adds on the line that references the +fragment ("include X, but drop the Y clause here"). A fragment's `## Context` tells you how to fill +each slot and when a slot or bullet is omitted; never emit that Context. Per-SDK variation lives in +the slots (filled from the KB), not in reworded prose. Source each SDK claim by kind: @@ -34,13 +49,15 @@ handoff and must never ship — `pnpm knowledge:check` fails on any `ESCALATE` m You handle two jobs: -- **New guide** — draft from the matching template. -- **Refresh an existing guide** — first diff it against the current skill and bring it up to the +- **New guide** — draft from the matching recipe's `## Template`, instantiating the fragments it + references. +- **Refresh an existing guide** — first diff it against the current recipe and bring it up to the present archetype. The fastest tells that a guide predates the current approach: no `## Quick start` or no `## Before you start`, a monolithic `## The integration flow` / `## Required steps` section, - numbered headings, a required-setup inventory table instead of a prerequisites list, or missing - `**Copy this:**` / `**Adapt this to your use case:**` labels. Restructure to the current archetype - while preserving content that is still correct; do not throw away accurate specifics. + numbered headings, a required-setup inventory table instead of a prerequisites list, missing + `**Copy this:**` / `**Adapt this to your use case:**` labels, or a hand-written intro explainer + that should be the `personalization-explainer` fragment. Restructure to the current archetype while + preserving content that is still correct; do not throw away accurate specifics. You draft; you do not sign off. After your pass the guide goes to the `guide-newcomer-review` and `guide-source-verification` roles. When they hand back findings, apply the fixes and fold any durable diff --git a/.claude/commands/author-guide.md b/.claude/commands/author-guide.md index 5b82645ca..d19d06696 100644 --- a/.claude/commands/author-guide.md +++ b/.claude/commands/author-guide.md @@ -28,11 +28,14 @@ reads source. It returns when `pnpm knowledge:check` passes for the new file. ## 2. Compose or reconcile the guide from the knowledge base (guide-writer) -Launch the `guide-writer` agent for the target guide. It composes **behavior** from the KB facts -created in step 1 (never re-tracing behavior from source) and reads **interface** (shapes, -signatures, props) directly from the types as needed, following the `optimization-guide-authoring` -skill — archetype, quick-start-then-deepen, `## Before you start`, copy-vs-adapt labels — grounded in -the matching reference implementation under `implementations/` for shape. Two sub-cases: +Launch the `guide-writer` agent for the target guide. It composes structure from the archetype's +recipe under `documentation/authoring/recipes/` — the recipe's `## Template` is the section spine and +it instantiates the fragments the recipe references (the personalization explainer, the +authored-variant gotcha) by copying their Template verbatim and filling `⟨slots⟩` from the KB. It +composes **behavior** from the KB facts created in step 1 (never re-tracing behavior from source) and +reads **interface** (shapes, signatures, props) directly from the types as needed, following the +`optimization-guide-authoring` skill for voice and workflow — grounded in the matching reference +implementation under `implementations/` for shape. Two sub-cases: - **Guide does not exist** — compose it from scratch against the template and the KB facts. - **Guide already exists** (predates the KB) — reconcile it: bring it to the current archetype and diff --git a/.claude/commands/review-guide.md b/.claude/commands/review-guide.md index 6d5aa6809..760fde266 100644 --- a/.claude/commands/review-guide.md +++ b/.claude/commands/review-guide.md @@ -36,10 +36,16 @@ Do this in order: 4. **Funnel learnings back.** For each finding that reflects a durable rule — not a one-off — fold it into the right artifact: - - a reader-experience or structure rule → the `optimization-guide-authoring` skill (principles - only, never SDK facts), + - a reader-experience or **voice/teaching** rule → the `optimization-guide-authoring` skill + (principles only, never SDK facts), + - a **structure or shared-prose** rule → the recipe or fragment under `documentation/authoring/` + (section spine and integration categories → the archetype's recipe; a reusable sentence that + drifted across the family → the relevant fragment's Template), - a missing or corrected SDK fact → the knowledge base via `sdk-knowledge-author`. + When a fragment the recipe names is missing from the guide, or its fixed spine was reworded rather + than instantiated verbatim, that is a structure finding: have the writer instantiate it. + 5. **Validate.** Run `pnpm knowledge:check` (KB must pass) and `pnpm format:fix ` — pass the specific files you changed, never a bare `pnpm format:fix` (it reformats the whole tree and pollutes the diff) — and confirm the guide's TOC anchors resolve. diff --git a/documentation/guides/AGENTS.md b/documentation/guides/AGENTS.md index bde8f9d28..31bd873d6 100644 --- a/documentation/guides/AGENTS.md +++ b/documentation/guides/AGENTS.md @@ -4,14 +4,21 @@ Applies to public guides under `documentation/guides/`. When you create, rewrite, or review any guide here — integration guides (`integrating-*.md`), `choosing-the-right-sdk.md`, supplemental recipe guides, or the directory `README.md` routing index -— use the **`optimization-guide-authoring`** skill. It owns the archetypes, the teach-first -quick-start-then-deepen structure, the copy-vs-adapt example labels, the `## Before you start` -prerequisites block, the directory README rules, and the self-review checklist. +— use two source-of-truth layers: + +- **Structure** — the archetype's recipe under [`../authoring/recipes/`](../authoring/recipes/), and + the reusable shared prose it composes under [`../authoring/fragments/`](../authoring/fragments/). + The recipe owns the section spine, the fixed heading order, the integration categories, the + `## Before you start` prerequisites shape, and the example-label set. It is authoritative over any + sibling guide. +- **Voice and workflow** — the **`optimization-guide-authoring`** skill: the teach-first + quick-start-then-deepen approach, the copy-vs-adapt honesty principle, the directory README rules, + and the self-review checklist. The skill lives at `skills/optimization-guide-authoring/` (a tool-neutral top-level directory, -symlinked into `.claude/skills/` and `.agents/skills/`); its `references/` hold the per-archetype -templates and the checklist. Do not duplicate those rules back into this file — keep this pointer -thin and let the skill be the source of truth. +symlinked into `.claude/skills/` and `.agents/skills/`). Do not duplicate recipe structure or skill +principles back into this file — keep this pointer thin and let those artifacts be the source of +truth. Deeper mechanics belong in `documentation/concepts/`, exhaustive API detail in generated TypeDoc under `docs/`, and prose style in the repo `STYLE_GUIDE.md`. diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index 10ce4be62..10fd01794 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -19,9 +19,17 @@ paths: documentation/guides/** # Authoring Optimization SDK guides Use this skill whenever you create, rewrite, or review a guide under `documentation/guides/`. It -owns the structure, voice, and self-review rules for those guides so they stay consistent and -teachable. It supersedes the former long-form rules in `documentation/guides/AGENTS.md`, which is -now a thin pointer to this skill. +owns the **voice, teaching approach, and authoring workflow** for those guides so they stay +consistent and teachable. It supersedes the former long-form rules in +`documentation/guides/AGENTS.md`, which is now a thin pointer to this skill. + +**Structure lives in recipes, not here.** A guide's section spine, fixed heading order, integration +categories, example-label rules, and the reusable shared prose (fragments) live in +[`documentation/authoring/`](../../documentation/authoring/) — one recipe per archetype plus the +fragments they compose. This skill holds the transferable principles (below); the recipe holds the +shape of a given archetype. When you write or refresh a guide, open the matching recipe as the +structural source of truth and this skill for the voice and workflow. Do not restate recipe structure +here, and do not let SDK facts (which belong in the knowledge base) leak into either. ## Who the guides are for @@ -57,76 +65,47 @@ go deeper. Two consequences drive everything below: - Guides under `documentation/guides/`: `integrating-*.md`, `choosing-the-right-sdk.md`, supplemental recipe guides, and the directory `README.md` routing index. -- Structure (archetype and headings), teaching voice, example labels, TOC rules, and self-review. +- This skill owns teaching voice, the copy-vs-adapt honesty principle, and the authoring workflow. + Section structure, heading order, integration categories, and the reusable shared prose belong to + the recipes and fragments under [`documentation/authoring/`](../../documentation/authoring/). ## Not in scope +- **Guide structure** — the section spine, fixed heading order, integration categories, TOC rules, + and per-archetype skeletons live in the recipes under `documentation/authoring/recipes/`. Reusable + reader-facing prose (the personalization explainer, the authored-variant gotcha) lives in + `documentation/authoring/fragments/`. Open the matching recipe as the structural source of truth. - Concept docs under `documentation/concepts/` — they own deeper mechanics; guides link to them. - Package READMEs, implementation READMEs, and product docs. - Generated TypeDoc under `docs/` — it owns exhaustive, method-by-method API reference. - Prose style mechanics — follow the repo `STYLE_GUIDE.md`; let Prettier own formatting. -## Archetypes +## Archetypes and structure Every guide has exactly one primary archetype. Pick it by the reader's primary goal, then open the -matching template. - -| Reader goal | Archetype | Template | -| ------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------ | -| Integrate an SDK into a specific runtime (`integrating-*.md`) | Integration guide | [references/integration-guide-template.md](references/integration-guide-template.md) | -| Choose between SDKs, runtimes, or patterns | Decision guide | [references/decision-and-recipe-templates.md](references/decision-and-recipe-templates.md) | -| Supplement an integration without replacing it | Supplemental recipe | [references/decision-and-recipe-templates.md](references/decision-and-recipe-templates.md) | - -## Core structure for integration guides - -The reader's journey is two milestones, layered inside the mandated section order: - -1. **Milestone 1 — the quick start.** The single shortest runnable path to one observable result - (personalized first paint, one accepted page event, one entry resolving, etc.). Complete and - shippable on its own. This is `## Quick start`. -2. **Milestone 2 and beyond — feature sections.** Everything else, introduced by the section that - needs it: `## Core integration`, then `## Optional integrations`, then - `## Advanced integrations`. - -Section order is fixed: `# H1` → intro (`Use this guide…`) → `## Quick start` → collapsible TOC → -`## Before you start` → `## Core integration` → `## Optional integrations` (if any) → -`## Advanced integrations` (if any) → `## Production checks` → `## Troubleshooting` (if any) → -`## Reference implementations to compare against`. The template has the full skeleton. - -Key rules (the template expands each): - -- Organize by feature/concept, not one monolithic flow. No `## The integration flow` / - `## Required steps` section, and no numbered headings at any level. Procedures are numbered lists - inside the relevant `###` section. -- Each `###` feature section starts with a bold `**Integration category:**` line using exactly one - of: `Required for first integration`, `Common but policy-dependent`, `Optional`, - `Advanced or production-only`. Category placement maps to the parent `##` section. -- **`## Before you start` replaces the old required-setup table.** List only what the reader must - gather from outside the guide (runtime prerequisites, credentials, and — critically — authored - Contentful data such as a variant attached to an experience). Do not restate the walkthrough as an - inventory; the sequenced sections own "where to configure." See - [references/before-you-start.md](references/before-you-start.md) for what belongs here and the - authored-variant gotcha every SDK guide must call out. +matching recipe under [`documentation/authoring/recipes/`](../../documentation/authoring/recipes/) — +the recipe's `## Template` is the section spine and its `## Context` is the structural rationale. -## Example labels: make copy-vs-adapt honest +| Reader goal | Archetype | Recipe | +| ------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Integrate an SDK into a specific runtime (`integrating-*.md`) | Integration guide | [../../documentation/authoring/recipes/integration.md](../../documentation/authoring/recipes/integration.md) | +| Choose between SDKs, runtimes, or patterns | Decision guide | [../../documentation/authoring/recipes/decision.md](../../documentation/authoring/recipes/decision.md) | +| Supplement an integration without replacing it | Supplemental recipe | [../../documentation/authoring/recipes/supplemental-recipe.md](../../documentation/authoring/recipes/supplemental-recipe.md) | -Every fenced code block gets a bold intent label immediately before it. The label is a promise to -the reader — keep it true. +The recipe owns the fixed section order, the `**Integration category:**` line values, the +`## Before you start` prerequisites shape, the example-label set, and which fragments each archetype +composes (the personalization explainer, the authored-variant gotcha). Do not re-derive structure +from a sibling guide — the recipe is authoritative; sibling guides are examples of it. -- `**Copy this:**` — pasteable with only simple value substitution (IDs, tokens, env var names, - versions, paths, config values). **The values must actually work against what the guide points - to.** If a snippet references env var names or endpoints, they must match reality (or the guide - must note the discrepancy). A placeholder alone does not make a snippet adaptive. -- `**Adapt this to your use case:**` — realistic app-shaped code that needs structural changes, - placement, or business logic. Say explicitly what is the reader's (their fetch, their components) - and what is the pattern to copy. Where the change means "find X in your code and wrap/change it," - say so — a before/after of the reader's likely code is the strongest form (see the template's - "composed sections" example). -- `**Follow this pattern:**` — illustrative shape, not drop-in runnable. -- `**Reference excerpt:**` — shortened or copied reference-implementation code. +## Example labels: make copy-vs-adapt honest -Add short comments only for SDK-specific lines that carry meaning (lifecycle placement, consent, -event sequencing, fallback, duplicate-event prevention). Do not narrate obvious syntax. +The recipe defines the label set and per-label rules. The transferable principle this skill owns: an +example label is a **promise to the reader — keep it true.** `**Copy this:**` only when the values +actually work as-is against what the guide points to; the moment a snippet needs relocation or +structural change, it is `**Adapt this to your use case:**` and the guide names what is the reader's +vs. the pattern to copy. Add short comments only for SDK-specific lines that carry meaning (lifecycle +placement, consent, event sequencing, fallback, duplicate-event prevention); never narrate obvious +syntax. ## Directory README (routing index) @@ -138,13 +117,16 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. ## Workflow -1. **Identify the archetype and reader goal.** Open the matching template. -2. **Draft the intro and quick start first.** Intro defines the essential terms in plain language - and frames the milestones. Quick start = one proof, one verification statement, minimum setup. - Keep optional concerns (analytics, preview, live updates, identity, strict consent, caching) out - of it unless that concern is the chosen proof. +1. **Identify the archetype and reader goal.** Open the matching recipe under + `documentation/authoring/recipes/`. Its `## Template` is the section spine; its `## Context` is the + structural rationale. The fragments it references are the shared prose you will instantiate. +2. **Draft the intro and quick start first.** The intro's teaching content is the + `personalization-explainer` fragment — instantiate it (copy its Template verbatim, fill each + `⟨slot⟩` from the knowledge base) rather than hand-writing an explainer. Quick start = one proof, + one verification statement, minimum setup. Keep optional concerns (analytics, preview, live + updates, identity, strict consent, caching) out of it unless that concern is the chosen proof. 3. **Write `## Before you start`** as an outside-the-guide prerequisites list, including the - authored data the reader needs to tell personalization from a bug. + `authored-variant-gotcha` fragment (mandatory) so the reader can tell personalization from a bug. 4. **Fill feature sections in order**, each with its integration-category line and numbered procedure. Cover the shared concern checklist or mark concerns not-applicable (see [references/authoring-checklist.md](references/authoring-checklist.md)). diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 1e86c7d52..6f9307d72 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -88,8 +88,9 @@ add per-archetype checks. ## B. Integration guides (`integrating-*.md`) -- [ ] Section order and headings match integration-guide-template.md; no numbered headings; no - monolithic flow section. +- [ ] Section order and headings match the integration recipe + (`documentation/authoring/recipes/integration.md`); no numbered headings; no monolithic flow + section. - [ ] Every `###` feature section has a correct `**Integration category:**` line, and its category matches its parent `##` (Required/Common under Core; Optional under Optional; Advanced under Advanced). @@ -123,8 +124,9 @@ add per-archetype checks. - [ ] The quick start is self-contained: no step (the verify step included) is only actionable via a forward reference to a later section. A step may link onward for depth, but the instruction it needs to act must be present inline where the reader hits it. -- [ ] `## Before you start` is a prerequisites list, not a setup-inventory table, and includes the - authored-variant gotcha (see before-you-start.md). +- [ ] `## Before you start` is a prerequisites list, not a setup-inventory table, and instantiates + the `authored-variant-gotcha` fragment + (`documentation/authoring/fragments/authored-variant-gotcha.md`). - [ ] Each shared concern is either covered by the guide or explicitly marked not-applicable for this SDK: install/init/config; consent & privacy handoff; the fetch/SDK boundary; entry resolution + fallback; page/route/screen events; interaction tracking; identity/profile/reset; diff --git a/skills/optimization-guide-authoring/references/before-you-start.md b/skills/optimization-guide-authoring/references/before-you-start.md deleted file mode 100644 index 21cdaa064..000000000 --- a/skills/optimization-guide-authoring/references/before-you-start.md +++ /dev/null @@ -1,68 +0,0 @@ -# `## Before you start` — what belongs here - -This section replaces the old mandated "Required setup" table. That table failed the reader: ~20 -rows with `Category` and `Required for quick start` columns that described the _document's_ -structure, and most rows just restated a walkthrough step or pointed at a later section. A newcomer -met the entire production surface before writing a line of code — the exact overwhelm the guides are -meant to avoid. - -## The rule - -List only what the reader must **gather from outside the guide** before they begin. If a sequenced -section teaches it, it does not go here. The feature sections own "where to configure." - -Include, at most: - -- **Runtime prerequisites** — the framework/app and its peer dependencies, and that the reader's own - Contentful fetching already works. -- **Credentials** — the delivery/API tokens, space, environment, and Optimization project values the - reader retrieves from a dashboard or settings, not from this guide. -- **Authored Contentful data** — the content the reader must create so the integration has something - to do. See the gotcha below; every SDK guide must include it. -- **A one-line env-var convention note** — tell the reader to match the prefix/convention their app - already uses for browser-visible variables, and keep it consistent. Do not invent a convention - that conflicts with real apps. - -Close with one sentence: everything else is introduced by the section that needs it; there is no -setup inventory to work through up front. - -## The authored-variant gotcha (mandatory in every guide) - -Without an authored variant attached to an experience, the integration runs but every visitor sees -the baseline — so the reader cannot tell working personalization from a bug, and concludes it is -broken. State plainly that they must author at least one variant + experience, and that for a first -test an **experience targeting all visitors** is easiest because they match it automatically. This -ties directly to the quick start's verify step. - -## Anti-patterns - -- A multi-column setup-inventory table. -- Rows that restate quick-start steps ("mount the root", "install the package"). -- Rows that only say "there is a section for this below" — the TOC already does that. -- `Category` / `Required for quick start` columns — they describe the doc, not the reader's task. -- Listing values the quick start never uses as if they are required up front (e.g. an API base URL - that defaults correctly). If it defaults, say so and defer it. - -## Shape to copy - -```md -## Before you start - -The sections below walk the integration in order. First, gather the few things you can only get from -outside this guide: - -- **.** -- **.** -- **At least one entry with a variant attached to an experience**, authored in Contentful. Without - an authored variant the integration still runs, but every visitor sees the baseline, so you cannot - tell personalization from a bug. For a first test, an experience that targets all visitors is - easiest to verify because you match it automatically. -- **** — . - -You do not need a setup inventory up front. Everything else is introduced by the section that needs -it. - -> [!NOTE] -> -> -``` diff --git a/skills/optimization-guide-authoring/references/decision-and-recipe-templates.md b/skills/optimization-guide-authoring/references/decision-and-recipe-templates.md deleted file mode 100644 index a35d192c4..000000000 --- a/skills/optimization-guide-authoring/references/decision-and-recipe-templates.md +++ /dev/null @@ -1,98 +0,0 @@ -# Decision guide and supplemental recipe templates - -Two archetypes share this file because both are short and rule-light next to integration guides. - -## Decision guide - -Use for `choosing-the-right-sdk.md` and any future guide whose primary reader goal is choosing -between SDKs, runtimes, patterns, or tradeoffs. - -### Skeleton - -```md -# - -Use this guide when . - -
- Table of Contents - - -
- -## Recommendation - - - -## Decision table - -| Reader need | Choose | Why | Next guide | -| ----------- | ------ | --- | ---------- | -| ... | ... | ... | ... | - -## Alternatives - - - -## Follow-up guides - - -``` - -### Rules - -- Place the TOC after the reader-goal opening, before the first `##`. -- Decision-table columns are exactly `Reader need`, `Choose`, `Why`, `Next guide`. Add - `Do not choose when` only when a row needs a boundary or warning. -- The recommendation must state a clear default ("choose the highest-level SDK that matches the - runtime") rather than listing every option neutrally. - -## Supplemental recipe guide - -Use for guides that supplement an SDK integration without replacing it (for example, analytics -forwarding), and future guides of that kind. - -### Skeleton - -```md -# - -Use this guide when . - -## Do you need this? - - - -## Quick start - - - -
- Table of Contents - - -
- -## Default recipe - - - -## Runtime or vendor variants - - - -## Validate the integration - -## Governance notes - -## Related guides and concepts -``` - -### Rules - -- `## Quick start` is one minimal viable recipe, not the full pattern. -- `## Default recipe` explains the reusable pattern behind the quick start. -- `## Runtime or vendor variants` groups by runtime/vendor/destination; each variant says when it - applies. -- The TOC omits both `## Quick start` and `## Do you need this?` (the reader has passed them). diff --git a/skills/optimization-guide-authoring/references/integration-guide-template.md b/skills/optimization-guide-authoring/references/integration-guide-template.md deleted file mode 100644 index 3c38f2166..000000000 --- a/skills/optimization-guide-authoring/references/integration-guide-template.md +++ /dev/null @@ -1,191 +0,0 @@ -# Integration guide template and rules - -Use this for files named `integrating-*.md`. Copy the skeleton, then fill each section. The rules -after the skeleton are the ones a review (human or hook) checks. - -## Skeleton - -```md -# Integrating the Optimization SDK in a app - -Use this guide to . - -**New to personalization?** Here is the whole idea in sentences: - -- -- -- -- - -That is enough to start. You do not need yet; this guide introduces each idea at -the point you need it. - -You will get there in two milestones: - -- **Milestone 1 — (the quick start below).** -- **Milestone 2 — (later).** See [
](#anchor). - -This guide uses ``. - -If , use the [](./.md) instead. - -## Quick start - - - -1. — **Copy this:** / **Adapt this to your use case:** + one fenced block. -2. ... N. Verify the one result. - -
- Table of Contents - - -
- -## Before you start - - - -## Core integration - -### - -**Integration category:** Required for first integration | Common but policy-dependent - -1. - -## Optional integrations - -### - -**Integration category:** Optional - -## Advanced integrations - -### - -**Integration category:** Advanced or production-only - -## Production checks - -## Troubleshooting - -## Reference implementations to compare against -``` - -## Structure rules - -- H1: `# Integrating the Optimization SDK in a app`. Next.js variants name the - router: `Next.js App Router app`, `Next.js Pages Router app`. -- Fixed section order: intro → `## Quick start` → collapsible TOC → `## Before you start` → - `## Core integration` → `## Optional integrations` (if any) → `## Advanced integrations` (if any) - → `## Production checks` → `## Troubleshooting` (if any) → - `## Reference implementations to compare against`. -- No numbered headings at any level. No monolithic flow section (`## The integration flow`, - `## Required steps`, `## Core steps`). Procedures are numbered lists inside `###` sections. -- Every `###` feature section under Core/Optional/Advanced starts with a bold - `**Integration category:**` line, exactly one of: `Required for first integration`, - `Common but policy-dependent`, `Optional`, `Advanced or production-only`. `Required` and - `Common but policy-dependent` go under `## Core integration`; `Optional` under - `## Optional integrations`; `Advanced or production-only` under `## Advanced integrations`. -- `## Production checks` must cover: credentials/runtime config, consent behavior, event delivery, - content fallback, duplicate-tracking prevention, privacy/governance, and a local validation path. - -## Quick-start rules (the part reviews scrutinize most) - -- One proof, one verification statement, minimum setup. Valid proofs: SDK init + one accepted page - event; one entry resolving to variant or baseline; server content surviving takeover without a - duplicate page event. -- **Ground it in a real app shape, not a test harness.** Do not invent fetch shapes such as a - hardcoded array of entry IDs. Model the reader's most common real shape and show changes as diffs - against their existing files. Point readers with other shapes to the feature section. -- **Never hand over a full file the reader would paste over their own.** For layouts, providers, and - renderers, show a `+`/`-` diff so the additions are unambiguous, keep the reader's existing lines - (guards, chrome, settings fetch) visible around them, and add a one-line note that the surrounding - code is illustrative context to match against — not a block to paste verbatim. Do not "fix" - copy-paste friction by blending the additions into a rewritten file; that trades clarity for - pastability and loses more than it gains. -- **Keep example labels honest.** `**Copy this:**` only when values work as-is against what the - guide points to; otherwise `**Adapt this to your use case:**`, and name what is the reader's vs - the pattern to copy. A path or file the reader must relocate (import alias, app-root file) is an - adapt, not a copy. -- **Define each term at first use, tersely.** One clause is enough — for example, a consent config - key defined inline as "may store the visitor-profile cookie." Do not promise "you don't need the - model" and then require an undefined term two steps later. -- Keep optional concerns out of the quick start unless one is the chosen proof: interaction-tracking - config, analytics forwarding, identity, preview, live updates, offline queues, caching, strict - event policy, production hardening. Default interaction tracking may exist implicitly via the - entry wrapper. -- **Make verify performable.** The last step must tell the reader how to cause the result (e.g. an - experience targeting all visitors so they match automatically, or forcing a variant via the - preview panel) — not just "load as a qualifying visitor." - -## Quick-start scope discipline (per SDK) - -Before adding or removing quick-start steps, consult the SDK's own source and its existing guide for -the exact entry points — this skill names none. Apply these general rules: - -- Start from the SDK's primary/bound entry point; defer lower-level or escape-hatch subpaths to - later sections. -- The first path should cover only: init/config, the request or lifecycle wiring, one accepted event - or one entry resolving (variant or baseline), and the entry wrap. Exclude interaction tracking, - analytics forwarding, identity, preview, live updates, offline queues, and production cache policy - unless one of those is the explicit chosen proof. Default interaction tracking may exist - implicitly via the entry wrapper. -- **Call out version-specific filenames or exports that fail silently.** When a framework resolves a - handler by filename and/or export name, and the current vs previous major differ, name both - explicitly and state the failure mode (a wrong name silently no-ops rather than erroring). Verify - the exact names against the framework and SDK source. -- **Flag runtime-mode side effects of the integration.** If wiring the SDK changes a route's - rendering mode (e.g. forcing dynamic rendering and disabling static/incremental generation), say - so in the core path, not only under caching — verify the trigger against source. -- For screen/entry-heavy native runtimes, "init + one accepted screen event" is an acceptable first - proof when entry rendering would make the quick start disproportionately app-specific. - -## Rendering / entry-wrap rules (the recurring credibility trap) - -- The entry wrapper goes wherever an entry becomes a component. Many apps have one such hand-off (a - renderer or registry mapping content type to component); others render an entry directly in a - page. Use app-neutral wording — do not assume a specific file or symbol name (e.g. - "SectionRenderer") that varies between apps. -- If the render prop returns a type wider than the app's schema type (e.g. a base entry type rather - than the app's specific content-type interface), the snippet must show the cast the reader needs - (`resolved as YourType`) and say why. Verify against the SDK source (`packages/**/src`), not just - the reference implementation — do not claim type-identity the SDK does not provide. Recommend the - plain direct cast as the default; it works for common narrowed types. Mention - `as unknown as YourType` only as a rare escape for genuinely disjoint types — do not tie it to a - specific type modifier, and do not add it speculatively without compiling first. -- State the fallback contract explicitly: on denied consent / no variant / unresolved links / - all-locale payloads, the render prop receives the baseline entry and the UI does not break. - -## Example labels - -- `**Copy this:**` — pasteable with only simple value substitution; values must actually work. -- `**Adapt this to your use case:**` — realistic app-shaped code needing structural - change/placement; name reader-owned vs pattern-to-copy; prefer a `+`/`-` diff of the reader's - likely code. -- `**Follow this pattern:**` — illustrative shape, not drop-in runnable. -- `**Reference excerpt:**` — shortened/copied reference-implementation code. -- Comment only meaningful SDK-specific lines (lifecycle, consent, sequencing, fallback, - duplicate-event prevention). Never narrate obvious syntax. - -## Reference implementations - -- Link only to monorepo reference implementation READMEs — not external demos, sample apps, or - implementation source files. -- Frame reference implementations as maintained comparison and validation targets for SDK behavior. - Do not present them as optional examples or lower-stakes sample code. -- Mention relevant implementations briefly near the top only when they clarify the quick start or - first required setup path; otherwise expand the links in - `## Reference implementations to compare against`. - -## TOC rules - -- Preserve `` / ``. -- Place the collapsible TOC after the quick-start content, before the next `##`. Omit - `## Quick start` from the TOC (the reader already passed it). -- Include all `##` headings and the `###` feature/concept headings under Core/Optional/Advanced. - Include other `###` only when they are major alternate flows readers navigate to. -- Every TOC anchor must resolve to a real heading. Re-check after any heading edit. From 3579e3af4e6a5798c5915cccb4030e0f30b51ffa Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 12:43:07 +0200 Subject: [PATCH 34/42] =?UTF-8?q?=F0=9F=93=9D=20docs(sdk-knowledge):=20Pro?= =?UTF-8?q?mote=20Experience-response=20payload=20fact=20to=20shared/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guide review escalated the "Experience API returns a profile" claim as behavioral-no-backing-fact: it lived only in node/node.md, so the RN guide's explainer had no backing fact in the base that feeds it, violating the "capture each shared fact once in shared/" rule. Add shared/concepts.md#experience-response-payload: an accepted Experience call returns OptimizationData { profile, selectedOptimizations, changes }, and a stateful SDK applies it to its signals. Reference it from native/react-native.md; dedupe node/node.md to link the shared shape while keeping Node-specific request-client behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/native/react-native.md | 6 ++++++ documentation/internal/sdk-knowledge/node/node.md | 7 ++++--- .../internal/sdk-knowledge/shared/concepts.md | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/documentation/internal/sdk-knowledge/native/react-native.md b/documentation/internal/sdk-knowledge/native/react-native.md index 5321ee5a6..1ac54aeec 100644 --- a/documentation/internal/sdk-knowledge/native/react-native.md +++ b/documentation/internal/sdk-knowledge/native/react-native.md @@ -204,6 +204,12 @@ source: core-sdk#constants.ts#ANONYMOUS_ID_KEY; core-sdk#constants.ts#ANONYMOUS_ `states.eventStream` for accepted events (dedupe by `messageId`) and `states.blockedEventStream` for consent/allow-list diagnostics. Flags: `states.flag(name)` is a reactive observable; `getFlag(name)` a non-reactive read. source: core-sdk#CoreStateful.ts#CoreStates; core-sdk#CoreStatefulEventEmitter.ts#getFlag +- Experience-response payload: an accepted Experience call returns the `{ profile, +selectedOptimizations, changes }` payload, and this stateful SDK applies it to its Core signals + (`signals.profile`, `signals.selectedOptimizations`, `signals.changes`) — the origin of the profile + and the changes the SDK surfaces. See + [`../shared/concepts.md`](../shared/concepts.md#experience-response-payload). + source: core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals; core-sdk#CoreStateful.ts#CoreStates ## Consent & persistence diff --git a/documentation/internal/sdk-knowledge/node/node.md b/documentation/internal/sdk-knowledge/node/node.md index b8f571b68..76027553f 100644 --- a/documentation/internal/sdk-knowledge/node/node.md +++ b/documentation/internal/sdk-knowledge/node/node.md @@ -107,9 +107,10 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core - Request-bound event methods (on `CoreStatelessRequest`, awaited): Experience methods `page(payload?)` (payload defaults `{}`), `identify({ userId, traits? })` (`userId` required), `screen({ name, properties })`, `track({ event, properties? })` (`event` required) → return `EventEmissionResult` - `{ accepted, data? }` where `data` is `OptimizationData` `{ profile, selectedOptimizations, changes }`. - Accepted `page()`/`identify()` update the request client's `profile` and cached - `selectedOptimizations`. + `{ accepted, data? }` where `data` is the accepted Experience-response `OptimizationData` payload + (see [`../shared/concepts.md`](../shared/concepts.md#experience-response-payload)). Being stateless, + the request client does not apply it to any signals; instead accepted `page()`/`identify()` update + this request client's `profile` getter and cached `selectedOptimizations` for the rest of the request. source: core-sdk#CoreStatelessRequest.ts#page; core-sdk#CoreStatelessRequest.ts#identify; core-sdk#CoreStatelessRequest.ts#screen; core-sdk#CoreStatelessRequest.ts#track; core-sdk#events/EventEmissionResult.ts#EventEmissionResult; api-schemas#experience/ExperienceResponse.ts#OptimizationData - Insights methods: `trackView(payload)` returns `EventEmissionResult`; `trackClick`, `trackHover`, `trackFlagView` return `void`. `trackView({ componentId, viewId, viewDurationMs, experienceId?, variantIndex?, sticky? })`. diff --git a/documentation/internal/sdk-knowledge/shared/concepts.md b/documentation/internal/sdk-knowledge/shared/concepts.md index a9e84ef96..f6bf8cf35 100644 --- a/documentation/internal/sdk-knowledge/shared/concepts.md +++ b/documentation/internal/sdk-knowledge/shared/concepts.md @@ -69,3 +69,15 @@ consecutive route keys. When the server already reported a consented page view, skip the duplicate (per-SDK `initialPageEvent` / tracker prop). Interaction events (view/click/hover) are consent-gated browser activity and use the resolved entry id. source: react-web-sdk#auto-page/useAutoPageEmitter.ts; react-web-sdk#router/next-app.tsx + +## Experience response payload + +An accepted Experience API request (page/identify/screen/track) returns `OptimizationData` +`{ profile, selectedOptimizations, changes }` — this response is the origin of the profile, the +selected optimizations, and the computed flag `changes` the rest of the SDK consumes. `OptimizationData` +mirrors the wire `ExperienceData` but renames its `experiences` field to `selectedOptimizations`. A +stateful SDK applies the payload to its personalization signals (`profile`, `selectedOptimizations`, +`changes`) in one batch, transitioning the Experience-request state to `success` atomically with the +selections so consumers never see `!pending` while optimization is still unavailable; a stateless SDK +returns the same payload per request instead of holding it. +source: api-schemas#experience/ExperienceResponse.ts#OptimizationData; api-schemas#experience/ExperienceResponse.ts#ExperienceData; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals From d16de49b3fc49d8502b9aa0945f6aa4c6d7552ef Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 12:43:23 +0200 Subject: [PATCH 35/42] =?UTF-8?q?=F0=9F=93=9D=20docs(react-native):=20Alig?= =?UTF-8?q?n=20RN=20guide=20via=20pipeline;=20fix=20explainer=20fragment?= =?UTF-8?q?=20+=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First full pipeline run on the new recipe/fragment system (RN guide). The writer realigned two intro bullets to the personalization-explainer fragment spine — reconciling real drift between two pipelined guides (RN "builds/keeps consistent" vs the canonical "returns/uses to keep consistent") — proving the primitive. Fix defects review surfaced in the new artifacts: - Fragment opener said "N sentences" but bullets are multi-sentence → "N points", with the four/five count tied to profile-bullet inclusion. - Composed entry-source bullet oversold freedom (a reorder license that invited the drift the writer then undid) → near-verbatim, fixed order. - Two new authoring-checklist rules: stated-count-matches-content, and a verify step's inspection mechanism must be present in the pasted quick-start code. Apply the RN reader-experience fixes: profile inbound/outbound consistency, performable screen-event verify (logLevel in the quick start), nt_experiences vs nt_experience gloss, changes/flag/merge-tag gloss, consent placement, include: 10 why. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fragments/personalization-explainer.md | 22 +++--- ...-react-native-sdk-in-a-react-native-app.md | 68 +++++++++++-------- .../references/authoring-checklist.md | 9 +++ 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/documentation/authoring/fragments/personalization-explainer.md b/documentation/authoring/fragments/personalization-explainer.md index 2f70152c6..a8ef615d4 100644 --- a/documentation/authoring/fragments/personalization-explainer.md +++ b/documentation/authoring/fragments/personalization-explainer.md @@ -14,9 +14,10 @@ Two kinds of bullet, and they instantiate differently: These fixed sentences are what keep the family consistent — do not reword them. - **Composed bullets** — the entry-source hand-off bullet is composed from a knowledge-base fact around a fixed anchor: the sentence must end with the **baseline-fallback** contract (the exact - clause below), but the lead and the fetch-boundary wording are shaped to the SDK because the - boundary itself is SDK-shaped (manual-only vs. managed vs. both) and some SDKs reorder the sentence. - Keep the anchor exact; compose the rest from the KB entry-source-boundary fact. + clause below), and the only free choices are the lead (which of the three prescribed strings fits + the app) and whether the managed-fetch clause is present. Keep the rest as written; do not reorder + or reshape it — this bullet is near-verbatim, not open composition. Compose only from the KB + entry-source-boundary fact. Every slot resolves from a knowledge-base fact — never invent one. @@ -35,7 +36,8 @@ Slots and where each is filled from: - **⟨profile bullet⟩** — include this bullet **only if the SDK persists a per-visitor profile** across requests or launches (Node, React Native). SDKs that hold no cross-request identity in the explainer omit the whole bullet (the web family). Fill the requests-vs-launches wording from the - KB runtime model. + KB runtime model. **This inclusion drives the opener count** (below): five points when the profile + bullet is included, four when it is omitted. - **entry-source hand-off bullet (composed)** — compose this bullet from the KB entry-source-boundary fact around one fixed anchor: it must state that the SDK hands back the resolved variant, **or the original entry when no variant applies, the baseline fallback** (that clause is the anchor — keep @@ -44,15 +46,14 @@ Contentful entries and turns them into a response`, `Your app turns Contentful e markup`, `Your app already fetches Contentful entries and turns them into components`). If the SDK supports **managed fetching** (it can take the reader's client and fetch by ID), add the "fetch it yourself or give the SDK your client — either way the client stays yours" clause; omit it for - manual-only SDKs. A screen-first SDK (React Native) may reorder the sentence so the hand-off reads - naturally — that is allowed for this composed bullet, as long as the baseline-fallback anchor - survives intact. + manual-only SDKs. Do not reorder the sentence to lead with the fetch step — keep the hand-off → + baseline-fallback → fetch-boundary order shown in the Template. - **⟨deferred concepts⟩** — the not-yet list for this SDK, drawn from the sections this guide defers (audiences, interaction events, identity, preview, live updates…). ## Template -**New to personalization?** Here is the whole idea in ⟨four | five⟩ sentences: +**New to personalization?** Here is the whole idea in ⟨four | five⟩ points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. @@ -64,8 +65,9 @@ markup`, `Your app already fetches Contentful entries and turns them into compon personalization consistent across ⟨requests | app launches⟩. - ⟨entry-source hand-off bullet — composed from the KB entry-source-boundary fact; MUST end with the baseline-fallback anchor "…or the original entry when no variant applies, the **baseline - fallback**." Add the managed-fetch clause only if the SDK fetches by ID; a screen-first SDK may - reorder the sentence. Example (managed, browser): "Your app turns Contentful entries into markup, + fallback**." Add the managed-fetch clause only if the SDK fetches by ID; keep the sentence order + shown here (do not lead with the fetch step). Example (managed, browser): "Your app turns + Contentful entries into markup, and the SDK sits at that hand-off: it gives you the resolved variant instead of the original — or the original entry when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it to the SDK, or give the SDK your Contentful client and let it fetch by ID — diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md index 78e0ac206..dbf9d391f 100644 --- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md +++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md @@ -3,20 +3,23 @@ Use this guide to add Contentful personalization to a React Native or Expo app using `@contentful/optimization-react-native`. By the end of the quick start, one Contentful entry will render the personalized variant for the current visitor — or the original entry when none applies — -and one screen event will report the visitor's profile to Contentful. +and one screen event will report the visitor's screen view to Contentful, which uses it to keep that +visitor's personalization consistent. -**New to personalization?** Here is the whole idea in five sentences: +**New to personalization?** Here is the whole idea in five points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. - As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. -- The Experience API also builds a **profile**: the anonymous, per-visitor identity and state the - SDK keeps consistent across app launches. -- Your app gets a Contentful entry — you fetch it and hand it to the SDK, or you give the SDK your - Contentful client and let it fetch by ID — and the SDK resolves it, returning the picked variant - or, when no variant applies, the original entry (the **baseline fallback**). +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state the + SDK uses to keep the same visitor's personalization consistent across app launches. +- Your app already fetches Contentful entries and turns them into components, and the SDK sits at + that hand-off: it gives you the resolved variant instead of the original — or the original entry + when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it to + the SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client + stays yours. - You render whatever the SDK hands back exactly as you render entries today. That is enough to start. You do not need audiences, interaction events, identity, or the preview @@ -45,12 +48,10 @@ baseline — and reports one screen event.** It mounts one `OptimizationRoot`, h Contentful client so it can fetch the entry by ID, and tracks the screen with `useScreenTracking`. This quick start assumes your application policy permits Optimization to start with accepted consent -and renders no end-user consent UI. Consent has two independent parts: `events` (may the SDK -personalize and send events) and `persistence` (may the SDK store profile continuity in -AsyncStorage). The shorthand `consent: true` sets both to `true`; the object form -`{ events, persistence }` sets them separately. If personalization must wait for a consent decision, -keep this structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) -step before you ship. +and renders no end-user consent UI, so it seeds `defaults={{ consent: true }}` — the shorthand that +accepts both consent axes at once. If personalization must wait for a consent decision, keep this +structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step +before you ship, which explains the two axes and the object form that sets them separately. 1. Install the React Native SDK, its required AsyncStorage peer dependency, and a Contentful delivery client if your app does not already have one. @@ -109,11 +110,14 @@ step before you ship. contentful={{ client: contentfulClient, defaultQuery: { - include: 10, // Resolve optimization and variant links before SDK resolution. + // An optimized entry links its experiences and their variants as nested entries; fetch + // deep enough to pull them back in one payload (see Contentful entry fetching below). + include: 10, locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale. }, }} defaults={{ consent: true }} + logLevel="debug" // Surface SDK activity, including the accepted screen event, so you can verify it. > @@ -127,12 +131,14 @@ step before you ship. `OptimizedEntry` — keep the rest of your components as they are. 3. Verify the first run. Launch the app; the screen displays a resolved entry ID for either the - selected variant or the baseline. To confirm the screen event fired, watch your SDK logs or - `states.eventStream` for one accepted `screen` event (see - [Screen and navigation tracking](#screen-and-navigation-tracking)). To see personalization rather than the baseline, author (in - Contentful) a variant of `hero-entry-id` attached to an experience that targets all visitors — - every visitor matches it automatically, so the resolved ID changes to the variant. Without an - authored variant every launch shows the baseline entry, which is expected, not a failure. + selected variant or the baseline. Because `logLevel="debug"` is set above, the SDK logs its + activity to the console, so you can confirm the screen event fired by watching your Metro or + device logs on mount for the `screen` event the SDK sends (the [Screen and navigation tracking](#screen-and-navigation-tracking) + and [Analytics forwarding](#analytics-forwarding) sections add `states.eventStream` for a + programmatic check). To see personalization rather than the baseline, author (in Contentful) a + variant of `hero-entry-id` attached to an experience that targets all visitors — every visitor + matches it automatically, so the resolved ID changes to the variant. Without an authored variant + every launch shows the baseline entry, which is expected, not a failure.
Table of Contents @@ -251,7 +257,10 @@ until the SDK is ready. **Integration category:** Common but policy-dependent -Consent policy belongs to your application. The SDK stores event consent, stores separate durable +Consent policy belongs to your application. Consent has two independent axes: `events` (may the SDK +personalize and send events) and `persistence` (may the SDK store profile continuity in +AsyncStorage). The shorthand `consent: true` sets both to `true`; the object form +`{ events, persistence }` sets them separately. The SDK stores event consent, stores separate durable profile-continuity persistence consent, and blocks non-allowed event types until event consent is accepted. @@ -300,8 +309,10 @@ allow-listed. Boolean consent calls control both event emission and durable prof `optimization.consent({ events: true, persistence: false })` when events can emit but the stored profile-continuity state must stay session-only. That state is the anonymous identity, the profile, the **selected optimizations** (which variant the Experience API picked for each experience), and the -**changes** (the profile-backed flag and merge-tag values the Experience API returns) — all of which -otherwise persist in AsyncStorage across launches. +**changes** (the profile-backed values the Experience API returns for feature flags — named on/off or +valued settings — and merge tags — profile-driven substitutions in Rich Text; both are covered in +[Merge tags and Custom Flags](#merge-tags-and-custom-flags)) — all of which otherwise persist in +AsyncStorage across launches. For cross-SDK policy details, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). @@ -319,11 +330,12 @@ direct values, not locale-keyed maps. layer. 2. Configure `contentful.defaultQuery` on the SDK, pass per-entry `entryQuery`, or pass the locale to manual Contentful CDA requests. -3. Request enough link depth for the SDK to resolve variants. `nt_experiences` is a fixed - Optimization field the SDK adds to an optimized entry (an SDK-owned content-model name you do not - choose); it links the experiences, and each experience links its variant entries. Your fetch must - `include` deeply enough to pull those linked entries back in one payload. `include: 10` is the - repository reference implementation's pattern. +3. Request enough link depth for the SDK to resolve variants. `nt_experiences` (plural) is a fixed + Optimization link field the SDK adds to an optimized entry; it links that entry's experiences, + and each experience links its variant entries. Do not confuse it with `nt_experience` (singular), + the content type of the experience entries it links to. Both are SDK-owned content-model names you + do not choose. Your fetch must `include` deeply enough to pull those linked entries back in one + payload. `include: 10` is the repository reference implementation's pattern. 4. Pass the same locale to SDK `locale` when Experience API responses and event context must use the same language. 5. Do not pass `contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 6f9307d72..702d42784 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -8,6 +8,10 @@ add per-archetype checks. - [ ] The intro opens by naming the working result, not stacked jargon. The first sentence a reader hits does not use an undefined term. +- [ ] **A stated count matches what follows.** If the intro explainer promises "the whole idea in N + points" (or sentences/steps), the actual count matches. Because explainer bullets can be + multi-sentence, count the unit named — the `personalization-explainer` fragment counts + **points** (bullets), five when the profile bullet is included, four when omitted. - [ ] Every term the quick start relies on is defined in plain language at or before first use (for SDK guides that includes the domain concepts — variant, experience, Experience API, resolving, baseline fallback — plus any SDK-owned config key, identifier, or event name the quick start @@ -102,6 +106,11 @@ add per-archetype checks. start promises more than one observable result (e.g. "one entry resolving and one screen event"), the verify step confirms every promised result where the reader hits it — or the quick start is narrowed to a single proof. A promised proof with no matching verification is a defect. +- [ ] **The inspection mechanism a verify step names is present in the pasted quick-start code.** If + the verify step tells the reader to watch `states.eventStream`, read a `logLevel` log line, or + inspect any accessor, the quick-start snippet the reader just pasted must already expose it — a + verify step that depends on an accessor only wired up in a later section is not performable + where the reader hits it. - [ ] **The quick start is grounded in a real app shape** — no invented fetch shapes (e.g. a hardcoded array of entry IDs). The most common real shape leads; other shapes are pointed to a feature section. From 984dd8cdf565901f279a4d99bf6f3a4f5162de42 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 12:43:38 +0200 Subject: [PATCH 36/42] =?UTF-8?q?=E2=9C=A8=20feat(commands):=20Add=20/iter?= =?UTF-8?q?ate-guide=20=E2=80=94=20fast=20editorial=20loop,=20no=20fact=20?= =?UTF-8?q?re-derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give technical writers a snappy inner loop: tune phrasing, tone, or sequence — or edit a recipe/fragment and re-render every guide that instantiates it — without running the whole pipeline. It recomposes only the affected prose from the EXISTING knowledge base; no source read, no fact re-verified, because a pure-prose change moves no fact. Hard-stop guardrail: the moment an edit would change what a guide asserts about the SDK, it stops and hands off to /refresh-docs (source changed) or /review-guide (verify vs the base) rather than silently drifting a fact. /review-guide remains the final gate. Wire it into the command family: cross-reference from /refresh-docs and surface it in the authoring README's Iterating section. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/iterate-guide.md | 75 +++++++++++++++++++++++++++++++ .claude/commands/refresh-docs.md | 4 +- documentation/authoring/README.md | 8 ++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/iterate-guide.md diff --git a/.claude/commands/iterate-guide.md b/.claude/commands/iterate-guide.md new file mode 100644 index 000000000..f213358b5 --- /dev/null +++ b/.claude/commands/iterate-guide.md @@ -0,0 +1,75 @@ +--- +description: Fast prose/structure/sequence iteration on a guide — recompose from the existing knowledge base and recipes, no source read, no fact re-verification +argument-hint: '[guide path + the phrasing/tone/sequence change, or a recipe/fragment edit to render]' +--- + +Iterate on a guide's **prose, structure, or sequence** and re-render it fast: `$ARGUMENTS` (if empty, +ask which guide and what to change). + +This is the **tuning path**, and it is deliberately the cheapest of the docs commands. It exists for +the technical writer's tight loop: adjust phrasing, tone of voice, section order, an example label, +or a recipe/fragment's wording — then see the rendered guide — without re-running the whole pipeline. +**No source is read, no knowledge-base fact is re-derived, and no fact is re-verified**, because a +pure-prose change moves no fact. The knowledge base is taken as-is: current and true. + +Use this when the change is editorial. If the change would alter what the guide **asserts** about the +SDK, stop and hand off (see the guardrail) — that is `/refresh-docs` (source changed) or +`/review-guide` (verify a claim against the base), not this command. + +## What counts as in-scope (editorial) + +- Rewording for clarity, tone, or voice; tightening or expanding an explanation. +- Reordering or re-sequencing sections; moving content between sections; changing what leads. +- Editing a recipe (`documentation/authoring/recipes/`) or fragment + (`documentation/authoring/fragments/`) and re-rendering the guides that instantiate it. +- Changing an example-intent label's prose, a heading's wording (with its TOC anchor), or a + transition. + +## What is out of scope (a fact moved — hand off) + +A change is out of scope the moment it alters a **factual claim**: a prop/config-key name or type, a +signature, a return shape, a default, a fallback/consent/dynamic-render behavior, an identifier's +ownership, an event name, or a cross-SDK semantic. These are the knowledge base's domain. + +**Guardrail — hard stop.** If a requested edit would change what a sentence asserts about the SDK, do +NOT reword the claim, do NOT read source to check it, and do NOT launch `sdk-knowledge-author`. Stop +and tell the writer which command to use instead: + +- the underlying SDK behavior actually changed → `/refresh-docs`, +- the guide's claim may be wrong and needs checking against the base → `/review-guide`. + +Rewording that preserves the assertion is fine ("hands you back the variant" → "returns the variant" +is editorial); rewording that changes the assertion is not ("returns the variant" → "returns the +variant or null" asserts a new fact — stop). + +## Steps + +1. **Scope the change.** State the guide(s) affected and whether the edit is to the guide prose + directly, or to a recipe/fragment that several guides instantiate (then all instantiating guides + are in scope — find them by which archetype/fragment they use). Confirm the change is editorial; + if any part trips the guardrail, name it and stop for that part. + +2. **Recompose the affected prose (guide-writer).** Launch `guide-writer` scoped to the editorial + change. It composes from the **existing** knowledge base (fills fragment slots and behavioral prose + from current facts — never re-tracing source, never escalating a fact) and reads interface only if + it must confirm a shape it is _rendering_ (not changing). If a recipe/fragment changed, it + re-instantiates that fragment verbatim into each affected guide, filling slots from the current KB. + It restructures and rewords per the `optimization-guide-authoring` skill and the archetype recipe; + it does not alter factual claims. + +3. **Validate cheaply.** `pnpm format:fix ` (never bare) and confirm the + collapsible TOC anchors still resolve. Run `pnpm knowledge:check` **only if** a recipe/fragment or + KB-adjacent file was touched (it is fast and confirms nothing drifted); a pure guide-prose edit + does not require it. Do NOT run the newcomer/source reviewers here — that is the final pass. + +4. **Report.** The change made, the guides re-rendered, anything the guardrail sent to `/refresh-docs` + or `/review-guide`, and the validation result. Remind the writer to run **`/review-guide`** as the + final pass before shipping — that is where the newcomer + source-verification roles gate the + result. This command is the fast inner loop; `/review-guide` is the gate. + +## Relationship to the other docs commands + +- `/author-guide` — bootstrap a new SDK's docs (reads source, builds the KB). Expensive. +- `/refresh-docs` — a source change (re-verifies affected facts, recomposes affected guides). +- **`/iterate-guide`** (this) — editorial tuning only (no source, no fact work). The inner loop. +- `/review-guide` — the final pass: newcomer + technical-foundation review, funnel-back, gate. diff --git a/.claude/commands/refresh-docs.md b/.claude/commands/refresh-docs.md index 21dd00b01..9b988198d 100644 --- a/.claude/commands/refresh-docs.md +++ b/.claude/commands/refresh-docs.md @@ -10,7 +10,9 @@ This is the **steady-state path**, and it is deliberately cheap. It does NOT re- recompose whole guides. The diff bounds the work: only the knowledge-base facts the change touches get re-verified, and only the guides that consume those facts get recomposed. If a change turns out to need a brand-new KB file (a new SDK, or a guide that was never authored the current way), stop and -use **`/author-guide`** instead — that is the bootstrap path. +use **`/author-guide`** instead — that is the bootstrap path. If nothing about the SDK changed and the +edit is purely editorial — phrasing, tone, section order, a recipe/fragment wording — use +**`/iterate-guide`** instead; it skips all fact work and just re-renders the prose. ## 1. Scope from the diff (deterministic — no agent) diff --git a/documentation/authoring/README.md b/documentation/authoring/README.md index a3741d8ac..de7ced48a 100644 --- a/documentation/authoring/README.md +++ b/documentation/authoring/README.md @@ -60,3 +60,11 @@ Machine enforcement of recipe/fragment usage (a `guides:check` validator) is **d the `guide-writer` and the newcomer/source reviewers enforce it by following these files. Because fragment spines are reproduced verbatim, a future check can confirm instantiation by matching the fixed sentences. + +## Iterating + +To tune wording, tone, or sequence and re-render the guides fast, edit a recipe or fragment here and +run **`/iterate-guide`** — it recomposes the affected guides from the existing knowledge base without +reading source or re-verifying facts. It is editorial-only: a change that would alter what a guide +_asserts_ about the SDK (a prop, a behavior, a return shape) hands off to `/refresh-docs` or +`/review-guide`. Run `/review-guide` as the final pass before shipping. From ed6e221f9d964dcea4158bd192189462ed30d62f Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 12:53:45 +0200 Subject: [PATCH 37/42] =?UTF-8?q?=F0=9F=93=9D=20docs(contributing):=20Sign?= =?UTF-8?q?post=20the=20docs=20authoring=20pipeline=20for=20devs=20and=20w?= =?UTF-8?q?riters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add discoverable entry points to the agent-driven docs system without duplicating the self-documenting artifacts: - CONTRIBUTING.md gains a "Guides and the knowledge base" section under Documentation — the three-layer pipeline, the four slash commands and when to use each, and knowledge:check — pointing into the authoring and sdk-knowledge READMEs for depth (dev/contributor signpost). - documentation/authoring/README.md gains a "Start here (technical writers)" opening: edit a recipe/fragment, re-render with /iterate-guide, gate with /review-guide (writer front door). No new standalone doc; both are thin pointers, one source of truth per fact. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 32 +++++++++++++++++++++++++++++++ documentation/authoring/README.md | 19 ++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1ac68fb7..b5602a9fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,7 @@ gotchas. - [Validation matrix](#validation-matrix) - [Code style and local hooks](#code-style-and-local-hooks) - [Documentation](#documentation) + - [Guides and the knowledge base (authoring pipeline)](#guides-and-the-knowledge-base-authoring-pipeline) - [README depth and render targets](#readme-depth-and-render-targets) - [Local troubleshooting](#local-troubleshooting) - [Troubleshooting CI issues](#troubleshooting-ci-issues) @@ -326,6 +327,37 @@ keep these artifacts aligned: `documentation/` contains source markdown that TypeDoc publishes. `docs/` is generated output. Do not hand-edit generated TypeDoc output. +### Guides and the knowledge base (authoring pipeline) + +The guides under `documentation/guides/` are not hand-maintained in isolation — they are composed by +an agent-driven pipeline with three source-of-truth layers, so a source change propagates instead of +being re-derived by hand: + +1. **Knowledge base** (`documentation/internal/sdk-knowledge/`, internal, not published) — verified + SDK _behavioral_ facts, each carrying a machine-checked `source:` pointer into `packages/**/src`. + Interface (signatures, prop shapes) is read directly from the types, not stored here. +2. **Recipes and fragments** (`documentation/authoring/`, writer-owned, not published) — the editorial + structure: one recipe per guide archetype and the reusable prose fragments they compose. This is + where a technical writer shapes wording, tone, and sequence. +3. **Guides** (`documentation/guides/`, published) — reader-facing prose, composed from the KB facts + and the recipes. + +Four slash commands drive it; pick by what changed: + +| Command | Use when | +| ---------------- | -------------------------------------------------------------------------------------------------- | +| `/author-guide` | a new SDK with no knowledge-base file yet (bootstrap; reads source once) | +| `/refresh-docs` | SDK source changed — re-verify only affected facts, recompose affected guides | +| `/iterate-guide` | editorial-only change (phrasing, tone, sequence, a recipe/fragment) — no source read, no fact work | +| `/review-guide` | the final pass before shipping — newcomer + technical-foundation review, then gate | + +`pnpm knowledge:check` validates the knowledge base (every `source:` pointer resolves, templates +conform, `feeds-guides` links are valid) and runs in CI on knowledge-base and `packages/**/src` +changes. For how the system works, start at +[`documentation/authoring/README.md`](./documentation/authoring/README.md) (recipes and fragments) +and [`documentation/internal/sdk-knowledge/README.md`](./documentation/internal/sdk-knowledge/README.md) +(the knowledge base and its pointer grammar). + ### README depth and render targets READMEs are orientation surfaces, not the only place for every detail. Match depth to the README diff --git a/documentation/authoring/README.md b/documentation/authoring/README.md index de7ced48a..5872c8283 100644 --- a/documentation/authoring/README.md +++ b/documentation/authoring/README.md @@ -9,6 +9,25 @@ guides, never one guide surgically, and never by editing agent logic. The compos LLM (`guide-writer`), not a deterministic templating engine: recipes and fragments are prose it reads and instantiates, not code it executes. +## Start here (technical writers) + +This directory is your front door. To change how the guides read: + +1. **Edit the structure or wording** — a **recipe** (`recipes/`) for a guide archetype's section + spine and sequence, or a **fragment** (`fragments/`) for a shared paragraph reused across guides. + You never touch SDK facts (those live in the knowledge base) or agent logic. +2. **Re-render fast** — run **`/iterate-guide`**; it recomposes the affected guides from the existing + knowledge base without reading source or re-verifying facts. This is the tight inner loop for + phrasing, tone, and sequence. A change that would alter what a guide _asserts_ about the SDK (a + prop, a behavior, a return shape) is out of scope and hands off to `/refresh-docs` or + `/review-guide`. +3. **Gate before shipping** — run **`/review-guide`**; it runs the newcomer and technical-foundation + review roles and funnels durable lessons back into recipes, fragments, or the knowledge base. + +For the SDK-fact side of the pipeline (`/author-guide`, `/refresh-docs`, `pnpm knowledge:check`), see +[`../internal/sdk-knowledge/README.md`](../internal/sdk-knowledge/README.md) and the "Guides and the +knowledge base" section of the repository [`CONTRIBUTING.md`](../../CONTRIBUTING.md). + ## The layers | Layer | Holds | Owner | From 8e3069de3269da307ac61faeb13c8641c33e272c Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 13:26:33 +0200 Subject: [PATCH 38/42] =?UTF-8?q?=F0=9F=90=9E=20fix(sdk-knowledge):=20Addr?= =?UTF-8?q?ess=20PR=20review=20=E2=80=94=20hook=20pathspec,=20meta-file=20?= =?UTF-8?q?match,=20coverage=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fmamud review findings on PR #369, all verified against the code: - Stop hook: `packages/**/src` matched zero files in `git status --porcelain` (empirically confirmed); use `packages/**/src/**` so a source-only change actually triggers the in-session knowledge:check. - Validator meta-file exemption used `path.basename`, which would also exempt a future nested `native/README.md` from all checks. Match by path relative to the knowledge dir via a new `isMetaFile()`, applied at both call sites. - Pointer coverage keyed off `line.endsWith('.')`, silently exempting any single-line fact not ending in a period (e.g. one ending in a backticked symbol) — narrowing the silent-rot guard. Replace with a fact-unit-end test (`isFactUnitEnd`) plus a label-lead-in skip; verified against the live KB (~122 wrapped facts still pass — no false positives) and that a period-less unsourced fact is now caught. Extracted the coverage shape-helpers (isFactUnitEnd, isLabelLeadIn, looksLikeFact, isCrossReference) into sdk-knowledge/markdown.ts to stay under the module line-cap — splitting into modules, per the repo rule, not disabling the lint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/knowledge-check.sh | 2 +- scripts/sdk-knowledge/markdown.ts | 60 ++++++++++++++++++++++++++++++ scripts/validate-sdk-knowledge.ts | 61 ++++++++++++------------------- 3 files changed, 85 insertions(+), 38 deletions(-) diff --git a/.claude/hooks/knowledge-check.sh b/.claude/hooks/knowledge-check.sh index 2849dad62..c8b6d3460 100755 --- a/.claude/hooks/knowledge-check.sh +++ b/.claude/hooks/knowledge-check.sh @@ -15,7 +15,7 @@ cwd="$(printf '%s' "$input" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]* # Only spend time when the knowledge base or package source changed in this working tree — a session # that never touched either cannot have introduced KB drift. if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - changed="$(git status --porcelain -- documentation/internal/sdk-knowledge 'packages/**/src' 2>/dev/null)" + changed="$(git status --porcelain -- documentation/internal/sdk-knowledge 'packages/**/src/**' 2>/dev/null)" [ -z "$changed" ] && exit 0 fi diff --git a/scripts/sdk-knowledge/markdown.ts b/scripts/sdk-knowledge/markdown.ts index 1eb635ea3..1e76b77f1 100644 --- a/scripts/sdk-knowledge/markdown.ts +++ b/scripts/sdk-knowledge/markdown.ts @@ -56,3 +56,63 @@ export function isPlaceholderCell(cell: string): boolean { export function isListItem(line: string): boolean { return /^[-*]\s|^\d+\.\s/u.test(line) } + +/** + * True if the list item at `index` is a single-line fact — nothing continues it. The next line is + * end-of-file, blank, another list item, or the item's own `source:` line. If instead an indented + * prose continuation follows, this bullet line is mid-fact (its terminal clause and pointer are + * below), so it must not be judged as a complete fact here. Multi-line facts are graded via their + * trailing `source:` line instead. + */ +export function isFactUnitEnd(lines: string[], index: number): boolean { + const { [index + 1]: next } = lines + if (next === undefined) { + return true + } + const trimmed = next.trim() + return trimmed === '' || isListItem(trimmed) || trimmed.includes('source:') +} + +/** + * True for a `**Label:**` or `Label:` bullet that only introduces a group of sub-bullets. Such a + * lead-in states no fact of its own; the group's shared `source:` sits on its last child, so the + * lead-in must not be required to carry its own pointer. + */ +export function isLabelLeadIn(line: string): boolean { + const withoutMarker = line.replace(/^[-*]\s+/u, '') + return withoutMarker.endsWith(':') || withoutMarker.endsWith(':**') +} + +/** + * A heuristic for "this list item is an SDK fact that must be sourced" vs prose guidance. Facts + * reference a concrete symbol/config key/path/identifier as inline code. A bullet that only + * delegates to another doc ("Model: see ../shared/concepts.md#…") is a cross-reference, not a fact. + */ +export function looksLikeFact(line: string, labelLeadInMax: number): boolean { + if (!/`[^`]+`/u.test(line)) { + return false + } + return !isCrossReference(line, labelLeadInMax) +} + +/** + * True if a bullet is a pure cross-reference — it just points the reader at another doc ("Model: see + * ../shared/concepts.md#…") rather than stating a fact of its own. Such a bullet inherits its source + * from the referenced section and must NOT be required to carry a pointer. + * + * Detection: it must contain the word "see", and after stripping the list marker, an optional short + * "Label:" lead-in (up to `labelLeadInMax` chars), a leading "see", every markdown link, and every + * inline-code span, nothing but punctuation may remain. A real fact leaves prose words behind and so + * is not treated as a reference. + */ +export function isCrossReference(line: string, labelLeadInMax: number): boolean { + if (!/\bsee\b/iu.test(line)) { + return false + } + const body = line + .replace(/^[-*]\s+|^\d+\.\s+/u, '') + .replace(new RegExp(`^[^:]{0,${labelLeadInMax}}:\\s*`, 'u'), '') + .replace(/^see\s+/iu, '') + const withoutLinks = body.replace(/\[[^\]]*\]\([^)]*\)/gu, '').replace(/`[^`]*`/gu, '') + return withoutLinks.replace(/[\s.,;]/gu, '') === '' +} diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index 98cc980c2..20f3c61fa 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -53,9 +53,12 @@ import { fileURLToPath } from 'node:url' import { type Heading, headingsOf, + isFactUnitEnd, + isLabelLeadIn, isListItem, isPlaceholderCell, isTableDivider, + looksLikeFact, parseTableRow, } from './sdk-knowledge/markdown' import { collectDeclaredSymbols } from './sdk-knowledge/source-symbols' @@ -128,7 +131,7 @@ report() function validateFile(absPath: string): void { // README.md and _template.md are meta files: they hold placeholder `source:` guidance and // illustrative examples, not real facts. Exempt them so the format's own docs are not graded. - if (META_FILES.has(path.basename(absPath))) { + if (isMetaFile(absPath)) { return } @@ -399,13 +402,21 @@ function checkFile(candidate: string, label: string, file: string, line: number) function checkPointerCoverage(lines: string[], file: string): void { lines.forEach((rawLine, index) => { const line = rawLine.trim() - if (!isListItem(line) || !line.endsWith('.') || line.endsWith('None.')) { + // Only a list item that is the last line of its own fact unit is a candidate: a single-line + // bullet (a wrapped fact's bullet line is not — its terminal prose, and pointer, live on a + // continuation line, so it is graded there via itemHasPointerNearby, not here). Skip `None.` + // placeholders and `Label:`/`Label:**` lead-ins that only head a group of sub-bullets, whose + // shared pointer sits on the group's last child. + if (!isListItem(line) || !isFactUnitEnd(lines, index) || line.endsWith('None.')) { + return + } + if (isLabelLeadIn(line)) { return } if (line.includes('source:') || itemHasPointerNearby(lines, index)) { return } - if (looksLikeFact(line)) { + if (looksLikeFact(line, LABEL_LEADIN_MAX)) { addProblem(file, index + 1, `fact has no source: pointer — "${truncate(line)}"`) } }) @@ -434,39 +445,6 @@ function itemHasPointerNearby(lines: string[], index: number): boolean { return false } -/** - * A heuristic for "this list item is an SDK fact that must be sourced" vs prose guidance. Facts - * reference a concrete symbol/config key/path/identifier as inline code. A bullet that only - * delegates to another doc ("Model: see ../shared/concepts.md#…") is a cross-reference, not a fact. - */ -function looksLikeFact(line: string): boolean { - if (!/`[^`]+`/u.test(line)) { - return false - } - return !isCrossReference(line) -} - -/** - * True if a bullet is a pure cross-reference — it just points the reader at another doc ("Model: see - * ../shared/concepts.md#…") rather than stating a fact of its own. Such a bullet inherits its source - * from the referenced section and must NOT be required to carry a pointer. - * - * Detection: it must contain the word "see", and after stripping the list marker, an optional short - * "Label:" lead-in, a leading "see", every markdown link, and every inline-code span, nothing but - * punctuation may remain. A real fact leaves prose words behind and so is not treated as a reference. - */ -function isCrossReference(line: string): boolean { - if (!/\bsee\b/iu.test(line)) { - return false - } - const body = line - .replace(/^[-*]\s+|^\d+\.\s+/u, '') - .replace(new RegExp(`^[^:]{0,${LABEL_LEADIN_MAX}}:\\s*`, 'u'), '') - .replace(/^see\s+/iu, '') - const withoutLinks = body.replace(/\[[^\]]*\]\([^)]*\)/gu, '').replace(/`[^`]*`/gu, '') - return withoutLinks.replace(/[\s.,;]/gu, '') === '' -} - // --- template conformance -------------------------------------------------------------------- /** @@ -652,7 +630,16 @@ function isPerSdkFile(absPath: string): boolean { if (firstSegment !== undefined && SHARED_DIRS.has(firstSegment)) { return false } - return !META_FILES.has(path.basename(absPath)) + return !isMetaFile(absPath) +} + +/** + * True only for the base's own format docs at its root: `README.md` (the grammar) and `_template.md` + * (the skeleton). Matched by path relative to the knowledge dir, NOT by basename — a per-SDK or family + * `README.md` nested under the base (e.g. `native/README.md`) is a fact file and must still be graded. + */ +function isMetaFile(absPath: string): boolean { + return META_FILES.has(path.relative(knowledgeDir, absPath)) } function fileExists(candidate: string): boolean { From 971a5698e3fe2ae38a9c3b3a5fe0769ca222a688 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 13:53:39 +0200 Subject: [PATCH 39/42] =?UTF-8?q?=F0=9F=93=9D=20docs(guides):=20Align=20th?= =?UTF-8?q?e=205=20pipelined=20guides=20to=20the=20recipe/fragment=20syste?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conformance pass (guide-writer per guide, then a cross-guide consistency review) bringing Node, Web, React Web, Next.js App Router, and Next.js Pages Router into verbatim instantiation of the personalization-explainer and authored-variant-gotcha fragments. Narrow edits only — structure already conformed. - Intro opener "N sentences" → "N points" across all five (five for Node, which includes the profile bullet; four for the web family). - Entry-source hand-off bullet normalized to the fragment: the fixed baseline-fallback anchor made exact ("…the **baseline fallback**", not "which is the …"), and the managed-fetch clause added where the KB confirms managed fetching (React Web, App Router, Pages Router — the earlier intros omitted it). - Fixed a parallel-run divergence: Pages Router kept the old "the SDK's only job is…" lead while gaining the managed-fetch clause (self-contradictory); aligned it to the fragment's "…and the SDK sits at that hand-off:" lead, matching the other four. knowledge:check green; format:fix per file; all TOC anchors resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../guides/integrating-the-node-sdk-in-a-node-app.md | 4 ++-- ...-the-optimization-sdk-in-a-nextjs-app-router-app.md | 10 ++++++---- ...he-optimization-sdk-in-a-nextjs-pages-router-app.md | 10 ++++++---- .../integrating-the-react-web-sdk-in-a-react-app.md | 10 ++++++---- .../guides/integrating-the-web-sdk-in-a-web-app.md | 6 +++--- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index 21580b315..62e1b8fcd 100644 --- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md @@ -5,7 +5,7 @@ a custom SSR server, or a server-side function — using `@contentful/optimizati of the quick start, one route will emit a page event and report the profile the Experience API returns for that request, without changing how your server fetches or renders content. -**New to personalization?** Here is the whole idea in five sentences: +**New to personalization?** Here is the whole idea in five points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. @@ -17,7 +17,7 @@ returns for that request, without changing how your server fetches or renders co - Your server fetches Contentful entries and turns them into a response, and the SDK sits at that hand-off: it gives you the resolved variant instead of the original — or the original entry when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it to the - SDK, or give the SDK your Contentful client and let it fetch by ID; either way the client stays + SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client stays yours. - You render whatever the SDK hands back exactly as you render entries today. diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md index 386bdcb23..420052c6e 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md @@ -4,16 +4,18 @@ Use this guide to add Contentful personalization to a Next.js App Router site yo the end of the quick start, one piece of content will be personalized per visitor in the server-rendered HTML — no flash of default content, no rewrite of how your app fetches or renders. -**New to personalization?** Here is the whole idea in four sentences: +**New to personalization?** Here is the whole idea in four points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. - When a page is requested, Contentful's **Experience API** looks at the current visitor and picks the variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. -- Your app already fetches Contentful entries and turns them into components. The SDK's only job is - to sit at that hand-off and give you the resolved variant instead of the original — or the - original entry when no variant applies, which is the **baseline fallback**. +- Your app already fetches Contentful entries and turns them into components, and the SDK sits at + that hand-off: it gives you the resolved variant instead of the original — or the original entry + when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it + to the SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client + stays yours. - You render whatever the SDK hands back exactly as you render entries today. That is enough to start. You do not need to understand audiences, traffic allocation, or events yet; diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md index 000067011..fbcffbc65 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md @@ -5,16 +5,18 @@ the end of the quick start, one piece of content will be personalized per visito server-rendered HTML — resolved in `getServerSideProps`, no flash of default content, no rewrite of how your app fetches or renders. -**New to personalization?** Here is the whole idea in four sentences: +**New to personalization?** Here is the whole idea in four points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. - When a page is requested, Contentful's **Experience API** looks at the current visitor and picks the variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. -- Your app already fetches Contentful entries and turns them into components. The SDK's only job is - to sit at that hand-off and give you the resolved variant instead of the original — or the - original entry when no variant applies, which is the **baseline fallback**. +- Your app already fetches Contentful entries and turns them into components, and the SDK sits at + that hand-off: it gives you the resolved variant instead of the original — or the original entry + when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it + to the SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client + stays yours. - You render whatever the SDK hands back exactly as you render entries today. That is enough to start. You do not need to understand audiences, traffic allocation, or events yet; diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index 7d552301b..8290b5113 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -5,16 +5,18 @@ single-page app built with Vite, Create React App, React Router, or a similar se the quick start, one piece of content will render its personalized variant in the browser once the SDK resolves it, without changing how your app fetches or renders content. -**New to personalization?** Here is the whole idea in four sentences: +**New to personalization?** Here is the whole idea in four points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. - As the visitor uses your app, Contentful's **Experience API** looks at who they are and picks the variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. -- Your app already fetches Contentful entries and turns them into components. The SDK's only job is - to sit at that hand-off and give you the resolved variant instead of the original — or the - original entry when no variant applies, which is the **baseline fallback**. +- Your app already fetches Contentful entries and turns them into components, and the SDK sits at + that hand-off: it gives you the resolved variant instead of the original — or the original entry + when no variant applies, the **baseline fallback**. You can fetch the entry yourself and hand it + to the SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client + stays yours. - You render whatever the SDK hands back exactly as you render entries today. That is enough to start. You do not need to understand audiences, traffic allocation, or events yet; diff --git a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md index 96bcd8ef1..16fcb26e5 100644 --- a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md +++ b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md @@ -6,7 +6,7 @@ you want to own the browser SDK lifecycle directly. By the end of the quick star content will render its personalized variant in the page once you resolve it, without changing how your app fetches or renders content. -**New to personalization?** Here is the whole idea in four sentences: +**New to personalization?** Here is the whole idea in four points: - In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. @@ -14,8 +14,8 @@ your app fetches or renders content. variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. - Your app turns Contentful entries into markup, and the SDK sits at that hand-off: it gives you the - resolved variant instead of the original — or the original entry when no variant applies, which is - the **baseline fallback**. You can fetch the entry yourself and hand it to the SDK, or give the + resolved variant instead of the original — or the original entry when no variant applies, the + **baseline fallback**. You can fetch the entry yourself and hand it to the SDK, or give the SDK your Contentful client and let it fetch by ID — either way the client stays yours. - You render whatever the SDK hands back exactly as you render entries today. From 195150cba1d6d70f4f5e35895d2ba03a2fbf6e9e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 16:26:40 +0200 Subject: [PATCH 40/42] =?UTF-8?q?=F0=9F=91=B7=20fix(ci):=20Make=20knowledg?= =?UTF-8?q?e-check=20filter=20satisfiable=20under=20predicate-quantifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The knowledge filter listed six mutually-exclusive positive globs while the paths-filter step runs with `predicate-quantifier: every`. Under `every` a changed file must match every pattern in a filter, but no path lives in two of these directories at once, so the filter was unsatisfiable and always emitted `false` — the `knowledge-check` job (guarded by `knowledge == 'true'`) could never run, even for a direct edit to the knowledge base. A local Stop hook running `pnpm knowledge:check` masked this by keeping the check green on dev machines. Collapse the six globs into one brace-alternation, matching the idiom of the other filters, so `every` is trivially satisfied and the job fires whenever a file touches any listed area. Preserves the deliberate no-markdown-exclude intent (the KB is markdown). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main-pipeline.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 1a6b19d68..7e7e2ea37 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -127,13 +127,12 @@ jobs: # the knowledge base itself, and any package source whose symbols its pointers name. # Deliberately NOT markdown-excluded — the KB is markdown, and a source change can # invalidate a symbol pointer without touching a fact. + # One brace-glob on purpose: the step sets `predicate-quantifier: every`, so a file must + # match every pattern in a filter. As separate positive globs these are mutually exclusive + # (no path is in two of these dirs at once), so `every` would be unsatisfiable and this + # filter would never fire. A single alternation matches if a file is in any listed area. knowledge: - - 'documentation/internal/sdk-knowledge/**' - - 'documentation/guides/**' - - 'packages/**/src/**' - - 'implementations/**' - - 'scripts/validate-sdk-knowledge.ts' - - '.github/workflows/main-pipeline.yaml' + - '{documentation/internal/sdk-knowledge/**,documentation/guides/**,packages/**/src/**,implementations/**,scripts/validate-sdk-knowledge.ts,.github/workflows/main-pipeline.yaml}' setup: name: 🛠️ pnpm install From 424e2ad4ecf35dff70bf1797e655c33b1aa81a2e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 16:38:22 +0200 Subject: [PATCH 41/42] =?UTF-8?q?=F0=9F=91=B7=20feat(sdk-knowledge):=20Rep?= =?UTF-8?q?ort=20what=20knowledge:check=20inspected;=20add=20--verbose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator printed an identical ✓ whether it checked the whole base or nothing, so a degenerate run (broken glob, misrooted scan) was indistinguishable from a real pass — the same class of silent-nothing failure that hid the dead CI filter. Always tally what was inspected into the success line (source pointers, fact files, ESCALATE-scanned files); "0 pointers" now stands out. Add opt-in `--verbose`/`-v` (or KNOWLEDGE_CHECK_VERBOSE) that logs every file and pointer as it is checked, and enable it in the CI job so the pipeline log shows the base was actually traversed. Local/default runs stay quiet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main-pipeline.yaml | 4 ++- scripts/validate-sdk-knowledge.ts | 42 ++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 7e7e2ea37..8786b13f9 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -232,7 +232,9 @@ jobs: # Validates that every knowledge-base source pointer still resolves to a real file and symbol # in packages/**/src. Runs when the KB OR any package source changes, so a source refactor # that renames or moves a symbol a pointer names fails here on the same PR. - - run: pnpm knowledge:check + # Verbose: log every file and pointer as it is checked, so the CI log shows the base was + # actually traversed (and how much) rather than a bare ✓ that could mask an empty run. + - run: pnpm knowledge:check --verbose build: name: 📦 Build diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index 20f3c61fa..cb56a014d 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -44,7 +44,12 @@ * * Failures accumulate into `problems` and are printed sorted by file:line; a non-empty set exits 1. * - * Usage: tsx scripts/validate-sdk-knowledge.ts (or `pnpm knowledge:check`) + * The success line always reports counts (files, source pointers, ESCALATE-scanned files) so a run + * that validated nothing is visibly distinct from one that validated the whole base — a green check + * over zero pointers is a bug, not a pass. Pass `--verbose`/`-v` (or set `KNOWLEDGE_CHECK_VERBOSE`) + * to additionally list every file and every source pointer as it is checked; CI enables this. + * + * Usage: tsx scripts/validate-sdk-knowledge.ts [--verbose] (or `pnpm knowledge:check`) */ import { readdirSync, readFileSync, statSync } from 'node:fs' @@ -104,13 +109,23 @@ interface Pointer { tokens: string[] } +// Verbose logging is opt-in: `--verbose`/`-v` on the CLI, or a truthy KNOWLEDGE_CHECK_VERBOSE env +// var (how CI turns it on without changing the invocation). Off, the run prints only its final +// summary; on, it also logs every file and source pointer as it is checked, so a CI reader can see +// the base was actually traversed rather than trusting a bare ✓. +const VERBOSE = + process.argv.slice(2).some((arg) => arg === '--verbose' || arg === '-v') || + isTruthyEnv(process.env.KNOWLEDGE_CHECK_VERBOSE) + // Module-level state, built once and shared across every file check: // problems — accumulates failures; a non-empty set makes report() exit 1. // sdkSrcRoots — grammar key → package src/ root, discovered from the workspace (not hardcoded). // symbolCache — per-file declared-symbol sets, so a file parsed once serves many pointers. +// counts — what was actually inspected, echoed in the summary so "checked nothing" is visible. const problems: Problem[] = [] const sdkSrcRoots = discoverSdkSrcRoots() const symbolCache = new Map>() +const counts = { factFiles: 0, pointers: 0, escalateScanned: 0 } // Entry point: validate every markdown file in the base, then print the sorted report and exit. for (const file of listMarkdownFiles(knowledgeDir)) { @@ -137,10 +152,14 @@ function validateFile(absPath: string): void { const relPath = path.relative(rootDir, absPath) const lines = readFileSync(absPath, 'utf8').split('\n') + counts.factFiles += 1 + verbose(`Checking ${relPath}`) // Grammar + resolution apply to every fact file: any `source:` pointer must be valid. for (const pointer of extractPointers(lines)) { for (const token of pointer.tokens) { + counts.pointers += 1 + verbose(` ${relPath}:${pointer.line} source: ${token}`) checkToken(token, relPath, pointer.line) } } @@ -513,6 +532,7 @@ function checkFeedsGuides(lines: string[], file: string): void { */ function checkNoEscalateMarker(absPath: string): void { const relPath = path.relative(rootDir, absPath) + counts.escalateScanned += 1 readFileSync(absPath, 'utf8') .split('\n') .forEach((line, index) => { @@ -694,15 +714,33 @@ function addProblem(file: string, line: number, message: string): void { problems.push({ file, line, message }) } +/** Prints a progress line only when verbose logging is on; a no-op otherwise. */ +function verbose(message: string): void { + if (VERBOSE) { + console.log(message) + } +} + +/** True for the usual truthy env-var spellings, so CI can set KNOWLEDGE_CHECK_VERBOSE=1 or true. */ +function isTruthyEnv(value: string | undefined): boolean { + return value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) +} + /** * Prints the outcome and sets the process exit code: a success line (exit 0) when clean, or the * problems sorted by file then line with a pointer to the grammar docs (exit 1) otherwise. Exit 1 is * what makes CI and the pre-commit path fail on knowledge-base drift. */ function report(): void { + // The counts make an empty run self-evident: "0 source pointers" is the signature of a check that + // silently validated nothing (a broken glob, a misrooted scan), which otherwise prints an identical ✓. + const tallied = + `checked ${counts.pointers} source pointer(s) across ${counts.factFiles} fact file(s); ` + + `scanned ${counts.escalateScanned} file(s) for ESCALATE markers` + if (problems.length === 0) { console.log( - '✓ SDK knowledge base: source pointers resolve, templates conform, and guide links are valid.', + `✓ SDK knowledge base: source pointers resolve, templates conform, and guide links are valid — ${tallied}.`, ) return } From fb581c7de2e7c7aa6e8550f944822bffd685d767 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 10 Jul 2026 17:01:57 +0200 Subject: [PATCH 42/42] =?UTF-8?q?=F0=9F=90=9E=20fix(sdk-knowledge):=20Cove?= =?UTF-8?q?r=20table-row=20facts;=20widen=20Stop=20hook=20to=20guides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review from Lotfi-Arif. - Coverage gap (validator): per-SDK files state facts as table rows, but checkPointerCoverage graded only list items, and pointer extraction skips a row whose source cell is empty or a —/none placeholder. So a table row could state an SDK fact with no pointer and pass. Add checkTableSourceCoverage: every non-divider body row under a `source` column must carry a real pointer. Lift the table-source walk both consumers share into markdown.ts (eachTableSourceCell) rather than duplicating it; extractPointers now reads cells through it and handleTableRow is gone. Behavior-preserving for extraction (still 670 pointers); verified an emptied and a —-placeholder cell now fail. - Stop hook: the validator scans documentation/guides/** for stray ESCALATE markers and CI triggers knowledge-check on guide changes, but the hook early-exited unless the KB or package source changed, so a guide-only session could leave an ESCALATE marker with no advisory. Add documentation/guides to the changed-path check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/knowledge-check.sh | 7 +-- scripts/sdk-knowledge/markdown.ts | 38 +++++++++++++++ scripts/validate-sdk-knowledge.ts | 79 ++++++++++++++----------------- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/.claude/hooks/knowledge-check.sh b/.claude/hooks/knowledge-check.sh index c8b6d3460..ccd8c3d74 100755 --- a/.claude/hooks/knowledge-check.sh +++ b/.claude/hooks/knowledge-check.sh @@ -12,10 +12,11 @@ input="$(cat)" cwd="$(printf '%s' "$input" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true -# Only spend time when the knowledge base or package source changed in this working tree — a session -# that never touched either cannot have introduced KB drift. +# Only spend time when something the validator inspects changed in this working tree: the knowledge +# base, package source (a refactor can orphan a pointer), or a guide (scanned for stray ESCALATE +# markers). A session touching none of these cannot have introduced drift the validator would catch. if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - changed="$(git status --porcelain -- documentation/internal/sdk-knowledge 'packages/**/src/**' 2>/dev/null)" + changed="$(git status --porcelain -- documentation/internal/sdk-knowledge documentation/guides 'packages/**/src/**' 2>/dev/null)" [ -z "$changed" ] && exit 0 fi diff --git a/scripts/sdk-knowledge/markdown.ts b/scripts/sdk-knowledge/markdown.ts index 1e76b77f1..867aa5602 100644 --- a/scripts/sdk-knowledge/markdown.ts +++ b/scripts/sdk-knowledge/markdown.ts @@ -52,6 +52,44 @@ export function isPlaceholderCell(cell: string): boolean { return cell === '—' || cell === '-' || cell.toLowerCase() === 'none' } +/** + * Visits every table BODY row that sits under a `source` column, yielding its 1-based line number + * and that row's raw source-column cell (`''` when the row is too short to have one). Threads the + * per-table source-column index exactly as the grammar requires: the column may sit at any index and + * differ between tables in one file, is (re)set on a `| … | source |` header, and cleared when the + * table ends (a blank or non-table line). Header rows, divider rows, and rows in tables with no + * `source` column at all are not visited. Both pointer extraction (which reads the cell's tokens) and + * coverage (which flags an empty/placeholder cell) walk tables through this one function. + */ +export function eachTableSourceCell( + lines: string[], + visit: (lineNumber: number, cell: string) => void, +): void { + let sourceColumnIndex: number | undefined = undefined + + lines.forEach((rawLine, index) => { + const line = rawLine.trimEnd() + const columns = parseTableRow(line) + if (columns === undefined) { + if (line === '' || !line.includes('|')) { + sourceColumnIndex = undefined + } + return + } + + const headerIndex = columns.findIndex((cell) => cell.toLowerCase() === 'source') + if (headerIndex !== -1) { + sourceColumnIndex = headerIndex + return + } + if (isTableDivider(columns) || sourceColumnIndex === undefined) { + return + } + + visit(index + 1, columns[sourceColumnIndex] ?? '') + }) +} + /** A bullet (`-`/`*`) or ordered (`1.`) list item. */ export function isListItem(line: string): boolean { return /^[-*]\s|^\d+\.\s/u.test(line) diff --git a/scripts/validate-sdk-knowledge.ts b/scripts/validate-sdk-knowledge.ts index cb56a014d..72af1d4e9 100644 --- a/scripts/validate-sdk-knowledge.ts +++ b/scripts/validate-sdk-knowledge.ts @@ -57,12 +57,12 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { type Heading, + eachTableSourceCell, headingsOf, isFactUnitEnd, isLabelLeadIn, isListItem, isPlaceholderCell, - isTableDivider, looksLikeFact, parseTableRow, } from './sdk-knowledge/markdown' @@ -173,6 +173,7 @@ function validateFile(absPath: string): void { // mix normative prose with facts and feed a family rather than a single guide. if (isPerSdkFile(absPath)) { checkPointerCoverage(lines, relPath) + checkTableSourceCoverage(lines, relPath) checkTemplateConformance(lines, relPath) checkFeedsGuides(lines, relPath) } @@ -192,61 +193,29 @@ function validateFile(absPath: string): void { */ function extractPointers(lines: string[]): Pointer[] { const pointers: Pointer[] = [] - let sourceColumnIndex: number | undefined = undefined + // Prose carrier: a `source:` line, on any non-table line (a table cell never holds `source:`). lines.forEach((rawLine, index) => { - const line = rawLine.trimEnd() - const lineNumber = index + 1 - const columns = parseTableRow(line) - - if (columns !== undefined) { - sourceColumnIndex = handleTableRow(columns, sourceColumnIndex, lineNumber, pointers) + if (parseTableRow(rawLine.trimEnd()) !== undefined) { return } - - // A blank or non-table line ends the current table, so its column mapping no longer applies. - if (line === '' || !line.includes('|')) { - sourceColumnIndex = undefined + const prosePointer = matchProseSource(rawLine.trimEnd()) + if (prosePointer !== undefined) { + pointers.push({ line: index + 1, tokens: splitTokens(prosePointer) }) } + }) - const prosePointer = matchProseSource(line) - if (prosePointer !== undefined) { - pointers.push({ line: lineNumber, tokens: splitTokens(prosePointer) }) + // Table carrier: the `source` column of each body row. A cell that is empty or a `—`/`none` + // placeholder carries no pointer — coverage flags that separately (checkTableSourceCoverage). + eachTableSourceCell(lines, (lineNumber, cell) => { + if (cell !== '' && !isPlaceholderCell(cell)) { + pointers.push({ line: lineNumber, tokens: splitTokens(cell) }) } }) return pointers } -/** - * Processes one table row, threading the `source`-column index through the table's rows. Three cases: - * - Header row (has a `source` cell): remember that column index for the rows that follow. - * - Divider row (`| --- |`), or a body row before any header was seen: nothing to record. - * - Body row with a known source column: record that cell's pointers (unless it is an empty/`—` - * placeholder cell, which legitimately carries no pointer). - * Returns the (possibly updated) source-column index for the next row. - */ -function handleTableRow( - columns: string[], - sourceColumnIndex: number | undefined, - lineNumber: number, - pointers: Pointer[], -): number | undefined { - const headerIndex = columns.findIndex((cell) => cell.toLowerCase() === 'source') - if (headerIndex !== -1) { - return headerIndex - } - if (isTableDivider(columns) || sourceColumnIndex === undefined) { - return sourceColumnIndex - } - - const { [sourceColumnIndex]: cell } = columns - if (cell !== undefined && cell !== '' && !isPlaceholderCell(cell)) { - pointers.push({ line: lineNumber, tokens: splitTokens(cell) }) - } - return sourceColumnIndex -} - /** * Flags a line that is a wrapped continuation of a `source:` line — a line consisting only of * grammar tokens (each segment carries a `#` or a known prefix), which the extractor above does not @@ -464,6 +433,28 @@ function itemHasPointerNearby(lines: string[], index: number): boolean { return false } +/** + * Coverage for the OTHER fact carrier: table body rows. checkPointerCoverage grades list-item facts, + * but per-SDK files also state facts as table rows (import-path, component, and identifier-owner + * tables), and extractPointers deliberately skips a row whose `source` cell is empty or a `—`/`none` + * placeholder — so such a row would state an SDK fact with no pointer and pass unnoticed. This closes + * that gap: in a table that HAS a `source` column, every non-divider body row must carry a real + * pointer in it. Tables with no `source` column at all are illustrative, not fact tables, and are + * left alone (the file's template still governs which tables must exist). + */ +function checkTableSourceCoverage(lines: string[], file: string): void { + eachTableSourceCell(lines, (lineNumber, cell) => { + if (cell === '' || isPlaceholderCell(cell)) { + const rowText = (lines[lineNumber - 1] ?? '').trim() + addProblem( + file, + lineNumber, + `table fact row has no source: pointer in its source column — "${truncate(rowText)}"`, + ) + } + }) +} + // --- template conformance -------------------------------------------------------------------- /**