From ce4f2c29f2343dd1e7d60e0d41ace93f063a7213 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Wed, 3 Jun 2026 14:43:01 -0600 Subject: [PATCH 01/25] feat: add experience-auditor showcase example [EXT-7476] Add a polished, customer-facing example app for the ExO toolbar location that goes beyond the minimal starter (EXT-7365). Experience Auditor runs inside the Experience Editor toolbar and continuously audits the experience for accessibility, SEO, and content-completeness issues. It demonstrates the standout capability of the toolbar location: live, selection-aware tooling that reads and mutates the experience tree. A scored dashboard lists findings; clicking one locates the offending component on the canvas (selection.set + selection.highlight), safe deterministic fixes apply in place via setContentProperty (permission- checked, with notifier feedback), and publish is gated on outstanding errors. Audit rules are pure functions over an SDK-independent node shape, fully unit-tested without a live SDK; the SDK boundary is isolated in a thin collector. Typed with ExperienceEditorToolbarAppSDK (app-sdk@4.58.0). Co-Authored-By: Claude Opus 4.8 --- examples/experience-auditor/.gitignore | 24 ++ examples/experience-auditor/README.md | 123 ++++++++++ examples/experience-auditor/index.html | 20 ++ examples/experience-auditor/package.json | 58 +++++ examples/experience-auditor/src/App.tsx | 27 +++ .../src/audit/audit.spec.ts | 152 ++++++++++++ .../src/audit/collect.spec.ts | 39 ++++ .../experience-auditor/src/audit/collect.ts | 34 +++ .../experience-auditor/src/audit/engine.ts | 64 ++++++ .../experience-auditor/src/audit/rules.ts | 181 +++++++++++++++ .../experience-auditor/src/audit/types.ts | 54 +++++ .../src/components/FindingList.tsx | 97 ++++++++ .../src/components/LocalhostWarning.tsx | 32 +++ .../src/components/ScoreSummary.tsx | 47 ++++ examples/experience-auditor/src/index.tsx | 23 ++ .../src/locations/ConfigScreen.tsx | 55 +++++ .../src/locations/ExperienceToolbar.spec.tsx | 59 +++++ .../src/locations/ExperienceToolbar.tsx | 217 ++++++++++++++++++ examples/experience-auditor/src/setupTests.ts | 10 + .../experience-auditor/test/mocks/index.ts | 1 + .../experience-auditor/test/mocks/mockSdk.ts | 75 ++++++ examples/experience-auditor/tsconfig.json | 18 ++ examples/experience-auditor/vite.config.mts | 19 ++ 23 files changed, 1429 insertions(+) create mode 100644 examples/experience-auditor/.gitignore create mode 100644 examples/experience-auditor/README.md create mode 100644 examples/experience-auditor/index.html create mode 100644 examples/experience-auditor/package.json create mode 100644 examples/experience-auditor/src/App.tsx create mode 100644 examples/experience-auditor/src/audit/audit.spec.ts create mode 100644 examples/experience-auditor/src/audit/collect.spec.ts create mode 100644 examples/experience-auditor/src/audit/collect.ts create mode 100644 examples/experience-auditor/src/audit/engine.ts create mode 100644 examples/experience-auditor/src/audit/rules.ts create mode 100644 examples/experience-auditor/src/audit/types.ts create mode 100644 examples/experience-auditor/src/components/FindingList.tsx create mode 100644 examples/experience-auditor/src/components/LocalhostWarning.tsx create mode 100644 examples/experience-auditor/src/components/ScoreSummary.tsx create mode 100644 examples/experience-auditor/src/index.tsx create mode 100644 examples/experience-auditor/src/locations/ConfigScreen.tsx create mode 100644 examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx create mode 100644 examples/experience-auditor/src/locations/ExperienceToolbar.tsx create mode 100644 examples/experience-auditor/src/setupTests.ts create mode 100644 examples/experience-auditor/test/mocks/index.ts create mode 100644 examples/experience-auditor/test/mocks/mockSdk.ts create mode 100644 examples/experience-auditor/tsconfig.json create mode 100644 examples/experience-auditor/vite.config.mts diff --git a/examples/experience-auditor/.gitignore b/examples/experience-auditor/.gitignore new file mode 100644 index 000000000..d258ba034 --- /dev/null +++ b/examples/experience-auditor/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# dotenv environment variables file +.env +.env.* +!.env*.example + +# misc +.DS_Store + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md new file mode 100644 index 000000000..7b2c96e84 --- /dev/null +++ b/examples/experience-auditor/README.md @@ -0,0 +1,123 @@ +# Experience Auditor + +A polished, real-world example app for the **Experience Editor toolbar** — the +`experience-toolbar` location introduced in +[`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk). + +Experience Auditor runs alongside the Experience Orchestration (ExO) editor and +continuously audits the experience you are editing for **accessibility, SEO, and +content-completeness** issues. It demonstrates the standout capability of the +toolbar location: **live, selection-aware tooling that reads _and_ mutates the +experience tree as the author works.** + +> Looking for the bare-bones starter instead? See the +> [`experience-toolbar`](../experience-toolbar) example, which demonstrates the +> minimal `sdk.exo` patterns. Experience Auditor builds on those to show a +> complete, opinionated app. + +## What it does + +- **Live audit** — walks the experience tree with `getRootNodes()` → + `getNode()` → `getProperties()`, runs a set of pure rules, and re-runs + automatically on `sdk.exo.experience.onChange()`. +- **Scored dashboard** — a 0–100 health score with error / warning / info + counts. +- **Locate on canvas** — clicking **Locate** calls + `selection.set(nodeId)` + `selection.highlight(nodeId, { flash, scrollIntoView })` + to jump straight to the offending component (visual mode only). +- **One-click fixes** — where a safe, deterministic fix exists (e.g. trimming + stray whitespace from alt text), the app applies it via + `getNode().setContentProperty()`, permission-checked with `sdk.access.can()` + and confirmed through `sdk.notifier`. +- **Pre-publish gate** — `experience.publish()` is blocked while any error-level + finding remains. + +### Audit rules + +| Rule | Severity | What it checks | +| --- | --- | --- | +| `a11y/image-alt-text` | error / warning | Images must have non-empty alt text; trims stray whitespace | +| `content/required-empty` | warning | Headings/titles should not be empty | +| `seo/missing-meta` | info | SEO meta fields should be populated | +| `content/broken-binding` | error | Entry-bound properties must resolve to an entry | + +The rules live in [`src/audit/rules.ts`](src/audit/rules.ts) as pure functions +over a SDK-independent `CollectedNode` shape, so they are fully unit-tested +without a live SDK. Adding a rule is a matter of dropping another `AuditRule` +into `AUDIT_RULES`. + +## Architecture + +``` +src/ + audit/ + types.ts SDK-independent domain types (CollectedNode, AuditFinding, …) + rules.ts Pure audit rules + engine.ts Runs rules, aggregates findings, computes the score + collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[] + components/ + ScoreSummary.tsx + FindingList.tsx + locations/ + ConfigScreen.tsx + ExperienceToolbar.tsx Wires the SDK to the engine (collect → audit → locate/fix/publish) +``` + +Keeping the rules pure and the SDK boundary thin (`collect.ts`) is the key +pattern: all the interesting logic is testable in isolation, and the live SDK +work is small enough to reason about. + +## How to use + +```bash +# npx +npx create-contentful-app --example experience-auditor + +# npm +npm init contentful-app -- --example experience-auditor + +# Yarn +yarn create contentful-app --example experience-auditor +``` + +Then: + +```bash +npm install +npm start +``` + +## Registering the toolbar location + +Like other toolbar apps, this is **not** assigned per content type — there is no +`EditorInterface` target state. It renders whenever the `experience-toolbar` +location is registered on your app definition. Create one with: + +```bash +npm run create-app-definition +``` + +selecting the **App configuration screen** and **Experience toolbar** locations, +pointing the app at `http://localhost:3000`. + +## A note on verification + +This app is built against the published `@contentful/app-sdk@4.58.0` types, +which are the contract for the toolbar location. At the time of writing the host +renderer that serves `sdk.exo` at runtime is still rolling out, so the app is +**type-verified and unit-tested against a mocked SDK** (audit rules, scoring, +collector, and the toolbar's locate/fix/publish-gate behavior all have tests), +but not yet verified end-to-end inside a live ExO editor. The API shapes used +here match the published types exactly. + +## Available scripts + +- `npm start` — run in development mode +- `npm run build` — production build to `build/` +- `npm run test:ci` — run the test suite once +- `npm run upload` / `npm run upload-ci` — deploy the bundle to Contentful + +## Libraries + +- [Forma 36](https://f36.contentful.com/) — Contentful's design system +- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) — the `sdk.exo` reference diff --git a/examples/experience-auditor/index.html b/examples/experience-auditor/index.html new file mode 100644 index 000000000..cf65f5e57 --- /dev/null +++ b/examples/experience-auditor/index.html @@ -0,0 +1,20 @@ + + + + + + + + +
+ + + + diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json new file mode 100644 index 000000000..4383fc531 --- /dev/null +++ b/examples/experience-auditor/package.json @@ -0,0 +1,58 @@ +{ + "name": "experience-auditor-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@contentful/app-sdk": "4.58.0", + "@contentful/f36-components": "4.81.1", + "@contentful/f36-icons": "^4.28.0", + "@contentful/f36-tokens": "4.2.0", + "@contentful/react-apps-toolkit": "1.2.16", + "emotion": "10.0.27", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:ci": "vitest run", + "create-app-definition": "contentful-app-scripts create-app-definition", + "add-locations": "contentful-app-scripts add-locations", + "upload": "contentful-app-scripts upload --bundle-dir ./build", + "upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@contentful/app-scripts": "^2.3.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.13.5", + "@types/react": "18.3.13", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.0.3", + "cross-env": "7.0.3", + "jsdom": "^26.0.0", + "typescript": "4.9.5", + "vite": "^6.2.2", + "vitest": "^3.0.9" + }, + "homepage": "." +} diff --git a/examples/experience-auditor/src/App.tsx b/examples/experience-auditor/src/App.tsx new file mode 100644 index 000000000..5db9499a3 --- /dev/null +++ b/examples/experience-auditor/src/App.tsx @@ -0,0 +1,27 @@ +import React, { useMemo } from 'react'; +import { locations } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +import ConfigScreen from './locations/ConfigScreen'; +import ExperienceToolbar from './locations/ExperienceToolbar'; + +const ComponentLocationSettings = { + [locations.LOCATION_APP_CONFIG]: ConfigScreen, + [locations.LOCATION_EXPERIENCE_TOOLBAR]: ExperienceToolbar, +}; + +const App = () => { + const sdk = useSDK(); + + const Component = useMemo(() => { + for (const [location, component] of Object.entries(ComponentLocationSettings)) { + if (sdk.location.is(location)) { + return component; + } + } + }, [sdk.location]); + + return Component ? : null; +}; + +export default App; diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts new file mode 100644 index 000000000..0a1491cb6 --- /dev/null +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; +import { computeScore, hasBlockingErrors, runAudit } from './engine'; +import { AUDIT_RULES } from './rules'; +import type { CollectedNode } from './types'; + +function node( + id: string, + properties: ComponentPropertyDescriptor[] +): CollectedNode { + return { id, nodeType: 'Component', properties }; +} + +describe('audit rules', () => { + it('flags an image with no alt text as an error', () => { + const report = runAudit([ + node('hero', [ + { key: 'image', area: 'content', value: { sys: { id: 'a1' } } }, + { key: 'altText', area: 'content', value: '' }, + ]), + ]); + + const finding = report.findings.find((f) => f.ruleId === 'a11y/image-alt-text'); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe('error'); + }); + + it('does not flag an image that has alt text', () => { + const report = runAudit([ + node('hero', [ + { key: 'image', area: 'content', value: { sys: { id: 'a1' } } }, + { key: 'altText', area: 'content', value: 'A hero image' }, + ]), + ]); + + expect(report.findings.find((f) => f.ruleId === 'a11y/image-alt-text')).toBeUndefined(); + }); + + it('offers a trim fix when alt text has surrounding whitespace', () => { + const report = runAudit([ + node('hero', [ + { key: 'image', area: 'content', value: { sys: { id: 'a1' } } }, + { key: 'altText', area: 'content', value: ' spaced ' }, + ]), + ]); + + const finding = report.findings.find((f) => f.ruleId === 'a11y/image-alt-text'); + expect(finding?.severity).toBe('warning'); + expect(finding?.fix).toEqual({ + label: 'Trim whitespace', + propertyKey: 'altText', + value: 'spaced', + }); + }); + + it('does not flag a node without an image', () => { + const report = runAudit([node('text', [{ key: 'body', area: 'content', value: 'Hi' }])]); + expect(report.findings.filter((f) => f.ruleId === 'a11y/image-alt-text')).toHaveLength(0); + }); + + it('flags an empty heading as a warning', () => { + const report = runAudit([node('cta', [{ key: 'heading', area: 'content', value: '' }])]); + const finding = report.findings.find((f) => f.ruleId === 'content/required-empty'); + expect(finding?.severity).toBe('warning'); + }); + + it('flags empty SEO metadata as info', () => { + const report = runAudit([ + node('page', [{ key: 'metaDescription', area: 'content', value: '' }]), + ]); + const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta'); + expect(finding?.severity).toBe('info'); + }); + + it('flags a broken entry binding as an error', () => { + const report = runAudit([ + node('card', [ + { + key: 'title', + area: 'content', + value: null, + binding: { sourceType: 'entry' }, + }, + ]), + ]); + const finding = report.findings.find((f) => f.ruleId === 'content/broken-binding'); + expect(finding?.severity).toBe('error'); + }); + + it('does not flag a resolved entry binding', () => { + const report = runAudit([ + node('card', [ + { + key: 'title', + area: 'content', + value: 'Bound', + binding: { sourceType: 'entry', entryId: 'entry-1' }, + }, + ]), + ]); + expect(report.findings.find((f) => f.ruleId === 'content/broken-binding')).toBeUndefined(); + }); + + it('exposes a stable rule set', () => { + expect(AUDIT_RULES.map((r) => r.id)).toEqual([ + 'a11y/image-alt-text', + 'content/required-empty', + 'seo/missing-meta', + 'content/broken-binding', + ]); + }); +}); + +describe('scoring', () => { + it('scores a clean experience at 100', () => { + const report = runAudit([node('ok', [{ key: 'body', area: 'content', value: 'Hello' }])]); + expect(report.score).toBe(100); + expect(hasBlockingErrors(report)).toBe(false); + }); + + it('subtracts weighted penalties and clamps at zero', () => { + expect(computeScore([])).toBe(100); + expect( + computeScore([ + { id: 'a', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'error', title: '', detail: '' }, + { id: 'b', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'warning', title: '', detail: '' }, + { id: 'c', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'info', title: '', detail: '' }, + ]) + ).toBe(85); // 100 - (10 + 4 + 1) + }); + + it('reports blocking errors when any error is present', () => { + const report = runAudit([ + node('hero', [ + { key: 'image', area: 'content', value: { sys: { id: 'a1' } } }, + { key: 'altText', area: 'content', value: '' }, + ]), + ]); + expect(hasBlockingErrors(report)).toBe(true); + }); + + it('sorts findings errors-first', () => { + const report = runAudit([ + node('a', [{ key: 'metaTitle', area: 'content', value: '' }]), // info + node('b', [ + { key: 'image', area: 'content', value: { sys: { id: 'x' } } }, + { key: 'altText', area: 'content', value: '' }, // error + ]), + ]); + expect(report.findings[0].severity).toBe('error'); + }); +}); diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts new file mode 100644 index 000000000..2e850a391 --- /dev/null +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { collectNodes } from './collect'; +import { makeMockNode } from '../../test/mocks'; + +describe('collectNodes', () => { + it('resolves properties for every root node', async () => { + const experience: any = { + getRootNodes: vi.fn().mockReturnValue([ + makeMockNode('a', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]), + makeMockNode('b', 'Component', [{ key: 'body', area: 'content', value: 'There' }]), + ]), + }; + + const collected = await collectNodes(experience); + + expect(collected).toHaveLength(2); + expect(collected[0]).toMatchObject({ id: 'a', nodeType: 'Component' }); + expect(collected[0].properties[0].key).toBe('heading'); + }); + + it('skips nodes whose properties fail to resolve', async () => { + const broken = makeMockNode('broken', 'Component', []); + broken.getProperties = vi.fn().mockRejectedValue(new Error('gone')); + + const experience: any = { + getRootNodes: vi + .fn() + .mockReturnValue([ + broken, + makeMockNode('ok', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]), + ]), + }; + + const collected = await collectNodes(experience); + + expect(collected).toHaveLength(1); + expect(collected[0].id).toBe('ok'); + }); +}); diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts new file mode 100644 index 000000000..f2e727203 --- /dev/null +++ b/examples/experience-auditor/src/audit/collect.ts @@ -0,0 +1,34 @@ +import type { ExperienceAPI } from '@contentful/app-sdk'; +import type { CollectedNode } from './types'; + +/** + * Walks the experience tree and resolves each node's properties into the + * SDK-independent {@link CollectedNode} shape the audit engine consumes. + * + * `getRootNodes()` returns the top-level nodes; this example audits those + * directly. A production app would additionally descend through slot + * descriptors (`getSlotDescriptor().currentItems`) to cover nested components — + * the same `getNode` + `getProperties` pattern, applied recursively. + */ +export async function collectNodes(experience: ExperienceAPI): Promise { + const roots = experience.getRootNodes(); + + const collected = await Promise.all( + roots.map(async (node) => { + try { + const properties = await node.getProperties(); + return { + id: node.id, + nodeType: node.nodeType, + properties, + } satisfies CollectedNode; + } catch { + // A node may have been removed mid-traversal; skip it rather than + // failing the whole audit. + return null; + } + }) + ); + + return collected.filter((node): node is CollectedNode => node !== null); +} diff --git a/examples/experience-auditor/src/audit/engine.ts b/examples/experience-auditor/src/audit/engine.ts new file mode 100644 index 000000000..2ab29209d --- /dev/null +++ b/examples/experience-auditor/src/audit/engine.ts @@ -0,0 +1,64 @@ +import type { AuditFinding, AuditReport, AuditRule, CollectedNode, Severity } from './types'; +import { AUDIT_RULES } from './rules'; + +/** Penalty applied to the health score per finding, by severity. */ +const SEVERITY_WEIGHT: Record = { + error: 10, + warning: 4, + info: 1, +}; + +const EMPTY_COUNTS: Record = { error: 0, warning: 0, info: 0 }; + +/** + * Runs every rule over every collected node and aggregates the findings into a + * report. Pure and synchronous — all async SDK work (resolving nodes and their + * properties) happens in the collector before this is called, which keeps the + * scoring logic trivially testable. + */ +export function runAudit( + nodes: CollectedNode[], + rules: AuditRule[] = AUDIT_RULES +): AuditReport { + const findings: AuditFinding[] = []; + + for (const node of nodes) { + for (const rule of rules) { + findings.push(...rule.evaluate(node)); + } + } + + const counts = { ...EMPTY_COUNTS }; + for (const finding of findings) { + counts[finding.severity] += 1; + } + + return { + findings: sortFindings(findings), + score: computeScore(findings), + counts, + nodeCount: nodes.length, + }; +} + +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; + +/** Errors first, then warnings, then info; stable within a severity. */ +function sortFindings(findings: AuditFinding[]): AuditFinding[] { + return [...findings].sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] + ); +} + +/** + * A 0–100 health score. Each finding subtracts its severity weight; the score + * is clamped at 0. An experience with no findings scores 100. + */ +export function computeScore(findings: AuditFinding[]): number { + const penalty = findings.reduce((sum, f) => sum + SEVERITY_WEIGHT[f.severity], 0); + return Math.max(0, 100 - penalty); +} + +export function hasBlockingErrors(report: AuditReport): boolean { + return report.counts.error > 0; +} diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts new file mode 100644 index 000000000..7a4780115 --- /dev/null +++ b/examples/experience-auditor/src/audit/rules.ts @@ -0,0 +1,181 @@ +import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; +import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types'; + +/** Property keys are matched case-insensitively against these hints. */ +const IMAGE_KEY_HINT = /(image|photo|asset|media|thumbnail|icon|logo)/i; +const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i; +const HEADING_KEY_HINT = /(heading|title|headline)/i; +const META_KEY_HINT = /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i; + +function findProperty( + node: CollectedNode, + matcher: RegExp +): ComponentPropertyDescriptor | undefined { + return node.properties.find((p) => matcher.test(stripNonAlpha(p.key))); +} + +function stripNonAlpha(key: string): string { + return key.replace(/[^a-z0-9]/gi, ''); +} + +function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +/** True when a property resolves to text the author actually authored. */ +function isContentText(property: ComponentPropertyDescriptor): property is ComponentPropertyDescriptor & { value: string } { + return property.area === 'content' && typeof property.value === 'string'; +} + +function makeFinding( + rule: Pick, + node: CollectedNode, + partial: { + propertyKey?: string; + severity: Severity; + title: string; + detail: string; + fix?: AuditFinding['fix']; + } +): AuditFinding { + return { + id: `${rule.id}:${node.id}:${partial.propertyKey ?? ''}`, + ruleId: rule.id, + nodeId: node.id, + nodeType: node.nodeType, + ...partial, + }; +} + +/** + * Image properties must have accompanying alt text. Flags an image-like content + * property whose sibling alt-text property is empty or missing. Where the alt + * text exists but is only whitespace, offers a one-click trim fix. + */ +const altTextRule: AuditRule = { + id: 'a11y/image-alt-text', + description: 'Images should have non-empty alternative text.', + evaluate(node) { + const image = findProperty(node, IMAGE_KEY_HINT); + if (!image || isEmptyValue(image.value)) { + // No image set on this node — nothing to audit. + return []; + } + + const alt = findProperty(node, ALT_KEY_HINT); + + if (!alt || isEmptyValue(alt.value)) { + return [ + makeFinding(altTextRule, node, { + propertyKey: alt?.key, + severity: 'error', + title: 'Image is missing alt text', + detail: + 'This component has an image but no alternative text. Screen readers cannot describe it.', + }), + ]; + } + + if (isContentText(alt) && alt.value !== alt.value.trim()) { + return [ + makeFinding(altTextRule, node, { + propertyKey: alt.key, + severity: 'warning', + title: 'Alt text has surrounding whitespace', + detail: 'The alt text has leading or trailing whitespace.', + fix: { + label: 'Trim whitespace', + propertyKey: alt.key, + value: alt.value.trim(), + }, + }), + ]; + } + + return []; + }, +}; + +/** + * Required content properties must not be empty. The host marks a property as + * required via a convention on the descriptor; here we treat any empty content + * text property whose key looks like a heading/title as required, plus any + * property explicitly flagged. (Kept conservative to avoid false positives.) + */ +const requiredContentRule: AuditRule = { + id: 'content/required-empty', + description: 'Required content fields should not be empty.', + evaluate(node) { + const heading = findProperty(node, HEADING_KEY_HINT); + if (heading && heading.area === 'content' && isEmptyValue(heading.value)) { + return [ + makeFinding(requiredContentRule, node, { + propertyKey: heading.key, + severity: 'warning', + title: 'Heading is empty', + detail: `"${heading.key}" has no value. Components usually need a heading to be useful.`, + }), + ]; + } + return []; + }, +}; + +/** + * SEO metadata should be present on the root of an experience. Flags a missing + * or empty meta description / title when the node exposes such a property. + */ +const seoMetaRule: AuditRule = { + id: 'seo/missing-meta', + description: 'SEO metadata should be populated.', + evaluate(node) { + const meta = findProperty(node, META_KEY_HINT); + if (meta && meta.area === 'content' && isEmptyValue(meta.value)) { + return [ + makeFinding(seoMetaRule, node, { + propertyKey: meta.key, + severity: 'info', + title: 'SEO metadata is empty', + detail: `"${meta.key}" is empty. Populate it to improve search and social sharing.`, + }), + ]; + } + return []; + }, +}; + +/** + * Content properties bound to an entry must actually resolve. A binding whose + * source is an entry but with no entryId recorded is a broken reference. + */ +const brokenBindingRule: AuditRule = { + id: 'content/broken-binding', + description: 'Entry bindings should resolve to an entry.', + evaluate(node) { + const findings: AuditFinding[] = []; + for (const property of node.properties) { + const binding = property.binding; + if (binding && binding.sourceType === 'entry' && !binding.entryId) { + findings.push( + makeFinding(brokenBindingRule, node, { + propertyKey: property.key, + severity: 'error', + title: 'Broken entry binding', + detail: `"${property.key}" is bound to an entry, but the reference is missing or unresolved.`, + }) + ); + } + } + return findings; + }, +}; + +export const AUDIT_RULES: AuditRule[] = [ + altTextRule, + requiredContentRule, + seoMetaRule, + brokenBindingRule, +]; diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts new file mode 100644 index 000000000..b45f2ee03 --- /dev/null +++ b/examples/experience-auditor/src/audit/types.ts @@ -0,0 +1,54 @@ +import type { ComponentPropertyDescriptor, ExoNodeType } from '@contentful/app-sdk'; + +export type Severity = 'error' | 'warning' | 'info'; + +/** + * A node collected from the experience tree, paired with its resolved + * properties. This is the SDK-independent shape the audit rules operate on, so + * the rules can be unit-tested without a live `sdk.exo`. + */ +export interface CollectedNode { + id: string; + nodeType: ExoNodeType; + properties: ComponentPropertyDescriptor[]; +} + +/** + * A deterministic, one-click fix for a finding. When present, the UI offers a + * "Fix" action that writes `value` to `propertyKey` via `setContentProperty`. + * Only safe, non-destructive transforms (e.g. trimming whitespace) carry a fix + * — we never invent content the author hasn't written. + */ +export interface AutoFix { + label: string; + propertyKey: string; + value: unknown; +} + +export interface AuditFinding { + /** Stable key for React lists and de-duplication. */ + id: string; + ruleId: string; + nodeId: string; + nodeType: ExoNodeType; + propertyKey?: string; + severity: Severity; + title: string; + detail: string; + fix?: AutoFix; +} + +/** A pure audit rule: given one node, return zero or more findings. */ +export interface AuditRule { + id: string; + description: string; + evaluate(node: CollectedNode): AuditFinding[]; +} + +export interface AuditReport { + findings: AuditFinding[]; + /** Overall health score, 0–100 (100 = no findings). */ + score: number; + counts: Record; + nodeCount: number; +} diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx new file mode 100644 index 000000000..ab3ec3340 --- /dev/null +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Badge, Box, Button, Flex, Note, Stack, Text } from '@contentful/f36-components'; +import type { BadgeProps } from '@contentful/f36-components'; +import type { AuditFinding, Severity } from '../audit/types'; + +interface FindingListProps { + findings: AuditFinding[]; + /** Whether the canvas supports highlighting (visual mode only). */ + canLocate: boolean; + /** Whether the current user may write content properties. */ + canFix: boolean; + onLocate: (finding: AuditFinding) => void; + onFix: (finding: AuditFinding) => void; + busyFindingId: string | null; +} + +const SEVERITY_VARIANT: Record = { + error: 'negative', + warning: 'warning', + info: 'secondary', +}; + +const FindingList = ({ + findings, + canLocate, + canFix, + onLocate, + onFix, + busyFindingId, +}: FindingListProps) => { + if (findings.length === 0) { + return ( + + No issues found. This experience passes every audit rule. 🎉 + + ); + } + + return ( + + {findings.map((finding) => ( + + + + + {finding.severity} + {finding.title} + + + {finding.detail} + + {finding.propertyKey && ( + + Property: {finding.propertyKey} + + )} + + + + + {finding.fix && ( + + )} + + + + ))} + + ); +}; + +export default FindingList; diff --git a/examples/experience-auditor/src/components/LocalhostWarning.tsx b/examples/experience-auditor/src/components/LocalhostWarning.tsx new file mode 100644 index 000000000..20be7a2c5 --- /dev/null +++ b/examples/experience-auditor/src/components/LocalhostWarning.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Paragraph, TextLink, Note, Flex } from '@contentful/f36-components'; + +const LocalhostWarning = () => { + return ( + + + + Contentful Apps need to run inside the Contentful web app to function properly. Install + the app into a space and render your app into one of the{' '} + + available locations + + . + +
+ + + Follow{' '} + + our guide + {' '} + to get started or{' '} + open Contentful{' '} + to manage your app. + +
+
+ ); +}; + +export default LocalhostWarning; diff --git a/examples/experience-auditor/src/components/ScoreSummary.tsx b/examples/experience-auditor/src/components/ScoreSummary.tsx new file mode 100644 index 000000000..e9251f6fe --- /dev/null +++ b/examples/experience-auditor/src/components/ScoreSummary.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Badge, Flex, Heading, Text } from '@contentful/f36-components'; +import tokens from '@contentful/f36-tokens'; +import { css } from 'emotion'; +import type { AuditReport } from '../audit/types'; + +interface ScoreSummaryProps { + report: AuditReport; +} + +function scoreColor(score: number): string { + if (score >= 90) return tokens.green600; + if (score >= 70) return tokens.yellow600; + return tokens.red600; +} + +const ScoreSummary = ({ report }: ScoreSummaryProps) => { + const { score, counts, nodeCount } = report; + + return ( + + + + Health score + + + {score} + + + across {nodeCount} {nodeCount === 1 ? 'component' : 'components'} + + + + + {counts.error} errors + {counts.warning} warnings + {counts.info} info + + + ); +}; + +export default ScoreSummary; diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx new file mode 100644 index 000000000..d10662719 --- /dev/null +++ b/examples/experience-auditor/src/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { GlobalStyles } from '@contentful/f36-components'; +import { SDKProvider } from '@contentful/react-apps-toolkit'; + +import LocalhostWarning from './components/LocalhostWarning'; +import App from './App'; + +const container = document.getElementById('root'); +const root = createRoot(container!); + +if (process.env.NODE_ENV === 'development' && window.self === window.top) { + // You can remove this if block before deploying your app + root.render(); +} else { + root.render( + + + + + ); +} diff --git a/examples/experience-auditor/src/locations/ConfigScreen.tsx b/examples/experience-auditor/src/locations/ConfigScreen.tsx new file mode 100644 index 000000000..325479209 --- /dev/null +++ b/examples/experience-auditor/src/locations/ConfigScreen.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ConfigAppSDK } from '@contentful/app-sdk'; +import { Flex, Form, Heading, Note, Paragraph } from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +export interface AppInstallationParameters {} + +const ConfigScreen = () => { + const [parameters, setParameters] = useState({}); + const sdk = useSDK(); + + const onConfigure = useCallback(async () => { + // The experience-toolbar location is not part of the EditorInterface, so + // there is no `targetState` to assign — visibility is determined solely by + // whether the location is registered on the app definition (see README). + const currentState = await sdk.app.getCurrentState(); + return { + parameters, + targetState: currentState, + }; + }, [parameters, sdk]); + + useEffect(() => { + sdk.app.onConfigure(() => onConfigure()); + }, [sdk, onConfigure]); + + useEffect(() => { + (async () => { + const currentParameters: AppInstallationParameters | null = await sdk.app.getParameters(); + if (currentParameters) { + setParameters(currentParameters); + } + sdk.app.setReady(); + })(); + }, [sdk]); + + return ( + +
+ Experience Auditor + + Experience Auditor runs inside the Experience Editor toolbar and continuously checks the + experience you are editing for accessibility, SEO, and content-completeness issues. + + + Nothing to configure here. Once installed, make sure the{' '} + experience-toolbar location is registered on your app definition — the + auditor appears automatically when editing an experience. + +
+
+ ); +}; + +export default ConfigScreen; diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx new file mode 100644 index 000000000..41aba1695 --- /dev/null +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ExperienceToolbar from './ExperienceToolbar'; +import { mockSdk } from '../../test/mocks'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, +})); + +describe('ExperienceToolbar (Experience Auditor)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSdk.exo.getUiMode.mockReturnValue('visual'); + mockSdk.access.can.mockResolvedValue(true); + }); + + it('runs an audit on mount and renders findings with a score', async () => { + const { getByTestId, getAllByTestId } = render(); + + await waitFor(() => expect(getByTestId('health-score')).toBeInTheDocument()); + // The default fixture has one error (missing alt) + one warning (empty heading). + expect(getAllByTestId('finding').length).toBeGreaterThanOrEqual(2); + }); + + it('blocks publish while errors remain', async () => { + const { getByTestId } = render(); + + await waitFor(() => expect(getByTestId('publish-blocked')).toBeInTheDocument()); + expect(mockSdk.exo.experience.publish).not.toHaveBeenCalled(); + }); + + it('locates a finding via selection + highlight', async () => { + const user = userEvent.setup(); + const { getAllByTestId } = render(); + + await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); + + const firstFinding = getAllByTestId('finding')[0]; + await user.click(within(firstFinding).getByText('Locate')); + + expect(mockSdk.exo.experience.selection.set).toHaveBeenCalledOnce(); + expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith( + expect.any(String), + { flash: true, scrollIntoView: true } + ); + }); + + it('disables locate in form mode', async () => { + mockSdk.exo.getUiMode.mockReturnValue('form'); + const { getAllByTestId } = render(); + + await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); + + const locateButton = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button'); + expect(locateButton).toBeDisabled(); + }); +}); diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx new file mode 100644 index 000000000..619be1998 --- /dev/null +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -0,0 +1,217 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { + ExoContext, + ExperienceEditorToolbarAppSDK, + UiMode, +} from '@contentful/app-sdk'; +import { + Badge, + Box, + Button, + Flex, + Heading, + Note, + Spinner, + Stack, + Text, +} from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +import { collectNodes } from '../audit/collect'; +import { hasBlockingErrors, runAudit } from '../audit/engine'; +import type { AuditFinding, AuditReport } from '../audit/types'; +import ScoreSummary from '../components/ScoreSummary'; +import FindingList from '../components/FindingList'; + +/** + * Experience Auditor — a selection-aware ExO toolbar app. + * + * On mount (and whenever the experience changes) it walks the experience tree + * with `sdk.exo.experience`, runs a set of pure audit rules, and renders a + * scored list of findings. Each finding can be located on the canvas + * (`selection.set` + `selection.highlight`) and, where a safe deterministic fix + * exists, repaired in place (`getNode().setContentProperty`). Publishing is + * gated on there being no outstanding errors. + */ +const ExperienceToolbar = () => { + const sdk = useSDK(); + + const [context, setContext] = useState(() => sdk.exo.context); + const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); + const [report, setReport] = useState(null); + const [auditing, setAuditing] = useState(true); + const [canFix, setCanFix] = useState(false); + const [busyFindingId, setBusyFindingId] = useState(null); + const [publishing, setPublishing] = useState(false); + + // Guard against state updates after unmount / stale async audits. + const runIdRef = useRef(0); + + const audit = useCallback(async () => { + const runId = ++runIdRef.current; + setAuditing(true); + try { + const nodes = await collectNodes(sdk.exo.experience); + const next = runAudit(nodes); + if (runId === runIdRef.current) { + setReport(next); + } + } finally { + if (runId === runIdRef.current) { + setAuditing(false); + } + } + }, [sdk]); + + // Keep context and ui mode in sync. + useEffect(() => sdk.exo.onContextChanged(setContext), [sdk]); + useEffect(() => sdk.exo.onUiModeChanged(setUiMode), [sdk]); + + // Resolve write permission once for UX gating (the host still enforces). + useEffect(() => { + let active = true; + sdk.access + .can('update', 'Entry') + .then((allowed) => { + if (active) setCanFix(allowed); + }) + .catch(() => { + if (active) setCanFix(false); + }); + return () => { + active = false; + }; + }, [sdk]); + + // Initial audit + re-audit whenever the experience changes. + useEffect(() => { + void audit(); + return sdk.exo.experience.onChange(() => { + void audit(); + }); + }, [sdk, audit]); + + const handleLocate = useCallback( + (finding: AuditFinding) => { + sdk.exo.experience.selection.set(finding.nodeId); + // Highlight is a no-op in form mode; the button is disabled there anyway. + sdk.exo.experience.selection.highlight(finding.nodeId, { + flash: true, + scrollIntoView: true, + }); + }, + [sdk] + ); + + const handleFix = useCallback( + async (finding: AuditFinding) => { + if (!finding.fix) return; + + setBusyFindingId(finding.id); + try { + const node = sdk.exo.experience.getNode(finding.nodeId); + if (!node) { + sdk.notifier.error('That component no longer exists.'); + return; + } + await node.setContentProperty(finding.fix.propertyKey, finding.fix.value); + sdk.notifier.success('Fix applied.'); + await audit(); + } catch { + sdk.notifier.error('Could not apply the fix. Please try again.'); + } finally { + setBusyFindingId(null); + } + }, + [sdk, audit] + ); + + const handlePublish = useCallback(async () => { + if (!report || hasBlockingErrors(report)) return; + + setPublishing(true); + try { + const allowed = await sdk.access.can('publish', 'Entry'); + if (!allowed) { + sdk.notifier.error('You do not have permission to publish this experience.'); + return; + } + await sdk.exo.experience.publish(); + sdk.notifier.success('Experience published.'); + } catch { + sdk.notifier.error('Publish failed. Please try again.'); + } finally { + setPublishing(false); + } + }, [sdk, report]); + + const blocked = report ? hasBlockingErrors(report) : false; + const canLocate = uiMode === 'visual'; + + return ( + + + + + Experience Auditor + + {context.type} + + + + + + {uiMode === 'form' && ( + + You are in form mode. Findings still update live, but locating a + component on the canvas requires visual mode. + + )} + + {report && } + + {auditing && !report && ( + + + + )} + + {report && ( + + )} + + + {blocked && ( + + Resolve all errors before publishing. + + )} + + + + + ); +}; + +export default ExperienceToolbar; diff --git a/examples/experience-auditor/src/setupTests.ts b/examples/experience-auditor/src/setupTests.ts new file mode 100644 index 000000000..eb82e0f2f --- /dev/null +++ b/examples/experience-auditor/src/setupTests.ts @@ -0,0 +1,10 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; + +configure({ + testIdAttribute: 'data-test-id', +}); diff --git a/examples/experience-auditor/test/mocks/index.ts b/examples/experience-auditor/test/mocks/index.ts new file mode 100644 index 000000000..9ddacc64b --- /dev/null +++ b/examples/experience-auditor/test/mocks/index.ts @@ -0,0 +1 @@ +export { mockSdk, defaultNodes, noopUnsubscribe, makeMockNode } from './mockSdk'; diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts new file mode 100644 index 000000000..5149fe8ba --- /dev/null +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -0,0 +1,75 @@ +import { vi } from 'vitest'; +import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; + +const noopUnsubscribe = vi.fn(); + +/** Builds a mock ExoNodeAPI whose getProperties resolves the given descriptors. */ +export function makeMockNode( + id: string, + nodeType: 'Component' | 'Fragment' | 'InlineFragment' | 'Slot', + properties: ComponentPropertyDescriptor[] +) { + return { + id, + nodeType, + get: vi.fn().mockReturnValue({ id, nodeType }), + onChange: vi.fn().mockReturnValue(noopUnsubscribe), + getProperties: vi.fn().mockResolvedValue(properties), + getContentProperty: vi.fn().mockResolvedValue(undefined), + setContentProperty: vi.fn().mockResolvedValue(undefined), + onContentPropertyChanged: vi.fn().mockReturnValue(noopUnsubscribe), + }; +} + +const defaultNodes = [ + makeMockNode('hero', 'Component', [ + { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, + { key: 'altText', area: 'content', value: '' }, // -> a11y error + { key: 'heading', area: 'content', value: 'Welcome' }, + ]), + makeMockNode('cta', 'Component', [ + { key: 'heading', area: 'content', value: '' }, // -> warning + ]), +]; + +const mockSdk: any = { + location: { is: vi.fn().mockReturnValue(true) }, + ids: { app: 'test-app' }, + app: { + onConfigure: vi.fn(), + getParameters: vi.fn().mockResolvedValue({}), + setReady: vi.fn(), + getCurrentState: vi.fn().mockResolvedValue(null), + }, + access: { + can: vi.fn().mockResolvedValue(true), + }, + notifier: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, + exo: { + context: { type: 'experience', entityId: 'experience-123' }, + onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe), + getUiMode: vi.fn().mockReturnValue('visual'), + onUiModeChanged: vi.fn().mockReturnValue(noopUnsubscribe), + experience: { + get: vi.fn(), + onChange: vi.fn().mockReturnValue(noopUnsubscribe), + save: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + getNode: vi.fn((id: string) => defaultNodes.find((n) => n.id === id) ?? null), + getRootNodes: vi.fn().mockReturnValue(defaultNodes), + selection: { + get: vi.fn().mockReturnValue({ nodeId: null }), + onChange: vi.fn().mockReturnValue(noopUnsubscribe), + set: vi.fn(), + highlight: vi.fn(), + }, + }, + }, +}; + +export { mockSdk, defaultNodes, noopUnsubscribe }; +// `makeMockNode` is exported above at its declaration. diff --git a/examples/experience-auditor/tsconfig.json b/examples/experience-auditor/tsconfig.json new file mode 100644 index 000000000..697fd88e9 --- /dev/null +++ b/examples/experience-auditor/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["actions", "functions"] +} diff --git a/examples/experience-auditor/vite.config.mts b/examples/experience-auditor/vite.config.mts new file mode 100644 index 000000000..438203f62 --- /dev/null +++ b/examples/experience-auditor/vite.config.mts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, // Enables Jest-like global test functions (test, expect) + environment: 'jsdom', // Simulates a browser for component tests + setupFiles: './src/setupTests.ts', // Equivalent to Jest's setup file + }, + base: '', + build: { + outDir: 'build', + }, + server: { + host: 'localhost', + port: 3000, + }, +}); From f7200dfcef4a3aa37db912fd0bbb2806c4eedce8 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 4 Jun 2026 15:39:13 -0600 Subject: [PATCH 02/25] fix: harden audit rules + cover fix path in experience-auditor [EXT-7476] Address code review on the experience-auditor showcase: - alt-text rule now requires an asset-shaped value, not just an image-ish key, so string fields (iconName, logoText, assetId) no longer emit false errors that block the publish gate - heading rule excludes meta-ish keys so metaTitle/seoTitle no longer double-fire across the heading and SEO rules - add a fix-apply integration test (setContentProperty + re-audit) so the documented fix path is actually covered - add regression tests for the false-positive and double-fire cases - document the onChange re-audit no-debounce simplification and why handleFix re-audits explicitly Co-Authored-By: Claude Opus 4.8 --- .../src/audit/audit.spec.ts | 23 +++++++++++ .../experience-auditor/src/audit/rules.ts | 38 ++++++++++++++++--- .../src/locations/ExperienceToolbar.spec.tsx | 36 ++++++++++++++++++ .../src/locations/ExperienceToolbar.tsx | 6 +++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index 0a1491cb6..748bbb11a 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -58,6 +58,20 @@ describe('audit rules', () => { expect(report.findings.filter((f) => f.ruleId === 'a11y/image-alt-text')).toHaveLength(0); }); + it('does not flag string fields whose key merely looks image-ish', () => { + // `iconName`/`logoText`/`assetId` are string labels, not images — they must + // not trigger a (publish-blocking) missing-alt error. + const report = runAudit([ + node('badge', [ + { key: 'iconName', area: 'content', value: 'star' }, + { key: 'logoText', area: 'content', value: 'ACME' }, + { key: 'assetId', area: 'content', value: 'abc123' }, + ]), + ]); + expect(report.findings.filter((f) => f.ruleId === 'a11y/image-alt-text')).toHaveLength(0); + expect(report.counts.error).toBe(0); + }); + it('flags an empty heading as a warning', () => { const report = runAudit([node('cta', [{ key: 'heading', area: 'content', value: '' }])]); const finding = report.findings.find((f) => f.ruleId === 'content/required-empty'); @@ -72,6 +86,15 @@ describe('audit rules', () => { expect(finding?.severity).toBe('info'); }); + it('does not double-fire on a key that matches both heading and meta hints', () => { + // `metaTitle` matches the meta hint and the bare "title" — it should be + // owned by the SEO rule only, producing exactly one finding. + const report = runAudit([node('page', [{ key: 'metaTitle', area: 'content', value: '' }])]); + const forKey = report.findings.filter((f) => f.propertyKey === 'metaTitle'); + expect(forKey).toHaveLength(1); + expect(forKey[0].ruleId).toBe('seo/missing-meta'); + }); + it('flags a broken entry binding as an error', () => { const report = runAudit([ node('card', [ diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index 7a4780115..4081f86e2 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -2,22 +2,40 @@ import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types'; /** Property keys are matched case-insensitively against these hints. */ -const IMAGE_KEY_HINT = /(image|photo|asset|media|thumbnail|icon|logo)/i; +const IMAGE_KEY_HINT = /(image|photo|media|thumbnail)/i; const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i; -const HEADING_KEY_HINT = /(heading|title|headline)/i; const META_KEY_HINT = /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i; +// Heading must look like a heading but NOT like SEO metadata — otherwise +// `metaTitle`/`seoTitle` would match both rules and double-count one field. +const HEADING_KEY_HINT = /(heading|headline|^title$|pagetitle)/i; function findProperty( node: CollectedNode, - matcher: RegExp + matcher: RegExp, + predicate?: (p: ComponentPropertyDescriptor) => boolean ): ComponentPropertyDescriptor | undefined { - return node.properties.find((p) => matcher.test(stripNonAlpha(p.key))); + return node.properties.find( + (p) => matcher.test(stripNonAlpha(p.key)) && (!predicate || predicate(p)) + ); } function stripNonAlpha(key: string): string { return key.replace(/[^a-z0-9]/gi, ''); } +/** + * Whether a property value looks like an actual image — an asset Link object + * (`{ sys: { linkType: 'Asset', ... } }`) or an array of them. Matching on the + * key alone is too loose: `iconName`, `logoText`, `assetId` are string labels, + * not images, and flagging them for missing alt text produces false errors. + */ +function looksLikeImageValue(value: unknown): boolean { + const isAssetLink = (v: unknown): boolean => + typeof v === 'object' && v !== null && 'sys' in (v as Record); + if (Array.isArray(value)) return value.some(isAssetLink); + return isAssetLink(value); +} + function isEmptyValue(value: unknown): boolean { if (value === null || value === undefined) return true; if (typeof value === 'string') return value.trim().length === 0; @@ -59,7 +77,9 @@ const altTextRule: AuditRule = { id: 'a11y/image-alt-text', description: 'Images should have non-empty alternative text.', evaluate(node) { - const image = findProperty(node, IMAGE_KEY_HINT); + // Require both an image-like key AND an asset-shaped value, so string + // fields like `iconName`/`logoText` don't trigger false alt-text errors. + const image = findProperty(node, IMAGE_KEY_HINT, (p) => looksLikeImageValue(p.value)); if (!image || isEmptyValue(image.value)) { // No image set on this node — nothing to audit. return []; @@ -109,7 +129,13 @@ const requiredContentRule: AuditRule = { id: 'content/required-empty', description: 'Required content fields should not be empty.', evaluate(node) { - const heading = findProperty(node, HEADING_KEY_HINT); + // Exclude meta-ish keys so `metaTitle`/`seoTitle` are owned solely by the + // SEO rule and don't double-fire here. + const heading = findProperty( + node, + HEADING_KEY_HINT, + (p) => !META_KEY_HINT.test(stripNonAlpha(p.key)) + ); if (heading && heading.area === 'content' && isEmptyValue(heading.value)) { return [ makeFinding(requiredContentRule, node, { diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index 41aba1695..8523cda07 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -56,4 +56,40 @@ describe('ExperienceToolbar (Experience Auditor)', () => { const locateButton = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button'); expect(locateButton).toBeDisabled(); }); + + it('applies a one-click fix via setContentProperty and re-audits', async () => { + const user = userEvent.setup(); + // A node whose alt text has stray whitespace yields a finding with a + // "Trim whitespace" fix. setContentProperty trims it and clears the finding. + const setContentProperty = vi.fn().mockResolvedValue(undefined); + let altValue = ' spaced alt '; + const fixNode = { + id: 'hero', + nodeType: 'Component', + onChange: vi.fn().mockReturnValue(vi.fn()), + getProperties: vi.fn().mockImplementation(() => + Promise.resolve([ + { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, + { key: 'altText', area: 'content', value: altValue }, + ]) + ), + setContentProperty: vi.fn().mockImplementation(async (key: string, value: string) => { + if (key === 'altText') altValue = value; + return setContentProperty(key, value); + }), + }; + mockSdk.exo.experience.getRootNodes.mockReturnValue([fixNode]); + mockSdk.exo.experience.getNode.mockReturnValue(fixNode); + + const { getAllByTestId, queryByText } = render(); + + await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); + const fixButton = within(getAllByTestId('finding')[0]).getByText('Trim whitespace'); + await user.click(fixButton); + + expect(setContentProperty).toHaveBeenCalledWith('altText', 'spaced alt'); + await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.')); + // Re-audit ran on the trimmed value: the whitespace finding is gone. + await waitFor(() => expect(queryByText('Trim whitespace')).toBeNull()); + }); }); diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index 619be1998..08b27715b 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -84,6 +84,9 @@ const ExperienceToolbar = () => { }, [sdk]); // Initial audit + re-audit whenever the experience changes. + // Simplification for the example: every onChange triggers a full traversal. + // A production app editing rapidly would debounce this (e.g. trailing 300ms) + // so a burst of edits collapses into a single re-audit instead of N+1 passes. useEffect(() => { void audit(); return sdk.exo.experience.onChange(() => { @@ -116,6 +119,9 @@ const ExperienceToolbar = () => { } await node.setContentProperty(finding.fix.propertyKey, finding.fix.value); sdk.notifier.success('Fix applied.'); + // Re-audit explicitly rather than relying on the setContentProperty + // write to round-trip back through onChange — that keeps the panel in + // sync even if the host doesn't emit a change event for this write. await audit(); } catch { sdk.notifier.error('Could not apply the fix. Please try again.'); From d860ac90dd7ee534185057ee7453f63bf4f8ff5f Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:27:08 -0600 Subject: [PATCH 03/25] refactor: add fix-kind discriminator and resolved-binding types to experience-auditor [EXT-7476] --- .../src/audit/audit.spec.ts | 1 + .../experience-auditor/src/audit/rules.ts | 1 + .../experience-auditor/src/audit/types.ts | 47 +++++++++++++++---- .../src/locations/ExperienceToolbar.tsx | 4 +- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index 748bbb11a..d6e9fe3cf 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -47,6 +47,7 @@ describe('audit rules', () => { const finding = report.findings.find((f) => f.ruleId === 'a11y/image-alt-text'); expect(finding?.severity).toBe('warning'); expect(finding?.fix).toEqual({ + kind: 'deterministic', label: 'Trim whitespace', propertyKey: 'altText', value: 'spaced', diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index 4081f86e2..6f4517dcf 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -107,6 +107,7 @@ const altTextRule: AuditRule = { title: 'Alt text has surrounding whitespace', detail: 'The alt text has leading or trailing whitespace.', fix: { + kind: 'deterministic', label: 'Trim whitespace', propertyKey: alt.key, value: alt.value.trim(), diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts index b45f2ee03..240c09918 100644 --- a/examples/experience-auditor/src/audit/types.ts +++ b/examples/experience-auditor/src/audit/types.ts @@ -11,19 +11,42 @@ export interface CollectedNode { id: string; nodeType: ExoNodeType; properties: ComponentPropertyDescriptor[]; + /** + * Per-property-key binding resolution, populated by the collector when the + * host backs `resolveEntryBinding`. Absent when the host does not support it + * (the binding rule then falls back to a structural check). + */ + resolvedBindings?: Record; +} + +/** The resolution result for an entry-bound property, captured at collect time. */ +export interface ResolvedBinding { + /** True when the binding declared an entry source. */ + isEntryBinding: boolean; + /** True when `resolveEntryBinding` returned a target; false when it returned null. */ + resolved: boolean; } /** - * A deterministic, one-click fix for a finding. When present, the UI offers a - * "Fix" action that writes `value` to `propertyKey` via `setContentProperty`. - * Only safe, non-destructive transforms (e.g. trimming whitespace) carry a fix - * — we never invent content the author hasn't written. + * A one-click fix for a finding. + * + * - `deterministic`: exactly one correct result (e.g. trimming whitespace). + * Applied immediately on click, no confirmation. + * - `suggested`: a proposed value the author reviews and edits before it is + * written. Used where the fix is helpful but not unambiguous (e.g. deriving a + * meta title from the heading). We never write a suggested value silently. */ -export interface AutoFix { - label: string; - propertyKey: string; - value: unknown; -} +export type AutoFix = + | { kind: 'deterministic'; label: string; propertyKey: string; value: unknown } + | { + kind: 'suggested'; + label: string; + propertyKey: string; + /** Pre-filled, editable proposed value. */ + suggestedValue: string; + /** Human-readable provenance, e.g. "from the heading on this component". */ + source: string; + }; export interface AuditFinding { /** Stable key for React lists and de-duplication. */ @@ -52,3 +75,9 @@ export interface AuditReport { counts: Record; nodeCount: number; } + +/** Which optional host surfaces are backed by the live `sdk.exo`. */ +export interface Capabilities { + /** Selection/highlight (Locate-on-canvas). Not backed on the experience route yet. */ + selection: boolean; +} diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index 08b27715b..af3828878 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -108,7 +108,9 @@ const ExperienceToolbar = () => { const handleFix = useCallback( async (finding: AuditFinding) => { - if (!finding.fix) return; + // This handler applies deterministic fixes (write the precomputed value). + // Suggested fixes are applied through a separate confirm path (added later). + if (finding.fix?.kind !== 'deterministic') return; setBusyFindingId(finding.id); try { From 0b4c533b03407919fcb41bd285f1e222eb6ef091 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:31:38 -0600 Subject: [PATCH 04/25] feat: detect backed sdk.exo capabilities in experience-auditor [EXT-7476] --- .../src/audit/capabilities.spec.ts | 21 +++++++++++++++++++ .../src/audit/capabilities.ts | 18 ++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 examples/experience-auditor/src/audit/capabilities.spec.ts create mode 100644 examples/experience-auditor/src/audit/capabilities.ts diff --git a/examples/experience-auditor/src/audit/capabilities.spec.ts b/examples/experience-auditor/src/audit/capabilities.spec.ts new file mode 100644 index 000000000..32a673dc3 --- /dev/null +++ b/examples/experience-auditor/src/audit/capabilities.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { detectCapabilities } from './capabilities'; + +describe('detectCapabilities', () => { + it('reports selection supported when the experience exposes a selection API', () => { + const exo: any = { + experience: { selection: { set: () => {}, highlight: () => {} } }, + }; + expect(detectCapabilities(exo)).toEqual({ selection: true }); + }); + + it('reports selection unsupported when selection is absent', () => { + const exo: any = { experience: {} }; + expect(detectCapabilities(exo)).toEqual({ selection: false }); + }); + + it('reports selection unsupported when set/highlight are not functions', () => { + const exo: any = { experience: { selection: { set: null, highlight: undefined } } }; + expect(detectCapabilities(exo)).toEqual({ selection: false }); + }); +}); diff --git a/examples/experience-auditor/src/audit/capabilities.ts b/examples/experience-auditor/src/audit/capabilities.ts new file mode 100644 index 000000000..60f69bb94 --- /dev/null +++ b/examples/experience-auditor/src/audit/capabilities.ts @@ -0,0 +1,18 @@ +import type { ExoSDK } from '@contentful/app-sdk'; +import type { Capabilities } from './types'; + +/** + * Probes which optional host surfaces the live `sdk.exo` actually backs. + * + * The toolbar host bridge intentionally degrades some surfaces (notably + * selection) to "not supported" until later host work lands. Rather than call + * an unsupported method and catch, the app asks this probe up front and renders + * an informative, disabled affordance instead. This is the pattern a + * well-behaved app uses when a host capability is still rolling out. + */ +export function detectCapabilities(exo: ExoSDK): Capabilities { + const selection = exo.experience?.selection; + return { + selection: typeof selection?.set === 'function' && typeof selection?.highlight === 'function', + }; +} From 0a43465ca514e81627405517eb90e555d41c7069 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:41:21 -0600 Subject: [PATCH 05/25] feat: add pure suggested-meta-from-heading fix derivation [EXT-7476] --- .../src/audit/fixes.spec.ts | 43 +++++++++++++++++++ .../experience-auditor/src/audit/fixes.ts | 23 ++++++++++ 2 files changed, 66 insertions(+) create mode 100644 examples/experience-auditor/src/audit/fixes.spec.ts create mode 100644 examples/experience-auditor/src/audit/fixes.ts diff --git a/examples/experience-auditor/src/audit/fixes.spec.ts b/examples/experience-auditor/src/audit/fixes.spec.ts new file mode 100644 index 000000000..f79db64df --- /dev/null +++ b/examples/experience-auditor/src/audit/fixes.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; +import { suggestMetaFromHeading } from './fixes'; +import type { CollectedNode } from './types'; + +function node(properties: ComponentPropertyDescriptor[]): CollectedNode { + return { id: 'n', nodeType: 'Component', properties }; +} + +describe('suggestMetaFromHeading', () => { + it('proposes the heading text as the meta value', () => { + const result = suggestMetaFromHeading( + node([ + { key: 'heading', area: 'content', value: ' Our Spring Sale ' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + 'metaTitle' + ); + expect(result).toBe('Our Spring Sale'); + }); + + it('returns null when there is no non-empty heading', () => { + const result = suggestMetaFromHeading( + node([ + { key: 'heading', area: 'content', value: ' ' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + 'metaTitle' + ); + expect(result).toBeNull(); + }); + + it('returns null when the heading is not a string', () => { + const result = suggestMetaFromHeading( + node([ + { key: 'heading', area: 'content', value: { sys: { id: 'x' } } }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + 'metaTitle' + ); + expect(result).toBeNull(); + }); +}); diff --git a/examples/experience-auditor/src/audit/fixes.ts b/examples/experience-auditor/src/audit/fixes.ts new file mode 100644 index 000000000..43176a0b2 --- /dev/null +++ b/examples/experience-auditor/src/audit/fixes.ts @@ -0,0 +1,23 @@ +import type { CollectedNode } from './types'; + +/** Case-insensitive, punctuation-stripped key match (mirrors rules.ts). */ +function stripNonAlpha(key: string): string { + return key.replace(/[^a-z0-9]/gi, ''); +} + +const HEADING_HINT = /(heading|headline|^title$|pagetitle)/i; + +/** + * Derives a suggested SEO meta value from the node's heading, when one exists. + * Pure: reads only the CollectedNode. Returns the trimmed heading text, or + * `null` when there is no usable heading (the caller then offers no suggestion). + * + * `_metaKey` is accepted for symmetry with other derivations and future + * per-key logic; the heading is the source regardless of which meta key is empty. + */ +export function suggestMetaFromHeading(node: CollectedNode, _metaKey: string): string | null { + const heading = node.properties.find((p) => HEADING_HINT.test(stripNonAlpha(p.key))); + if (!heading || typeof heading.value !== 'string') return null; + const trimmed = heading.value.trim(); + return trimmed.length > 0 ? trimmed : null; +} From 48d70fa29d66e84cd5d4ceaf920379a5ceb04d14 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:46:31 -0600 Subject: [PATCH 06/25] feat: add heading-order rule, suggested meta fix, and real binding resolution [EXT-7476] --- .../src/audit/audit.spec.ts | 112 ++++++++++++++++-- .../experience-auditor/src/audit/engine.ts | 12 +- .../experience-auditor/src/audit/fixes.ts | 10 +- examples/experience-auditor/src/audit/keys.ts | 14 +++ .../experience-auditor/src/audit/rules.ts | 92 +++++++++++--- 5 files changed, 200 insertions(+), 40 deletions(-) create mode 100644 examples/experience-auditor/src/audit/keys.ts diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index d6e9fe3cf..cc800e8e4 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -4,10 +4,7 @@ import { computeScore, hasBlockingErrors, runAudit } from './engine'; import { AUDIT_RULES } from './rules'; import type { CollectedNode } from './types'; -function node( - id: string, - properties: ComponentPropertyDescriptor[] -): CollectedNode { +function node(id: string, properties: ComponentPropertyDescriptor[]): CollectedNode { return { id, nodeType: 'Component', properties }; } @@ -125,6 +122,83 @@ describe('audit rules', () => { expect(report.findings.find((f) => f.ruleId === 'content/broken-binding')).toBeUndefined(); }); + it('flags a heading level that skips a level', () => { + const report = runAudit([ + node('a', [{ key: 'headingLevel', area: 'content', value: 2 }]), + node('b', [{ key: 'headingLevel', area: 'content', value: 4 }]), + ]); + const finding = report.findings.find((f) => f.ruleId === 'a11y/heading-order'); + expect(finding?.severity).toBe('warning'); + expect(finding?.fix).toEqual({ + kind: 'deterministic', + label: 'Set to H3', + propertyKey: 'headingLevel', + value: 3, + }); + }); + + it('does not flag sequential heading levels', () => { + const report = runAudit([ + node('a', [{ key: 'headingLevel', area: 'content', value: 2 }]), + node('b', [{ key: 'headingLevel', area: 'content', value: 3 }]), + ]); + expect(report.findings.filter((f) => f.ruleId === 'a11y/heading-order')).toHaveLength(0); + }); + + it('offers a suggested meta value derived from the heading', () => { + const report = runAudit([ + node('hero', [ + { key: 'heading', area: 'content', value: 'Spring Sale' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + ]); + const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta'); + expect(finding?.fix).toEqual({ + kind: 'suggested', + label: 'Use heading as meta', + propertyKey: 'metaTitle', + suggestedValue: 'Spring Sale', + source: 'the heading on this component', + }); + }); + + it('flags an entry binding that fails to resolve via resolvedBindings', () => { + const n: CollectedNode = { + id: 'card', + nodeType: 'Component', + properties: [ + { + key: 'featured', + area: 'content', + value: null, + binding: { sourceType: 'entry', entryId: 'e1' }, + }, + ], + resolvedBindings: { featured: { isEntryBinding: true, resolved: false } }, + }; + const finding = runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding'); + expect(finding?.severity).toBe('error'); + }); + + it('does not flag a binding that resolves', () => { + const n: CollectedNode = { + id: 'card', + nodeType: 'Component', + properties: [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { sourceType: 'entry', entryId: 'e1' }, + }, + ], + resolvedBindings: { featured: { isEntryBinding: true, resolved: true } }, + }; + expect( + runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding') + ).toBeUndefined(); + }); + it('exposes a stable rule set', () => { expect(AUDIT_RULES.map((r) => r.id)).toEqual([ 'a11y/image-alt-text', @@ -146,9 +220,33 @@ describe('scoring', () => { expect(computeScore([])).toBe(100); expect( computeScore([ - { id: 'a', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'error', title: '', detail: '' }, - { id: 'b', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'warning', title: '', detail: '' }, - { id: 'c', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'info', title: '', detail: '' }, + { + id: 'a', + ruleId: 'r', + nodeId: 'n', + nodeType: 'Component', + severity: 'error', + title: '', + detail: '', + }, + { + id: 'b', + ruleId: 'r', + nodeId: 'n', + nodeType: 'Component', + severity: 'warning', + title: '', + detail: '', + }, + { + id: 'c', + ruleId: 'r', + nodeId: 'n', + nodeType: 'Component', + severity: 'info', + title: '', + detail: '', + }, ]) ).toBe(85); // 100 - (10 + 4 + 1) }); diff --git a/examples/experience-auditor/src/audit/engine.ts b/examples/experience-auditor/src/audit/engine.ts index 2ab29209d..db596938a 100644 --- a/examples/experience-auditor/src/audit/engine.ts +++ b/examples/experience-auditor/src/audit/engine.ts @@ -1,5 +1,5 @@ import type { AuditFinding, AuditReport, AuditRule, CollectedNode, Severity } from './types'; -import { AUDIT_RULES } from './rules'; +import { AUDIT_RULES, evaluateHeadingOrder } from './rules'; /** Penalty applied to the health score per finding, by severity. */ const SEVERITY_WEIGHT: Record = { @@ -16,10 +16,7 @@ const EMPTY_COUNTS: Record = { error: 0, warning: 0, info: 0 } * properties) happens in the collector before this is called, which keeps the * scoring logic trivially testable. */ -export function runAudit( - nodes: CollectedNode[], - rules: AuditRule[] = AUDIT_RULES -): AuditReport { +export function runAudit(nodes: CollectedNode[], rules: AuditRule[] = AUDIT_RULES): AuditReport { const findings: AuditFinding[] = []; for (const node of nodes) { @@ -27,6 +24,7 @@ export function runAudit( findings.push(...rule.evaluate(node)); } } + findings.push(...evaluateHeadingOrder(nodes)); const counts = { ...EMPTY_COUNTS }; for (const finding of findings) { @@ -45,9 +43,7 @@ const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 /** Errors first, then warnings, then info; stable within a severity. */ function sortFindings(findings: AuditFinding[]): AuditFinding[] { - return [...findings].sort( - (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] - ); + return [...findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); } /** diff --git a/examples/experience-auditor/src/audit/fixes.ts b/examples/experience-auditor/src/audit/fixes.ts index 43176a0b2..064f43c3a 100644 --- a/examples/experience-auditor/src/audit/fixes.ts +++ b/examples/experience-auditor/src/audit/fixes.ts @@ -1,11 +1,5 @@ import type { CollectedNode } from './types'; - -/** Case-insensitive, punctuation-stripped key match (mirrors rules.ts). */ -function stripNonAlpha(key: string): string { - return key.replace(/[^a-z0-9]/gi, ''); -} - -const HEADING_HINT = /(heading|headline|^title$|pagetitle)/i; +import { stripNonAlpha, HEADING_KEY_HINT } from './keys'; /** * Derives a suggested SEO meta value from the node's heading, when one exists. @@ -16,7 +10,7 @@ const HEADING_HINT = /(heading|headline|^title$|pagetitle)/i; * per-key logic; the heading is the source regardless of which meta key is empty. */ export function suggestMetaFromHeading(node: CollectedNode, _metaKey: string): string | null { - const heading = node.properties.find((p) => HEADING_HINT.test(stripNonAlpha(p.key))); + const heading = node.properties.find((p) => HEADING_KEY_HINT.test(stripNonAlpha(p.key))); if (!heading || typeof heading.value !== 'string') return null; const trimmed = heading.value.trim(); return trimmed.length > 0 ? trimmed : null; diff --git a/examples/experience-auditor/src/audit/keys.ts b/examples/experience-auditor/src/audit/keys.ts new file mode 100644 index 000000000..612e89f12 --- /dev/null +++ b/examples/experience-auditor/src/audit/keys.ts @@ -0,0 +1,14 @@ +/** Case-insensitive, punctuation-stripped key matching shared by rules and fixes. */ +export function stripNonAlpha(key: string): string { + return key.replace(/[^a-z0-9]/gi, ''); +} + +export const IMAGE_KEY_HINT = /(image|photo|media|thumbnail)/i; +export const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i; +export const META_KEY_HINT = + /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i; +// Heading must look like a heading but NOT like SEO metadata — otherwise +// `metaTitle`/`seoTitle` would match both rules and double-count one field. +export const HEADING_KEY_HINT = /(heading|headline|^title$|pagetitle)/i; +// Numeric heading-level property (e.g. `headingLevel`), used by the heading-order rule. +export const HEADING_LEVEL_HINT = /(headinglevel|level|hlevel)/i; diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index 6f4517dcf..b26374402 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -1,13 +1,14 @@ import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types'; - -/** Property keys are matched case-insensitively against these hints. */ -const IMAGE_KEY_HINT = /(image|photo|media|thumbnail)/i; -const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i; -const META_KEY_HINT = /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i; -// Heading must look like a heading but NOT like SEO metadata — otherwise -// `metaTitle`/`seoTitle` would match both rules and double-count one field. -const HEADING_KEY_HINT = /(heading|headline|^title$|pagetitle)/i; +import { + stripNonAlpha, + IMAGE_KEY_HINT, + ALT_KEY_HINT, + META_KEY_HINT, + HEADING_KEY_HINT, + HEADING_LEVEL_HINT, +} from './keys'; +import { suggestMetaFromHeading } from './fixes'; function findProperty( node: CollectedNode, @@ -19,10 +20,6 @@ function findProperty( ); } -function stripNonAlpha(key: string): string { - return key.replace(/[^a-z0-9]/gi, ''); -} - /** * Whether a property value looks like an actual image — an asset Link object * (`{ sys: { linkType: 'Asset', ... } }`) or an array of them. Matching on the @@ -44,7 +41,9 @@ function isEmptyValue(value: unknown): boolean { } /** True when a property resolves to text the author actually authored. */ -function isContentText(property: ComponentPropertyDescriptor): property is ComponentPropertyDescriptor & { value: string } { +function isContentText( + property: ComponentPropertyDescriptor +): property is ComponentPropertyDescriptor & { value: string } { return property.area === 'content' && typeof property.value === 'string'; } @@ -161,12 +160,22 @@ const seoMetaRule: AuditRule = { evaluate(node) { const meta = findProperty(node, META_KEY_HINT); if (meta && meta.area === 'content' && isEmptyValue(meta.value)) { + const suggestion = suggestMetaFromHeading(node, meta.key); return [ makeFinding(seoMetaRule, node, { propertyKey: meta.key, severity: 'info', title: 'SEO metadata is empty', detail: `"${meta.key}" is empty. Populate it to improve search and social sharing.`, + fix: suggestion + ? { + kind: 'suggested', + label: 'Use heading as meta', + propertyKey: meta.key, + suggestedValue: suggestion, + source: 'the heading on this component', + } + : undefined, }), ]; } @@ -175,8 +184,11 @@ const seoMetaRule: AuditRule = { }; /** - * Content properties bound to an entry must actually resolve. A binding whose - * source is an entry but with no entryId recorded is a broken reference. + * Content properties bound to an entry must actually resolve. When the collector + * recorded a real resolution (`resolvedBindings`), prefer it: a binding is broken + * iff it is an entry binding that did not resolve. Where no resolution is present + * (host without `resolveEntryBinding`), fall back to the structural check — an + * entry source with no recorded `entryId` is a broken reference. */ const brokenBindingRule: AuditRule = { id: 'content/broken-binding', @@ -185,13 +197,20 @@ const brokenBindingRule: AuditRule = { const findings: AuditFinding[] = []; for (const property of node.properties) { const binding = property.binding; - if (binding && binding.sourceType === 'entry' && !binding.entryId) { + if (!binding || binding.sourceType !== 'entry') continue; + + const resolution = node.resolvedBindings?.[property.key]; + const broken = resolution + ? resolution.isEntryBinding && !resolution.resolved + : !binding.entryId; + + if (broken) { findings.push( makeFinding(brokenBindingRule, node, { propertyKey: property.key, severity: 'error', title: 'Broken entry binding', - detail: `"${property.key}" is bound to an entry, but the reference is missing or unresolved.`, + detail: `"${property.key}" is bound to an entry, but the reference does not resolve.`, }) ); } @@ -200,6 +219,45 @@ const brokenBindingRule: AuditRule = { }, }; +function headingLevelOf(node: CollectedNode): number | undefined { + const prop = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key))); + return typeof prop?.value === 'number' ? prop.value : undefined; +} + +/** + * Heading levels should not skip (e.g. H2 -> H4 is an a11y/SEO problem). + * Order-sensitive across nodes, so unlike the per-node rules this is applied by + * the engine over the full node list. + */ +export function evaluateHeadingOrder(nodes: CollectedNode[]): AuditFinding[] { + const findings: AuditFinding[] = []; + let previous: number | undefined; + for (const node of nodes) { + const level = headingLevelOf(node); + if (level === undefined) continue; + if (previous !== undefined && level > previous + 1) { + const expected = previous + 1; + const key = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)))!.key; + findings.push( + makeFinding({ id: 'a11y/heading-order' }, node, { + propertyKey: key, + severity: 'warning', + title: 'Heading level skips a level', + detail: `This heading is H${level} but follows an H${previous}. Use H${expected} so the outline is sequential.`, + fix: { + kind: 'deterministic', + label: `Set to H${expected}`, + propertyKey: key, + value: expected, + }, + }) + ); + } + previous = level; + } + return findings; +} + export const AUDIT_RULES: AuditRule[] = [ altTextRule, requiredContentRule, From 16fc6de2632f263fc2510ae04faa6ecfe5306c3a Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:54:00 -0600 Subject: [PATCH 07/25] fix: tighten heading-level matching to avoid key collisions [EXT-7476] --- .../experience-auditor/src/audit/audit.spec.ts | 18 ++++++++++++++++++ examples/experience-auditor/src/audit/fixes.ts | 7 +++++-- examples/experience-auditor/src/audit/keys.ts | 5 +++-- examples/experience-auditor/src/audit/rules.ts | 14 ++++++++------ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index cc800e8e4..c83e6c1e5 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -162,6 +162,24 @@ describe('audit rules', () => { }); }); + it('suggests meta from the real heading even when a numeric headingLevel is present', () => { + const report = runAudit([ + node('hero', [ + { key: 'headingLevel', area: 'content', value: 2 }, + { key: 'heading', area: 'content', value: 'Spring Sale' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + ]); + const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta'); + expect(finding?.fix).toEqual({ + kind: 'suggested', + label: 'Use heading as meta', + propertyKey: 'metaTitle', + suggestedValue: 'Spring Sale', + source: 'the heading on this component', + }); + }); + it('flags an entry binding that fails to resolve via resolvedBindings', () => { const n: CollectedNode = { id: 'card', diff --git a/examples/experience-auditor/src/audit/fixes.ts b/examples/experience-auditor/src/audit/fixes.ts index 064f43c3a..fc37b8b8e 100644 --- a/examples/experience-auditor/src/audit/fixes.ts +++ b/examples/experience-auditor/src/audit/fixes.ts @@ -1,5 +1,5 @@ import type { CollectedNode } from './types'; -import { stripNonAlpha, HEADING_KEY_HINT } from './keys'; +import { stripNonAlpha, HEADING_KEY_HINT, HEADING_LEVEL_HINT } from './keys'; /** * Derives a suggested SEO meta value from the node's heading, when one exists. @@ -10,7 +10,10 @@ import { stripNonAlpha, HEADING_KEY_HINT } from './keys'; * per-key logic; the heading is the source regardless of which meta key is empty. */ export function suggestMetaFromHeading(node: CollectedNode, _metaKey: string): string | null { - const heading = node.properties.find((p) => HEADING_KEY_HINT.test(stripNonAlpha(p.key))); + const heading = node.properties.find( + (p) => + HEADING_KEY_HINT.test(stripNonAlpha(p.key)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)) + ); if (!heading || typeof heading.value !== 'string') return null; const trimmed = heading.value.trim(); return trimmed.length > 0 ? trimmed : null; diff --git a/examples/experience-auditor/src/audit/keys.ts b/examples/experience-auditor/src/audit/keys.ts index 612e89f12..2e5be6f87 100644 --- a/examples/experience-auditor/src/audit/keys.ts +++ b/examples/experience-auditor/src/audit/keys.ts @@ -10,5 +10,6 @@ export const META_KEY_HINT = // Heading must look like a heading but NOT like SEO metadata — otherwise // `metaTitle`/`seoTitle` would match both rules and double-count one field. export const HEADING_KEY_HINT = /(heading|headline|^title$|pagetitle)/i; -// Numeric heading-level property (e.g. `headingLevel`), used by the heading-order rule. -export const HEADING_LEVEL_HINT = /(headinglevel|level|hlevel)/i; +// Matches a dedicated heading-level key (e.g. `headingLevel`, `hLevel`, `level`) +// without catching unrelated `*Level` keys like `nestingLevel` or `accessLevel`. +export const HEADING_LEVEL_HINT = /(headinglevel|hlevel|^level$)/i; diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index b26374402..3707af546 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -134,7 +134,8 @@ const requiredContentRule: AuditRule = { const heading = findProperty( node, HEADING_KEY_HINT, - (p) => !META_KEY_HINT.test(stripNonAlpha(p.key)) + (p) => + !META_KEY_HINT.test(stripNonAlpha(p.key)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)) ); if (heading && heading.area === 'content' && isEmptyValue(heading.value)) { return [ @@ -219,9 +220,10 @@ const brokenBindingRule: AuditRule = { }, }; -function headingLevelOf(node: CollectedNode): number | undefined { +function headingLevelOf(node: CollectedNode): { level: number; key: string } | undefined { const prop = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key))); - return typeof prop?.value === 'number' ? prop.value : undefined; + if (!prop || typeof prop.value !== 'number') return undefined; + return { level: prop.value, key: prop.key }; } /** @@ -233,11 +235,11 @@ export function evaluateHeadingOrder(nodes: CollectedNode[]): AuditFinding[] { const findings: AuditFinding[] = []; let previous: number | undefined; for (const node of nodes) { - const level = headingLevelOf(node); - if (level === undefined) continue; + const heading = headingLevelOf(node); + if (heading === undefined) continue; + const { level, key } = heading; if (previous !== undefined && level > previous + 1) { const expected = previous + 1; - const key = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)))!.key; findings.push( makeFinding({ id: 'a11y/heading-order' }, node, { propertyKey: key, From dd86f811be8c154d4ab30d70997990dd680ba4bf Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:57:18 -0600 Subject: [PATCH 08/25] feat: resolve entry bindings in the collector seam [EXT-7476] --- .../src/audit/collect.spec.ts | 54 +++++++++++++++++-- .../experience-auditor/src/audit/collect.ts | 29 +++++++++- .../experience-auditor/test/mocks/mockSdk.ts | 1 + 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts index 2e850a391..b2663755e 100644 --- a/examples/experience-auditor/src/audit/collect.spec.ts +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -5,10 +5,12 @@ import { makeMockNode } from '../../test/mocks'; describe('collectNodes', () => { it('resolves properties for every root node', async () => { const experience: any = { - getRootNodes: vi.fn().mockReturnValue([ - makeMockNode('a', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]), - makeMockNode('b', 'Component', [{ key: 'body', area: 'content', value: 'There' }]), - ]), + getRootNodes: vi + .fn() + .mockReturnValue([ + makeMockNode('a', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]), + makeMockNode('b', 'Component', [{ key: 'body', area: 'content', value: 'There' }]), + ]), }; const collected = await collectNodes(experience); @@ -36,4 +38,48 @@ describe('collectNodes', () => { expect(collected).toHaveLength(1); expect(collected[0].id).toBe('ok'); }); + + it('populates resolvedBindings when the node resolves an entry binding', async () => { + const okNode = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { sourceType: 'entry', entryId: 'e1' }, + }, + ]); + okNode.resolveEntryBinding = vi.fn().mockResolvedValue({ entryId: 'e1' }); + const experience: any = { getRootNodes: vi.fn().mockReturnValue([okNode]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toEqual({ + featured: { isEntryBinding: true, resolved: true }, + }); + }); + + it('marks a binding unresolved when resolveEntryBinding returns null', async () => { + const brokenNode = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: null, + binding: { sourceType: 'entry', entryId: 'gone' }, + }, + ]); + brokenNode.resolveEntryBinding = vi.fn().mockResolvedValue(null); + const experience: any = { getRootNodes: vi.fn().mockReturnValue([brokenNode]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toEqual({ + featured: { isEntryBinding: true, resolved: false }, + }); + }); + + it('omits resolvedBindings when the node has no resolveEntryBinding method', async () => { + const node = makeMockNode('plain', 'Component', [ + { key: 'body', area: 'content', value: 'hi' }, + ]); + // makeMockNode may or may not define resolveEntryBinding; this node has no entry-bound props anyway. + const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toBeUndefined(); + }); }); diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts index f2e727203..3cead4240 100644 --- a/examples/experience-auditor/src/audit/collect.ts +++ b/examples/experience-auditor/src/audit/collect.ts @@ -1,5 +1,5 @@ -import type { ExperienceAPI } from '@contentful/app-sdk'; -import type { CollectedNode } from './types'; +import type { ComponentPropertyDescriptor, ExoNodeAPI, ExperienceAPI } from '@contentful/app-sdk'; +import type { CollectedNode, ResolvedBinding } from './types'; /** * Walks the experience tree and resolves each node's properties into the @@ -17,10 +17,12 @@ export async function collectNodes(experience: ExperienceAPI): Promise { try { const properties = await node.getProperties(); + const resolvedBindings = await resolveBindings(node, properties); return { id: node.id, nodeType: node.nodeType, properties, + ...(resolvedBindings ? { resolvedBindings } : {}), } satisfies CollectedNode; } catch { // A node may have been removed mid-traversal; skip it rather than @@ -32,3 +34,26 @@ export async function collectNodes(experience: ExperienceAPI): Promise node !== null); } + +/** + * Resolves each entry-bound property via the host's `resolveEntryBinding`, + * capturing whether the reference actually resolves. Returns `undefined` when + * the host does not back resolution (older/partial bridge) or the node has no + * entry-bound properties, so the binding rule falls back to its structural check. + * SDK coupling lives here, not in the rules. + */ +async function resolveBindings( + node: ExoNodeAPI, + properties: ComponentPropertyDescriptor[] +): Promise | undefined> { + if (typeof node.resolveEntryBinding !== 'function') return undefined; + + const entries: Array<[string, ResolvedBinding]> = []; + for (const property of properties) { + if (property.binding?.sourceType !== 'entry') continue; + const target = await node.resolveEntryBinding(property.key); + entries.push([property.key, { isEntryBinding: true, resolved: target !== null }]); + } + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts index 5149fe8ba..5ac3b09bf 100644 --- a/examples/experience-auditor/test/mocks/mockSdk.ts +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -15,6 +15,7 @@ export function makeMockNode( get: vi.fn().mockReturnValue({ id, nodeType }), onChange: vi.fn().mockReturnValue(noopUnsubscribe), getProperties: vi.fn().mockResolvedValue(properties), + resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'mock-entry' }), getContentProperty: vi.fn().mockResolvedValue(undefined), setContentProperty: vi.fn().mockResolvedValue(undefined), onContentPropertyChanged: vi.fn().mockReturnValue(noopUnsubscribe), From 51efdefb511ffc31d013914bf2e02812890d84cf Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:04:04 -0600 Subject: [PATCH 09/25] refactor: drop redundant isEntryBinding field, cover partial-host guard [EXT-7476] --- .../src/audit/audit.spec.ts | 4 ++-- .../src/audit/collect.spec.ts | 23 +++++++++++++++---- .../experience-auditor/src/audit/collect.ts | 2 +- .../experience-auditor/src/audit/rules.ts | 4 +--- .../experience-auditor/src/audit/types.ts | 2 -- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index c83e6c1e5..46479199a 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -192,7 +192,7 @@ describe('audit rules', () => { binding: { sourceType: 'entry', entryId: 'e1' }, }, ], - resolvedBindings: { featured: { isEntryBinding: true, resolved: false } }, + resolvedBindings: { featured: { resolved: false } }, }; const finding = runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding'); expect(finding?.severity).toBe('error'); @@ -210,7 +210,7 @@ describe('audit rules', () => { binding: { sourceType: 'entry', entryId: 'e1' }, }, ], - resolvedBindings: { featured: { isEntryBinding: true, resolved: true } }, + resolvedBindings: { featured: { resolved: true } }, }; expect( runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding') diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts index b2663755e..7c9034106 100644 --- a/examples/experience-auditor/src/audit/collect.spec.ts +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -52,7 +52,7 @@ describe('collectNodes', () => { const experience: any = { getRootNodes: vi.fn().mockReturnValue([okNode]) }; const [collected] = await collectNodes(experience); expect(collected.resolvedBindings).toEqual({ - featured: { isEntryBinding: true, resolved: true }, + featured: { resolved: true }, }); }); @@ -69,15 +69,30 @@ describe('collectNodes', () => { const experience: any = { getRootNodes: vi.fn().mockReturnValue([brokenNode]) }; const [collected] = await collectNodes(experience); expect(collected.resolvedBindings).toEqual({ - featured: { isEntryBinding: true, resolved: false }, + featured: { resolved: false }, }); }); - it('omits resolvedBindings when the node has no resolveEntryBinding method', async () => { + it('omits resolvedBindings when no property is entry-bound', async () => { const node = makeMockNode('plain', 'Component', [ { key: 'body', area: 'content', value: 'hi' }, ]); - // makeMockNode may or may not define resolveEntryBinding; this node has no entry-bound props anyway. + const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toBeUndefined(); + }); + + it('omits resolvedBindings when the host does not implement resolveEntryBinding', async () => { + const node = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { sourceType: 'entry', entryId: 'e1' }, + }, + ]); + // Simulate a partial host bridge that has not shipped resolveEntryBinding yet. + delete (node as { resolveEntryBinding?: unknown }).resolveEntryBinding; const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; const [collected] = await collectNodes(experience); expect(collected.resolvedBindings).toBeUndefined(); diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts index 3cead4240..908e299e8 100644 --- a/examples/experience-auditor/src/audit/collect.ts +++ b/examples/experience-auditor/src/audit/collect.ts @@ -52,7 +52,7 @@ async function resolveBindings( for (const property of properties) { if (property.binding?.sourceType !== 'entry') continue; const target = await node.resolveEntryBinding(property.key); - entries.push([property.key, { isEntryBinding: true, resolved: target !== null }]); + entries.push([property.key, { resolved: target !== null }]); } return entries.length > 0 ? Object.fromEntries(entries) : undefined; diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index 3707af546..d64674f76 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -201,9 +201,7 @@ const brokenBindingRule: AuditRule = { if (!binding || binding.sourceType !== 'entry') continue; const resolution = node.resolvedBindings?.[property.key]; - const broken = resolution - ? resolution.isEntryBinding && !resolution.resolved - : !binding.entryId; + const broken = resolution ? !resolution.resolved : !binding.entryId; if (broken) { findings.push( diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts index 240c09918..da800c8c0 100644 --- a/examples/experience-auditor/src/audit/types.ts +++ b/examples/experience-auditor/src/audit/types.ts @@ -21,8 +21,6 @@ export interface CollectedNode { /** The resolution result for an entry-bound property, captured at collect time. */ export interface ResolvedBinding { - /** True when the binding declared an entry source. */ - isEntryBinding: boolean; /** True when `resolveEntryBinding` returned a target; false when it returned null. */ resolved: boolean; } From 695ad728c6a3e7e815e2f16204ff4eb2bf0e3bd2 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:05:57 -0600 Subject: [PATCH 10/25] feat: add SuggestedFix confirm-step component [EXT-7476] --- .../src/components/SuggestedFix.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 examples/experience-auditor/src/components/SuggestedFix.tsx diff --git a/examples/experience-auditor/src/components/SuggestedFix.tsx b/examples/experience-auditor/src/components/SuggestedFix.tsx new file mode 100644 index 000000000..e6de35494 --- /dev/null +++ b/examples/experience-auditor/src/components/SuggestedFix.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { Box, Button, Flex, Text, TextInput } from '@contentful/f36-components'; + +interface SuggestedFixProps { + /** The proposed value, pre-filled and editable. */ + suggestedValue: string; + /** Human-readable provenance shown above the field. */ + source: string; + /** Whether the current user may write (mirrors the deterministic Fix gate). */ + canApply: boolean; + /** True while the write is in flight. */ + isApplying: boolean; + /** Apply the (possibly edited) value. */ + onApply: (value: string) => void; +} + +/** + * The confirm-step for a `suggested` fix. Unlike a deterministic fix (applied on + * click), a suggested value is shown in an editable field so the author reviews + * and adjusts it before it is written. This keeps the app from silently writing + * an opinionated value. + */ +const SuggestedFix = ({ + suggestedValue, + source, + canApply, + isApplying, + onApply, +}: SuggestedFixProps) => { + const [value, setValue] = useState(suggestedValue); + + return ( + + + 💡 Suggested from {source} + + + setValue(e.target.value)} + isDisabled={!canApply || isApplying} + aria-label="Suggested value" + /> + + + + Editable before write · confirms via setContentProperty + notifier + + + ); +}; + +export default SuggestedFix; From 739bd5a144fb51c89879d3e5188267ffc911276b Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:09:25 -0600 Subject: [PATCH 11/25] feat: circular score gauge and celebration empty state [EXT-7476] --- .../src/components/EmptyState.tsx | 11 ++++ .../src/components/ScoreSummary.tsx | 65 ++++++++++++++----- 2 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 examples/experience-auditor/src/components/EmptyState.tsx diff --git a/examples/experience-auditor/src/components/EmptyState.tsx b/examples/experience-auditor/src/components/EmptyState.tsx new file mode 100644 index 000000000..5c4e5fdb2 --- /dev/null +++ b/examples/experience-auditor/src/components/EmptyState.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Note } from '@contentful/f36-components'; + +/** Celebration state shown when the experience passes every audit rule. */ +const EmptyState = () => ( + + 🎉 No issues found. This experience passes every audit rule. + +); + +export default EmptyState; diff --git a/examples/experience-auditor/src/components/ScoreSummary.tsx b/examples/experience-auditor/src/components/ScoreSummary.tsx index e9251f6fe..b4b910a0a 100644 --- a/examples/experience-auditor/src/components/ScoreSummary.tsx +++ b/examples/experience-auditor/src/components/ScoreSummary.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Badge, Flex, Heading, Text } from '@contentful/f36-components'; +import { Badge, Flex, Text } from '@contentful/f36-components'; import tokens from '@contentful/f36-tokens'; import { css } from 'emotion'; import type { AuditReport } from '../audit/types'; @@ -16,23 +16,58 @@ function scoreColor(score: number): string { const ScoreSummary = ({ report }: ScoreSummaryProps) => { const { score, counts, nodeCount } = report; + const color = scoreColor(score); + const radius = 28; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - score / 100); return ( - - - Health score - - - {score} - - - across {nodeCount} {nodeCount === 1 ? 'component' : 'components'} - + +
+ + + + +
+ {score} +
+
+ + + Health score + + + across {nodeCount} {nodeCount === 1 ? 'component' : 'components'} + +
From 973a747e1285fff10aac3a9906c3327933a438e4 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:15:04 -0600 Subject: [PATCH 12/25] feat: severity grouping, suggested-fix apply path, capability-gated Locate [EXT-7476] --- .../src/components/FindingList.tsx | 152 ++++++++++-------- .../src/locations/ExperienceToolbar.spec.tsx | 61 ++++++- .../src/locations/ExperienceToolbar.tsx | 52 +++--- 3 files changed, 174 insertions(+), 91 deletions(-) diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx index ab3ec3340..7fe250491 100644 --- a/examples/experience-auditor/src/components/FindingList.tsx +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -1,16 +1,17 @@ import React from 'react'; -import { Badge, Box, Button, Flex, Note, Stack, Text } from '@contentful/f36-components'; +import { Badge, Box, Button, Flex, Stack, Text } from '@contentful/f36-components'; import type { BadgeProps } from '@contentful/f36-components'; import type { AuditFinding, Severity } from '../audit/types'; +import SuggestedFix from './SuggestedFix'; +import EmptyState from './EmptyState'; interface FindingListProps { findings: AuditFinding[]; - /** Whether the canvas supports highlighting (visual mode only). */ canLocate: boolean; - /** Whether the current user may write content properties. */ canFix: boolean; onLocate: (finding: AuditFinding) => void; - onFix: (finding: AuditFinding) => void; + onApplyDeterministic: (finding: AuditFinding) => void; + onApplySuggested: (finding: AuditFinding, value: string) => void; busyFindingId: string | null; } @@ -20,76 +21,97 @@ const SEVERITY_VARIANT: Record = { info: 'secondary', }; +const SEVERITY_ORDER: Severity[] = ['error', 'warning', 'info']; +const SEVERITY_LABEL: Record = { + error: 'Errors', + warning: 'Warnings', + info: 'Info', +}; + const FindingList = ({ findings, canLocate, canFix, onLocate, - onFix, + onApplyDeterministic, + onApplySuggested, busyFindingId, }: FindingListProps) => { - if (findings.length === 0) { - return ( - - No issues found. This experience passes every audit rule. 🎉 - - ); - } + if (findings.length === 0) return ; return ( - - {findings.map((finding) => ( - - - - - {finding.severity} - {finding.title} - - - {finding.detail} - - {finding.propertyKey && ( - - Property: {finding.propertyKey} - - )} - - - - - {finding.fix && ( - - )} - - - - ))} + + {SEVERITY_ORDER.map((severity) => { + const group = findings.filter((f) => f.severity === severity); + if (group.length === 0) return null; + return ( + + + {SEVERITY_LABEL[severity]} · {group.length} + + {group.map((finding) => ( + + + + + {finding.severity} + {finding.title} + + + {finding.detail} + + {finding.fix?.kind === 'suggested' && ( + onApplySuggested(finding, value)} + /> + )} + + + + {finding.fix?.kind === 'deterministic' && ( + + )} + + + + ))} + + ); + })} ); }; diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index 8523cda07..fa3f4b75c 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -3,7 +3,7 @@ import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ExperienceToolbar from './ExperienceToolbar'; -import { mockSdk } from '../../test/mocks'; +import { mockSdk, defaultNodes } from '../../test/mocks'; vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -14,6 +14,16 @@ describe('ExperienceToolbar (Experience Auditor)', () => { vi.clearAllMocks(); mockSdk.exo.getUiMode.mockReturnValue('visual'); mockSdk.access.can.mockResolvedValue(true); + mockSdk.exo.experience.selection = { + get: vi.fn().mockReturnValue({ nodeId: null }), + onChange: vi.fn().mockReturnValue(vi.fn()), + set: vi.fn(), + highlight: vi.fn(), + }; + mockSdk.exo.experience.getRootNodes.mockReturnValue(defaultNodes); + mockSdk.exo.experience.getNode.mockImplementation( + (id: string) => defaultNodes.find((n) => n.id === id) ?? null + ); }); it('runs an audit on mount and renders findings with a score', async () => { @@ -41,10 +51,10 @@ describe('ExperienceToolbar (Experience Auditor)', () => { await user.click(within(firstFinding).getByText('Locate')); expect(mockSdk.exo.experience.selection.set).toHaveBeenCalledOnce(); - expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith( - expect.any(String), - { flash: true, scrollIntoView: true } - ); + expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith(expect.any(String), { + flash: true, + scrollIntoView: true, + }); }); it('disables locate in form mode', async () => { @@ -92,4 +102,45 @@ describe('ExperienceToolbar (Experience Auditor)', () => { // Re-audit ran on the trimmed value: the whitespace finding is gone. await waitFor(() => expect(queryByText('Trim whitespace')).toBeNull()); }); + + it('applies a suggested fix with the edited value via setContentProperty', async () => { + const user = userEvent.setup(); + const setContentProperty = vi.fn().mockResolvedValue(undefined); + const metaNode = { + id: 'page', + nodeType: 'Component', + onChange: vi.fn().mockReturnValue(vi.fn()), + resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'e' }), + getProperties: vi.fn().mockResolvedValue([ + { key: 'heading', area: 'content', value: 'Spring Sale' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), + setContentProperty, + }; + mockSdk.exo.experience.getRootNodes.mockReturnValue([metaNode]); + mockSdk.exo.experience.getNode.mockReturnValue(metaNode); + + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('suggested-fix')).toBeInTheDocument()); + + const input = within(getByTestId('suggested-fix')).getByLabelText( + 'Suggested value' + ) as HTMLInputElement; + expect(input.value).toBe('Spring Sale'); + await user.clear(input); + await user.type(input, 'Spring Sale 2026'); + await user.click(within(getByTestId('suggested-fix')).getByText('Apply')); + + expect(setContentProperty).toHaveBeenCalledWith('metaTitle', 'Spring Sale 2026'); + await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.')); + }); + + it('renders Locate as disabled when selection is unsupported', async () => { + mockSdk.exo.experience.selection = undefined; + const { getAllByTestId } = render(); + await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); + const locate = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button'); + expect(locate).toBeDisabled(); + expect(locate).toHaveAttribute('title', expect.stringContaining('not available')); + }); }); diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index af3828878..ce4c21cf7 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -1,9 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { - ExoContext, - ExperienceEditorToolbarAppSDK, - UiMode, -} from '@contentful/app-sdk'; +import type { ExoContext, ExperienceEditorToolbarAppSDK, UiMode } from '@contentful/app-sdk'; import { Badge, Box, @@ -19,7 +15,8 @@ import { useSDK } from '@contentful/react-apps-toolkit'; import { collectNodes } from '../audit/collect'; import { hasBlockingErrors, runAudit } from '../audit/engine'; -import type { AuditFinding, AuditReport } from '../audit/types'; +import { detectCapabilities } from '../audit/capabilities'; +import type { AuditFinding, AuditReport, Capabilities } from '../audit/types'; import ScoreSummary from '../components/ScoreSummary'; import FindingList from '../components/FindingList'; @@ -43,6 +40,8 @@ const ExperienceToolbar = () => { const [canFix, setCanFix] = useState(false); const [busyFindingId, setBusyFindingId] = useState(null); const [publishing, setPublishing] = useState(false); + const [capabilities, setCapabilities] = useState(() => detectCapabilities(sdk.exo)); + useEffect(() => setCapabilities(detectCapabilities(sdk.exo)), [sdk]); // Guard against state updates after unmount / stale async audits. const runIdRef = useRef(0); @@ -106,12 +105,8 @@ const ExperienceToolbar = () => { [sdk] ); - const handleFix = useCallback( - async (finding: AuditFinding) => { - // This handler applies deterministic fixes (write the precomputed value). - // Suggested fixes are applied through a separate confirm path (added later). - if (finding.fix?.kind !== 'deterministic') return; - + const applyWrite = useCallback( + async (finding: AuditFinding, propertyKey: string, value: unknown) => { setBusyFindingId(finding.id); try { const node = sdk.exo.experience.getNode(finding.nodeId); @@ -119,11 +114,11 @@ const ExperienceToolbar = () => { sdk.notifier.error('That component no longer exists.'); return; } - await node.setContentProperty(finding.fix.propertyKey, finding.fix.value); + await node.setContentProperty(propertyKey, value); sdk.notifier.success('Fix applied.'); // Re-audit explicitly rather than relying on the setContentProperty - // write to round-trip back through onChange — that keeps the panel in - // sync even if the host doesn't emit a change event for this write. + // write to round-trip back through onChange — keeps the panel in sync + // even if the host does not emit a change event for this write. await audit(); } catch { sdk.notifier.error('Could not apply the fix. Please try again.'); @@ -134,6 +129,22 @@ const ExperienceToolbar = () => { [sdk, audit] ); + const handleApplyDeterministic = useCallback( + (finding: AuditFinding) => { + if (finding.fix?.kind !== 'deterministic') return; + void applyWrite(finding, finding.fix.propertyKey, finding.fix.value); + }, + [applyWrite] + ); + + const handleApplySuggested = useCallback( + (finding: AuditFinding, value: string) => { + if (finding.fix?.kind !== 'suggested') return; + void applyWrite(finding, finding.fix.propertyKey, value); + }, + [applyWrite] + ); + const handlePublish = useCallback(async () => { if (!report || hasBlockingErrors(report)) return; @@ -154,7 +165,7 @@ const ExperienceToolbar = () => { }, [sdk, report]); const blocked = report ? hasBlockingErrors(report) : false; - const canLocate = uiMode === 'visual'; + const canLocate = uiMode === 'visual' && capabilities.selection; return ( @@ -170,8 +181,7 @@ const ExperienceToolbar = () => { size="small" variant="secondary" onClick={() => void audit()} - isLoading={auditing} - > + isLoading={auditing}> Re-run audit @@ -197,7 +207,8 @@ const ExperienceToolbar = () => { canLocate={canLocate} canFix={canFix} onLocate={handleLocate} - onFix={handleFix} + onApplyDeterministic={handleApplyDeterministic} + onApplySuggested={handleApplySuggested} busyFindingId={busyFindingId} /> )} @@ -212,8 +223,7 @@ const ExperienceToolbar = () => { variant="positive" isDisabled={!report || blocked || publishing} isLoading={publishing} - onClick={() => void handlePublish()} - > + onClick={() => void handlePublish()}> Publish experience
From d45b95315b20367254fec37d31679d51d4ff6760 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:22:46 -0600 Subject: [PATCH 13/25] feat: add standalone demo mode for experience-auditor [EXT-7476] --- .../src/demo/DemoProvider.tsx | 23 ++++++ .../experience-auditor/src/demo/mockExo.ts | 81 +++++++++++++++++++ examples/experience-auditor/src/index.tsx | 12 ++- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 examples/experience-auditor/src/demo/DemoProvider.tsx create mode 100644 examples/experience-auditor/src/demo/mockExo.ts diff --git a/examples/experience-auditor/src/demo/DemoProvider.tsx b/examples/experience-auditor/src/demo/DemoProvider.tsx new file mode 100644 index 000000000..332697b7c --- /dev/null +++ b/examples/experience-auditor/src/demo/DemoProvider.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { SDKContext } from '@contentful/react-apps-toolkit'; +import { GlobalStyles } from '@contentful/f36-components'; + +import { demoSdk } from './mockExo'; +import ExperienceToolbar from '../locations/ExperienceToolbar'; + +/** + * DEMO ONLY — renders the toolbar against a seeded in-memory `sdk.exo` so the + * audit -> suggested-fix -> re-score loop is clickable via `npm start` before + * the host renderer is broadly available. Never used on the real app path. + * + * The toolkit's `useSDK()` reads `useContext(SDKContext).sdk`, so the context + * value must be `{ sdk }` — matching what the real `SDKProvider` supplies. + */ +const DemoProvider = () => ( + + + + +); + +export default DemoProvider; diff --git a/examples/experience-auditor/src/demo/mockExo.ts b/examples/experience-auditor/src/demo/mockExo.ts new file mode 100644 index 000000000..2790f1ac6 --- /dev/null +++ b/examples/experience-auditor/src/demo/mockExo.ts @@ -0,0 +1,81 @@ +import type { ExperienceEditorToolbarAppSDK } from '@contentful/app-sdk'; + +/** + * DEMO ONLY — scaffolding for the standalone `?demo` mode. Typed loosely on + * purpose: the mock only implements the surfaces the toolbar and collector + * actually touch, and a single `as unknown as ExperienceEditorToolbarAppSDK` + * cast at the export bridges it to the real SDK type. Not used on any real path. + */ + +interface DemoProperty { + key: string; + area: 'content'; + value: unknown; +} + +function makeNode(id: string, initialProps: DemoProperty[]) { + let props = initialProps; + return { + id, + nodeType: 'Component' as const, + get: () => ({ id, nodeType: 'Component' as const }), + onChange: () => () => {}, + getProperties: async () => props, + getContentProperty: async () => undefined, + setContentProperty: async (key: string, value: unknown) => { + // Mutate in place so a re-audit reflects the applied fix. + props = props.map((p) => (p.key === key ? { ...p, value } : p)); + }, + onContentPropertyChanged: () => () => {}, + resolveEntryBinding: async () => ({ entryId: 'demo-entry' }), + }; +} + +// Three seeded nodes, each planting exactly one finding: +// - `hero`: image present + empty altText -> error (missing alt text) +// - `cta`: empty heading -> warning (empty heading) +// - `page`: heading present + empty metaTitle -> info + suggested fix +const nodes = [ + makeNode('hero', [ + { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, + { key: 'altText', area: 'content', value: '' }, + { key: 'heading', area: 'content', value: 'Welcome' }, + ]), + makeNode('cta', [{ key: 'heading', area: 'content', value: '' }]), + makeNode('page', [ + { key: 'heading', area: 'content', value: 'Our Spring Sale' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]), +]; + +/** + * DEMO ONLY — a minimal seeded `sdk.exo`. `experience.selection` is deliberately + * omitted so the demo exercises the graceful "Locate not available" degradation + * (`detectCapabilities` reports `selection: false` and the button renders + * disabled). + */ +export const demoSdk = { + location: { is: () => true }, + ids: { app: 'demo-app' }, + access: { can: async () => true }, + notifier: { + success: (m: string) => console.info('[demo notifier]', m), + error: (m: string) => console.warn('[demo notifier]', m), + warning: (m: string) => console.warn('[demo notifier]', m), + }, + exo: { + context: { type: 'experience' as const, entityId: 'demo-experience' }, + onContextChanged: () => () => {}, + getUiMode: () => 'visual' as const, + onUiModeChanged: () => () => {}, + experience: { + get: () => ({ sys: { id: 'demo-experience' } }), + onChange: () => () => {}, + save: async () => {}, + publish: async () => {}, + getNode: (id: string) => nodes.find((n) => n.id === id) ?? null, + getRootNodes: () => nodes, + // selection intentionally omitted (see above) + }, + }, +} as unknown as ExperienceEditorToolbarAppSDK; diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx index d10662719..024d71dff 100644 --- a/examples/experience-auditor/src/index.tsx +++ b/examples/experience-auditor/src/index.tsx @@ -10,7 +10,17 @@ import App from './App'; const container = document.getElementById('root'); const root = createRoot(container!); -if (process.env.NODE_ENV === 'development' && window.self === window.top) { +const params = new URLSearchParams(window.location.search); +const demoMode = process.env.NODE_ENV === 'development' && params.has('demo'); + +if (demoMode) { + // Standalone demo: `npm start` + `?demo` renders the toolbar against a seeded + // in-memory sdk.exo. Dynamically imported so the demo scaffolding stays out of + // the production bundle. + void import('./demo/DemoProvider').then(({ default: DemoProvider }) => { + root.render(); + }); +} else if (process.env.NODE_ENV === 'development' && window.self === window.top) { // You can remove this if block before deploying your app root.render(); } else { From d6df14bcc89a0a1dd28fdec05ef0ff0ab46e046d Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:29:57 -0600 Subject: [PATCH 14/25] refactor: fold demo-mode URL parse out of production bundle [EXT-7476] --- examples/experience-auditor/src/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx index 024d71dff..472705157 100644 --- a/examples/experience-auditor/src/index.tsx +++ b/examples/experience-auditor/src/index.tsx @@ -10,8 +10,10 @@ import App from './App'; const container = document.getElementById('root'); const root = createRoot(container!); -const params = new URLSearchParams(window.location.search); -const demoMode = process.env.NODE_ENV === 'development' && params.has('demo'); +// `process.env.NODE_ENV` is evaluated first so the URL parse is fully +// eliminated from production builds (the whole `&&` folds away). +const demoMode = + process.env.NODE_ENV === 'development' && new URLSearchParams(window.location.search).has('demo'); if (demoMode) { // Standalone demo: `npm start` + `?demo` renders the toolbar against a seeded From da86b1ab4c71d6ae8d7769d7530726ec7ce3d11c Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:33:19 -0600 Subject: [PATCH 15/25] docs: document new rules, fix model, capability degradation, and demo mode [EXT-7476] --- examples/experience-auditor/README.md | 124 ++++++++++++++++++++------ 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index 7b2c96e84..7bb773b46 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -24,48 +24,95 @@ experience tree as the author works.** counts. - **Locate on canvas** — clicking **Locate** calls `selection.set(nodeId)` + `selection.highlight(nodeId, { flash, scrollIntoView })` - to jump straight to the offending component (visual mode only). -- **One-click fixes** — where a safe, deterministic fix exists (e.g. trimming - stray whitespace from alt text), the app applies it via + to jump straight to the offending component (visual mode only, and only where + the host backs the selection surface — see _Capability-aware behavior_). +- **One-click fixes** — findings can carry a fix that writes back via `getNode().setContentProperty()`, permission-checked with `sdk.access.can()` - and confirmed through `sdk.notifier`. + and confirmed through `sdk.notifier`. Fixes come in two kinds (see below). - **Pre-publish gate** — `experience.publish()` is blocked while any error-level finding remains. ### Audit rules -| Rule | Severity | What it checks | -| --- | --- | --- | -| `a11y/image-alt-text` | error / warning | Images must have non-empty alt text; trims stray whitespace | -| `content/required-empty` | warning | Headings/titles should not be empty | -| `seo/missing-meta` | info | SEO meta fields should be populated | -| `content/broken-binding` | error | Entry-bound properties must resolve to an entry | - -The rules live in [`src/audit/rules.ts`](src/audit/rules.ts) as pure functions -over a SDK-independent `CollectedNode` shape, so they are fully unit-tested -without a live SDK. Adding a rule is a matter of dropping another `AuditRule` -into `AUDIT_RULES`. +| Rule | Severity | What it checks | +| ------------------------ | --------------- | --------------------------------------------------------------------- | +| `a11y/image-alt-text` | error / warning | Images must have non-empty alt text; trims stray whitespace | +| `content/required-empty` | warning | Headings/titles should not be empty | +| `seo/missing-meta` | info | SEO meta fields should be populated | +| `content/broken-binding` | error | Entry-bound properties must resolve via the host's binding resolution | +| `a11y/heading-order` | warning | Heading levels should not skip (e.g. H2 → H4) | + +Most rules are **per-node**: they look at a single component's properties in +isolation. `a11y/heading-order` is **cross-node** — it reads the heading levels +across the experience in order, so it lives alongside the per-node rules but is +applied by the engine over the full node list rather than node-by-node. + +The per-node rules live in [`src/audit/rules.ts`](src/audit/rules.ts) as pure +functions over a SDK-independent `CollectedNode` shape, so they are fully +unit-tested without a live SDK. Adding a per-node rule is a matter of dropping +another `AuditRule` into `AUDIT_RULES`. + +### One-click fixes + +A finding can offer a fix, and there are two kinds — the distinction matters +because one is safe to apply blindly and the other is not: + +- **Deterministic** — exactly one correct result, applied immediately on click. + Examples: trimming surrounding whitespace from alt text, or setting a skipped + heading to the level that keeps the outline sequential. There is nothing to + review, so the app just writes the value. +- **Suggested** — a proposed value the author reviews and edits _before_ it is + written. Example: deriving an SEO meta value from the component's heading. The + app pre-fills the suggestion with its provenance, and the author can accept it + as-is or change it. **A suggested value is never written silently** — the + write only happens after the author confirms. + +The fix shapes are modelled as a discriminated union in +[`src/audit/types.ts`](src/audit/types.ts) (`AutoFix`), so the toolbar can route +each kind to the right UI and the rules stay declarative about what they offer. + +### Capability-aware behavior + +Not every `sdk.exo` surface is backed by every host, and surfaces can roll out +incrementally. Rather than call a method and catch the failure, the app probes +up front which surfaces are available +([`src/audit/capabilities.ts`](src/audit/capabilities.ts)) and adapts its UI to +match. Concretely: where the host does not back selection, **Locate** is +rendered **disabled** with an explanation of why, instead of calling an +unsupported API and erroring. + +This is a pattern worth copying for any app that depends on optional or +still-rolling-out host capabilities: detect once, degrade gracefully, and tell +the user why an affordance is unavailable rather than letting it fail. ## Architecture ``` src/ audit/ - types.ts SDK-independent domain types (CollectedNode, AuditFinding, …) - rules.ts Pure audit rules - engine.ts Runs rules, aggregates findings, computes the score - collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[] + types.ts SDK-independent domain types (CollectedNode, AuditFinding, AutoFix, …) + rules.ts Pure audit rules (per-node + the cross-node heading-order rule) + engine.ts Runs rules, aggregates findings, computes the score + fixes.ts Pure derivation of suggested fix values (e.g. meta from heading) + capabilities.ts Probes which optional sdk.exo surfaces the host backs + collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[] components/ ScoreSummary.tsx - FindingList.tsx + FindingList.tsx Groups findings by severity; renders locate + fix affordances + SuggestedFix.tsx Review-and-edit step for suggested fixes + EmptyState.tsx + demo/ + DemoProvider.tsx Dev-only: renders the toolbar against the seeded mock (?demo) + mockExo.ts Seeded in-memory sdk.exo for the demo locations/ ConfigScreen.tsx ExperienceToolbar.tsx Wires the SDK to the engine (collect → audit → locate/fix/publish) ``` Keeping the rules pure and the SDK boundary thin (`collect.ts`) is the key -pattern: all the interesting logic is testable in isolation, and the live SDK -work is small enough to reason about. +pattern: all the interesting logic — rules, scoring, suggested-fix derivation, +capability detection — is testable in isolation, and the live SDK work is small +enough to reason about. ## How to use @@ -87,6 +134,25 @@ npm install npm start ``` +### Try it locally (demo mode) + +You can drive the full audit → suggested-fix → re-score loop without a live host +at all: + +```bash +npm start +# then open: +http://localhost:3000/?demo +``` + +The `?demo` flag renders the toolbar against a seeded, in-memory experience so +you can click an audit, accept or edit a suggested fix, and watch the score +update. It is a **dev-only convenience with no live canvas** — because there is +no real selection surface behind it, **Locate is disabled in the demo** (an +illustration of the capability-aware behavior described above). The demo +scaffolding is dynamically imported and stripped from production builds; the +real runtime is inside the Contentful host. + ## Registering the toolbar location Like other toolbar apps, this is **not** assigned per content type — there is no @@ -103,12 +169,14 @@ pointing the app at `http://localhost:3000`. ## A note on verification This app is built against the published `@contentful/app-sdk@4.58.0` types, -which are the contract for the toolbar location. At the time of writing the host -renderer that serves `sdk.exo` at runtime is still rolling out, so the app is -**type-verified and unit-tested against a mocked SDK** (audit rules, scoring, -collector, and the toolbar's locate/fix/publish-gate behavior all have tests), -but not yet verified end-to-end inside a live ExO editor. The API shapes used -here match the published types exactly. +which are the contract for the toolbar location. The host renderer that serves +`sdk.exo` at runtime is still rolling out, so the app is **type-verified and +unit-tested against a mocked SDK** — 40 tests cover the audit rules, scoring, +the collector and its binding resolution, capability detection, the suggested- +fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is +not yet verified end-to-end inside a live ExO editor; that live verification is +tracked separately as the host renderer rolls out. The API shapes used here +match the published types exactly. ## Available scripts From b4b716c441872df5f8a69ed8f33a1d7976c9d6e4 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:36:37 -0600 Subject: [PATCH 16/25] docs: correct live-audit traversal description in experience-auditor [EXT-7476] --- examples/experience-auditor/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index 7bb773b46..81945631e 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -18,8 +18,8 @@ experience tree as the author works.** ## What it does - **Live audit** — walks the experience tree with `getRootNodes()` → - `getNode()` → `getProperties()`, runs a set of pure rules, and re-runs - automatically on `sdk.exo.experience.onChange()`. + `getProperties()`, runs a set of pure rules, and re-runs automatically on + `sdk.exo.experience.onChange()`. - **Scored dashboard** — a 0–100 health score with error / warning / info counts. - **Locate on canvas** — clicking **Locate** calls From bd05563e5390b17acf2cce6ac3fc481c5101d775 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 12:14:09 -0600 Subject: [PATCH 17/25] fix: address gauntlet review findings on experience-auditor [EXT-7476] --- .../src/audit/audit.spec.ts | 2 + .../src/audit/fixes.spec.ts | 9 ++-- .../experience-auditor/src/audit/fixes.ts | 5 +- .../experience-auditor/src/audit/rules.ts | 2 +- .../src/components/FindingList.spec.tsx | 46 +++++++++++++++++++ .../src/components/FindingList.tsx | 7 +++ .../src/components/SuggestedFix.tsx | 7 ++- 7 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 examples/experience-auditor/src/components/FindingList.spec.tsx diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index 46479199a..bbd4c35e1 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -218,6 +218,8 @@ describe('audit rules', () => { }); it('exposes a stable rule set', () => { + // Per-node rules only; the cross-node a11y/heading-order rule is invoked by + // the engine over the full node list, not registered in AUDIT_RULES. expect(AUDIT_RULES.map((r) => r.id)).toEqual([ 'a11y/image-alt-text', 'content/required-empty', diff --git a/examples/experience-auditor/src/audit/fixes.spec.ts b/examples/experience-auditor/src/audit/fixes.spec.ts index f79db64df..4eabb280f 100644 --- a/examples/experience-auditor/src/audit/fixes.spec.ts +++ b/examples/experience-auditor/src/audit/fixes.spec.ts @@ -13,8 +13,7 @@ describe('suggestMetaFromHeading', () => { node([ { key: 'heading', area: 'content', value: ' Our Spring Sale ' }, { key: 'metaTitle', area: 'content', value: '' }, - ]), - 'metaTitle' + ]) ); expect(result).toBe('Our Spring Sale'); }); @@ -24,8 +23,7 @@ describe('suggestMetaFromHeading', () => { node([ { key: 'heading', area: 'content', value: ' ' }, { key: 'metaTitle', area: 'content', value: '' }, - ]), - 'metaTitle' + ]) ); expect(result).toBeNull(); }); @@ -35,8 +33,7 @@ describe('suggestMetaFromHeading', () => { node([ { key: 'heading', area: 'content', value: { sys: { id: 'x' } } }, { key: 'metaTitle', area: 'content', value: '' }, - ]), - 'metaTitle' + ]) ); expect(result).toBeNull(); }); diff --git a/examples/experience-auditor/src/audit/fixes.ts b/examples/experience-auditor/src/audit/fixes.ts index fc37b8b8e..16a78f87f 100644 --- a/examples/experience-auditor/src/audit/fixes.ts +++ b/examples/experience-auditor/src/audit/fixes.ts @@ -5,11 +5,8 @@ import { stripNonAlpha, HEADING_KEY_HINT, HEADING_LEVEL_HINT } from './keys'; * Derives a suggested SEO meta value from the node's heading, when one exists. * Pure: reads only the CollectedNode. Returns the trimmed heading text, or * `null` when there is no usable heading (the caller then offers no suggestion). - * - * `_metaKey` is accepted for symmetry with other derivations and future - * per-key logic; the heading is the source regardless of which meta key is empty. */ -export function suggestMetaFromHeading(node: CollectedNode, _metaKey: string): string | null { +export function suggestMetaFromHeading(node: CollectedNode): string | null { const heading = node.properties.find( (p) => HEADING_KEY_HINT.test(stripNonAlpha(p.key)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)) diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index d64674f76..ee6e39b0e 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -161,7 +161,7 @@ const seoMetaRule: AuditRule = { evaluate(node) { const meta = findProperty(node, META_KEY_HINT); if (meta && meta.area === 'content' && isEmptyValue(meta.value)) { - const suggestion = suggestMetaFromHeading(node, meta.key); + const suggestion = suggestMetaFromHeading(node); return [ makeFinding(seoMetaRule, node, { propertyKey: meta.key, diff --git a/examples/experience-auditor/src/components/FindingList.spec.tsx b/examples/experience-auditor/src/components/FindingList.spec.tsx new file mode 100644 index 000000000..7ee430015 --- /dev/null +++ b/examples/experience-auditor/src/components/FindingList.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import FindingList from './FindingList'; +import type { AuditFinding } from '../audit/types'; + +function suggestedFinding(suggestedValue: string): AuditFinding { + return { + id: 'seo/missing-meta:page:metaTitle', // stable across re-audits + ruleId: 'seo/missing-meta', + nodeId: 'page', + nodeType: 'Component', + propertyKey: 'metaTitle', + severity: 'info', + title: 'SEO metadata is empty', + detail: 'metaTitle is empty.', + fix: { + kind: 'suggested', + label: 'Use heading as meta', + propertyKey: 'metaTitle', + suggestedValue, + source: 'the heading on this component', + }, + }; +} + +describe('FindingList suggested-fix seeding', () => { + it('re-seeds the suggested value when the suggestion changes for the same finding id', () => { + const props = { + canLocate: false, + canFix: true, + onLocate: vi.fn(), + onApplyDeterministic: vi.fn(), + onApplySuggested: vi.fn(), + busyFindingId: null, + }; + const { getByLabelText, rerender } = render( + + ); + expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale'); + + // Same finding.id, new derived suggestion (heading changed, meta still empty). + rerender(); + expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale 2026'); + }); +}); diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx index 7fe250491..e1adfdfab 100644 --- a/examples/experience-auditor/src/components/FindingList.tsx +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -70,7 +70,14 @@ const FindingList = ({ {finding.detail} {finding.fix?.kind === 'suggested' && ( + // Key on the suggested value so a *changed* suggestion (same + // finding.id, new derived value) remounts SuggestedFix and + // re-seeds its state; an unchanged suggestion keeps the same + // key, preserving the author's in-progress edit across a + // no-op re-audit. React's "reset state with a key" idiom — + // preferred over a useEffect-sync anti-pattern. 💡 Suggested from {source} From 7fdee6cbe8265f639f343088f72619bab39d00f5 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Tue, 9 Jun 2026 14:45:22 -0600 Subject: [PATCH 18/25] fix: align experience-auditor with app-sdk 4.58.1 Binding union [EXT-7476] 4.58.1 reshaped ComponentPropertyDescriptor.binding to the typed `Binding` union (EntryBinding | ManualBinding), replacing the old `{ sourceType, entryId }` ComponentPropertyBinding shape. - rules.ts / collect.ts: gate entry bindings on `binding.type === 'entry'` (was `binding.sourceType === 'entry'`); `entryId` reads now narrow off the EntryBinding arm. - Specs: use the EntryBinding fixture shape `{ type, entryId, fieldId }`. - Bump @contentful/app-sdk 4.58.0 -> 4.58.1. Verified: tsc --noEmit clean, 41 tests passing against 4.58.1. Co-Authored-By: Claude Opus 4.8 --- examples/experience-auditor/package.json | 2 +- examples/experience-auditor/src/audit/audit.spec.ts | 8 ++++---- examples/experience-auditor/src/audit/collect.spec.ts | 6 +++--- examples/experience-auditor/src/audit/collect.ts | 2 +- examples/experience-auditor/src/audit/rules.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json index 4383fc531..da47d2995 100644 --- a/examples/experience-auditor/package.json +++ b/examples/experience-auditor/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.0", + "@contentful/app-sdk": "4.58.1", "@contentful/f36-components": "4.81.1", "@contentful/f36-icons": "^4.28.0", "@contentful/f36-tokens": "4.2.0", diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index bbd4c35e1..c02cf5bd7 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -100,7 +100,7 @@ describe('audit rules', () => { key: 'title', area: 'content', value: null, - binding: { sourceType: 'entry' }, + binding: { type: 'entry', entryId: '', fieldId: 'title' }, }, ]), ]); @@ -115,7 +115,7 @@ describe('audit rules', () => { key: 'title', area: 'content', value: 'Bound', - binding: { sourceType: 'entry', entryId: 'entry-1' }, + binding: { type: 'entry', entryId: 'entry-1', fieldId: 'title' }, }, ]), ]); @@ -189,7 +189,7 @@ describe('audit rules', () => { key: 'featured', area: 'content', value: null, - binding: { sourceType: 'entry', entryId: 'e1' }, + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, }, ], resolvedBindings: { featured: { resolved: false } }, @@ -207,7 +207,7 @@ describe('audit rules', () => { key: 'featured', area: 'content', value: 'x', - binding: { sourceType: 'entry', entryId: 'e1' }, + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, }, ], resolvedBindings: { featured: { resolved: true } }, diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts index 7c9034106..b5f55cd65 100644 --- a/examples/experience-auditor/src/audit/collect.spec.ts +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -45,7 +45,7 @@ describe('collectNodes', () => { key: 'featured', area: 'content', value: 'x', - binding: { sourceType: 'entry', entryId: 'e1' }, + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, }, ]); okNode.resolveEntryBinding = vi.fn().mockResolvedValue({ entryId: 'e1' }); @@ -62,7 +62,7 @@ describe('collectNodes', () => { key: 'featured', area: 'content', value: null, - binding: { sourceType: 'entry', entryId: 'gone' }, + binding: { type: 'entry', entryId: 'gone', fieldId: 'featured' }, }, ]); brokenNode.resolveEntryBinding = vi.fn().mockResolvedValue(null); @@ -88,7 +88,7 @@ describe('collectNodes', () => { key: 'featured', area: 'content', value: 'x', - binding: { sourceType: 'entry', entryId: 'e1' }, + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, }, ]); // Simulate a partial host bridge that has not shipped resolveEntryBinding yet. diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts index 908e299e8..ff602c110 100644 --- a/examples/experience-auditor/src/audit/collect.ts +++ b/examples/experience-auditor/src/audit/collect.ts @@ -50,7 +50,7 @@ async function resolveBindings( const entries: Array<[string, ResolvedBinding]> = []; for (const property of properties) { - if (property.binding?.sourceType !== 'entry') continue; + if (property.binding?.type !== 'entry') continue; const target = await node.resolveEntryBinding(property.key); entries.push([property.key, { resolved: target !== null }]); } diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index ee6e39b0e..e226cdc59 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -198,7 +198,7 @@ const brokenBindingRule: AuditRule = { const findings: AuditFinding[] = []; for (const property of node.properties) { const binding = property.binding; - if (!binding || binding.sourceType !== 'entry') continue; + if (!binding || binding.type !== 'entry') continue; const resolution = node.resolvedBindings?.[property.key]; const broken = resolution ? !resolution.resolved : !binding.entryId; From 29e748b3cc34582b06cab42254de1293d9ec200e Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Fri, 12 Jun 2026 13:40:30 -0600 Subject: [PATCH 19/25] fix: bump experience-auditor to app-sdk 4.58.2 and fix prettier [EXT-7476] - Bump @contentful/app-sdk 4.58.1 -> 4.58.2 to track the latest published ExO surface (DA definition-only trim, ui-extensions-sdk#2592). - Run prettier (2.8.8, matching CI) on ConfigScreen.tsx to clear the prettier-check.sh violation failing apps-test. 41 tests green against 4.58.2. Co-Authored-By: Claude Opus 4.8 --- examples/experience-auditor/package.json | 2 +- examples/experience-auditor/src/locations/ConfigScreen.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json index da47d2995..b907f0dc5 100644 --- a/examples/experience-auditor/package.json +++ b/examples/experience-auditor/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.1", + "@contentful/app-sdk": "4.58.2", "@contentful/f36-components": "4.81.1", "@contentful/f36-icons": "^4.28.0", "@contentful/f36-tokens": "4.2.0", diff --git a/examples/experience-auditor/src/locations/ConfigScreen.tsx b/examples/experience-auditor/src/locations/ConfigScreen.tsx index 325479209..601af0cca 100644 --- a/examples/experience-auditor/src/locations/ConfigScreen.tsx +++ b/examples/experience-auditor/src/locations/ConfigScreen.tsx @@ -43,9 +43,9 @@ const ConfigScreen = () => { experience you are editing for accessibility, SEO, and content-completeness issues. - Nothing to configure here. Once installed, make sure the{' '} - experience-toolbar location is registered on your app definition — the - auditor appears automatically when editing an experience. + Nothing to configure here. Once installed, make sure the experience-toolbar{' '} + location is registered on your app definition — the auditor appears automatically when + editing an experience.
From 4e0b3d3fd9b27bfb5bf33023555046a7404435df Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Wed, 24 Jun 2026 12:00:38 -0600 Subject: [PATCH 20/25] fix: adapt experience-auditor to app-sdk 4.59 surface [EXT-7476] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @contentful/app-sdk 4.58.2 → ^4.59.0 and adapt the example to the removed surface: - resolveEntryBinding is gone; brokenBindingRule now uses the structural !binding.entryId check it already fell back to (drop resolveBindings + ResolvedBinding type + the resolution-aware test cases). - setContentProperty is gone; node content is DA-only on 4.59 with no content-string write path. The auditor becomes read-only: findings still surface their derived fix as advice (SuggestedFix renders the value read-only), but the one-click apply path is removed. Auditor still detects + reports + locates; it no longer mutates content. --- examples/experience-auditor/package.json | 2 +- .../src/audit/audit.spec.ts | 8 +- .../src/audit/collect.spec.ts | 59 -------------- .../experience-auditor/src/audit/collect.ts | 29 +------ .../experience-auditor/src/audit/rules.ts | 16 ++-- .../experience-auditor/src/audit/types.ts | 12 --- .../src/components/FindingList.spec.tsx | 17 ++-- .../src/components/FindingList.tsx | 46 +++-------- .../src/components/SuggestedFix.tsx | 58 ++++---------- .../experience-auditor/src/demo/mockExo.ts | 10 +-- .../src/locations/ExperienceToolbar.spec.tsx | 77 ++++++------------- .../src/locations/ExperienceToolbar.tsx | 76 ++---------------- .../experience-auditor/test/mocks/mockSdk.ts | 4 - 13 files changed, 76 insertions(+), 338 deletions(-) diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json index b907f0dc5..8301ff522 100644 --- a/examples/experience-auditor/package.json +++ b/examples/experience-auditor/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.2", + "@contentful/app-sdk": "^4.59.0", "@contentful/f36-components": "4.81.1", "@contentful/f36-icons": "^4.28.0", "@contentful/f36-tokens": "4.2.0", diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index c02cf5bd7..ba1dab29f 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -180,7 +180,7 @@ describe('audit rules', () => { }); }); - it('flags an entry binding that fails to resolve via resolvedBindings', () => { + it('flags an entry binding with no entryId (structural check)', () => { const n: CollectedNode = { id: 'card', nodeType: 'Component', @@ -189,16 +189,15 @@ describe('audit rules', () => { key: 'featured', area: 'content', value: null, - binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, + binding: { type: 'entry', entryId: '', fieldId: 'featured' }, }, ], - resolvedBindings: { featured: { resolved: false } }, }; const finding = runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding'); expect(finding?.severity).toBe('error'); }); - it('does not flag a binding that resolves', () => { + it('does not flag an entry binding that references an entry', () => { const n: CollectedNode = { id: 'card', nodeType: 'Component', @@ -210,7 +209,6 @@ describe('audit rules', () => { binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, }, ], - resolvedBindings: { featured: { resolved: true } }, }; expect( runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding') diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts index b5f55cd65..d0fd75835 100644 --- a/examples/experience-auditor/src/audit/collect.spec.ts +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -38,63 +38,4 @@ describe('collectNodes', () => { expect(collected).toHaveLength(1); expect(collected[0].id).toBe('ok'); }); - - it('populates resolvedBindings when the node resolves an entry binding', async () => { - const okNode = makeMockNode('card', 'Component', [ - { - key: 'featured', - area: 'content', - value: 'x', - binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, - }, - ]); - okNode.resolveEntryBinding = vi.fn().mockResolvedValue({ entryId: 'e1' }); - const experience: any = { getRootNodes: vi.fn().mockReturnValue([okNode]) }; - const [collected] = await collectNodes(experience); - expect(collected.resolvedBindings).toEqual({ - featured: { resolved: true }, - }); - }); - - it('marks a binding unresolved when resolveEntryBinding returns null', async () => { - const brokenNode = makeMockNode('card', 'Component', [ - { - key: 'featured', - area: 'content', - value: null, - binding: { type: 'entry', entryId: 'gone', fieldId: 'featured' }, - }, - ]); - brokenNode.resolveEntryBinding = vi.fn().mockResolvedValue(null); - const experience: any = { getRootNodes: vi.fn().mockReturnValue([brokenNode]) }; - const [collected] = await collectNodes(experience); - expect(collected.resolvedBindings).toEqual({ - featured: { resolved: false }, - }); - }); - - it('omits resolvedBindings when no property is entry-bound', async () => { - const node = makeMockNode('plain', 'Component', [ - { key: 'body', area: 'content', value: 'hi' }, - ]); - const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; - const [collected] = await collectNodes(experience); - expect(collected.resolvedBindings).toBeUndefined(); - }); - - it('omits resolvedBindings when the host does not implement resolveEntryBinding', async () => { - const node = makeMockNode('card', 'Component', [ - { - key: 'featured', - area: 'content', - value: 'x', - binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, - }, - ]); - // Simulate a partial host bridge that has not shipped resolveEntryBinding yet. - delete (node as { resolveEntryBinding?: unknown }).resolveEntryBinding; - const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; - const [collected] = await collectNodes(experience); - expect(collected.resolvedBindings).toBeUndefined(); - }); }); diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts index ff602c110..f2e727203 100644 --- a/examples/experience-auditor/src/audit/collect.ts +++ b/examples/experience-auditor/src/audit/collect.ts @@ -1,5 +1,5 @@ -import type { ComponentPropertyDescriptor, ExoNodeAPI, ExperienceAPI } from '@contentful/app-sdk'; -import type { CollectedNode, ResolvedBinding } from './types'; +import type { ExperienceAPI } from '@contentful/app-sdk'; +import type { CollectedNode } from './types'; /** * Walks the experience tree and resolves each node's properties into the @@ -17,12 +17,10 @@ export async function collectNodes(experience: ExperienceAPI): Promise { try { const properties = await node.getProperties(); - const resolvedBindings = await resolveBindings(node, properties); return { id: node.id, nodeType: node.nodeType, properties, - ...(resolvedBindings ? { resolvedBindings } : {}), } satisfies CollectedNode; } catch { // A node may have been removed mid-traversal; skip it rather than @@ -34,26 +32,3 @@ export async function collectNodes(experience: ExperienceAPI): Promise node !== null); } - -/** - * Resolves each entry-bound property via the host's `resolveEntryBinding`, - * capturing whether the reference actually resolves. Returns `undefined` when - * the host does not back resolution (older/partial bridge) or the node has no - * entry-bound properties, so the binding rule falls back to its structural check. - * SDK coupling lives here, not in the rules. - */ -async function resolveBindings( - node: ExoNodeAPI, - properties: ComponentPropertyDescriptor[] -): Promise | undefined> { - if (typeof node.resolveEntryBinding !== 'function') return undefined; - - const entries: Array<[string, ResolvedBinding]> = []; - for (const property of properties) { - if (property.binding?.type !== 'entry') continue; - const target = await node.resolveEntryBinding(property.key); - entries.push([property.key, { resolved: target !== null }]); - } - - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index e226cdc59..b4559234f 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -185,25 +185,21 @@ const seoMetaRule: AuditRule = { }; /** - * Content properties bound to an entry must actually resolve. When the collector - * recorded a real resolution (`resolvedBindings`), prefer it: a binding is broken - * iff it is an entry binding that did not resolve. Where no resolution is present - * (host without `resolveEntryBinding`), fall back to the structural check — an - * entry source with no recorded `entryId` is a broken reference. + * Content properties bound to an entry must reference an entry. The audit is + * structural: an entry binding with no `entryId` is a broken reference. (The + * app-sdk surface exposes no host call to resolve a binding to a live entry, so + * the auditor cannot verify the target exists — only that one is referenced.) */ const brokenBindingRule: AuditRule = { id: 'content/broken-binding', - description: 'Entry bindings should resolve to an entry.', + description: 'Entry bindings should reference an entry.', evaluate(node) { const findings: AuditFinding[] = []; for (const property of node.properties) { const binding = property.binding; if (!binding || binding.type !== 'entry') continue; - const resolution = node.resolvedBindings?.[property.key]; - const broken = resolution ? !resolution.resolved : !binding.entryId; - - if (broken) { + if (!binding.entryId) { findings.push( makeFinding(brokenBindingRule, node, { propertyKey: property.key, diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts index da800c8c0..7be5c1010 100644 --- a/examples/experience-auditor/src/audit/types.ts +++ b/examples/experience-auditor/src/audit/types.ts @@ -11,18 +11,6 @@ export interface CollectedNode { id: string; nodeType: ExoNodeType; properties: ComponentPropertyDescriptor[]; - /** - * Per-property-key binding resolution, populated by the collector when the - * host backs `resolveEntryBinding`. Absent when the host does not support it - * (the binding rule then falls back to a structural check). - */ - resolvedBindings?: Record; -} - -/** The resolution result for an entry-bound property, captured at collect time. */ -export interface ResolvedBinding { - /** True when `resolveEntryBinding` returned a target; false when it returned null. */ - resolved: boolean; } /** diff --git a/examples/experience-auditor/src/components/FindingList.spec.tsx b/examples/experience-auditor/src/components/FindingList.spec.tsx index 7ee430015..6214db85c 100644 --- a/examples/experience-auditor/src/components/FindingList.spec.tsx +++ b/examples/experience-auditor/src/components/FindingList.spec.tsx @@ -24,23 +24,20 @@ function suggestedFinding(suggestedValue: string): AuditFinding { }; } -describe('FindingList suggested-fix seeding', () => { - it('re-seeds the suggested value when the suggestion changes for the same finding id', () => { +describe('FindingList suggested-fix display', () => { + it('renders the derived suggestion as read-only advice', () => { const props = { canLocate: false, - canFix: true, onLocate: vi.fn(), - onApplyDeterministic: vi.fn(), - onApplySuggested: vi.fn(), - busyFindingId: null, }; - const { getByLabelText, rerender } = render( + const { getByTestId, rerender } = render( ); - expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale'); + expect(getByTestId('suggested-value')).toHaveTextContent('Spring Sale'); - // Same finding.id, new derived suggestion (heading changed, meta still empty). + // A re-derived suggestion (heading changed, meta still empty) updates the + // displayed value. rerender(); - expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale 2026'); + expect(getByTestId('suggested-value')).toHaveTextContent('Spring Sale 2026'); }); }); diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx index e1adfdfab..7b42a049c 100644 --- a/examples/experience-auditor/src/components/FindingList.tsx +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -8,11 +8,7 @@ import EmptyState from './EmptyState'; interface FindingListProps { findings: AuditFinding[]; canLocate: boolean; - canFix: boolean; onLocate: (finding: AuditFinding) => void; - onApplyDeterministic: (finding: AuditFinding) => void; - onApplySuggested: (finding: AuditFinding, value: string) => void; - busyFindingId: string | null; } const SEVERITY_VARIANT: Record = { @@ -28,15 +24,7 @@ const SEVERITY_LABEL: Record = { info: 'Info', }; -const FindingList = ({ - findings, - canLocate, - canFix, - onLocate, - onApplyDeterministic, - onApplySuggested, - busyFindingId, -}: FindingListProps) => { +const FindingList = ({ findings, canLocate, onLocate }: FindingListProps) => { if (findings.length === 0) return ; return ( @@ -70,21 +58,20 @@ const FindingList = ({ {finding.detail} {finding.fix?.kind === 'suggested' && ( - // Key on the suggested value so a *changed* suggestion (same - // finding.id, new derived value) remounts SuggestedFix and - // re-seeds its state; an unchanged suggestion keeps the same - // key, preserving the author's in-progress edit across a - // no-op re-audit. React's "reset state with a key" idiom — - // preferred over a useEffect-sync anti-pattern. onApplySuggested(finding, value)} /> )} + {finding.fix?.kind === 'deterministic' && ( + + 💡 {finding.fix.label}: {String(finding.fix.value)} + + )} - {finding.fix?.kind === 'deterministic' && ( - - )} diff --git a/examples/experience-auditor/src/components/SuggestedFix.tsx b/examples/experience-auditor/src/components/SuggestedFix.tsx index 97fc16d4b..101b62d5f 100644 --- a/examples/experience-auditor/src/components/SuggestedFix.tsx +++ b/examples/experience-auditor/src/components/SuggestedFix.tsx @@ -1,35 +1,22 @@ -import React, { useState } from 'react'; -import { Box, Button, Flex, Text, TextInput } from '@contentful/f36-components'; +import React from 'react'; +import { Box, Text } from '@contentful/f36-components'; import tokens from '@contentful/f36-tokens'; interface SuggestedFixProps { - /** The proposed value, pre-filled and editable. */ + /** The proposed value. */ suggestedValue: string; - /** Human-readable provenance shown above the field. */ + /** Human-readable provenance shown above the value. */ source: string; - /** Whether the current user may write (mirrors the deterministic Fix gate). */ - canApply: boolean; - /** True while the write is in flight. */ - isApplying: boolean; - /** Apply the (possibly edited) value. */ - onApply: (value: string) => void; } /** - * The confirm-step for a `suggested` fix. Unlike a deterministic fix (applied on - * click), a suggested value is shown in an editable field so the author reviews - * and adjusts it before it is written. This keeps the app from silently writing - * an opinionated value. + * Read-only advice for a `suggested` finding. The app-sdk surface exposes no + * host call to write a node's content properties, so the auditor cannot apply + * the value — it surfaces the derived suggestion for the author to copy across + * manually. Provenance (`source`) is shown so the author can judge the + * suggestion before using it. */ -const SuggestedFix = ({ - suggestedValue, - source, - canApply, - isApplying, - onApply, -}: SuggestedFixProps) => { - const [value, setValue] = useState(suggestedValue); - +const SuggestedFix = ({ suggestedValue, source }: SuggestedFixProps) => { return ( 💡 Suggested from {source} - - setValue(e.target.value)} - isDisabled={!canApply || isApplying} - aria-label="Suggested value" - /> - - - - Editable before write · confirms via setContentProperty + notifier + + {suggestedValue} ); diff --git a/examples/experience-auditor/src/demo/mockExo.ts b/examples/experience-auditor/src/demo/mockExo.ts index 2790f1ac6..38854a244 100644 --- a/examples/experience-auditor/src/demo/mockExo.ts +++ b/examples/experience-auditor/src/demo/mockExo.ts @@ -14,20 +14,12 @@ interface DemoProperty { } function makeNode(id: string, initialProps: DemoProperty[]) { - let props = initialProps; return { id, nodeType: 'Component' as const, get: () => ({ id, nodeType: 'Component' as const }), onChange: () => () => {}, - getProperties: async () => props, - getContentProperty: async () => undefined, - setContentProperty: async (key: string, value: unknown) => { - // Mutate in place so a re-audit reflects the applied fix. - props = props.map((p) => (p.key === key ? { ...p, value } : p)); - }, - onContentPropertyChanged: () => () => {}, - resolveEntryBinding: async () => ({ entryId: 'demo-entry' }), + getProperties: async () => initialProps, }; } diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index fa3f4b75c..0b17550c6 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -3,7 +3,7 @@ import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ExperienceToolbar from './ExperienceToolbar'; -import { mockSdk, defaultNodes } from '../../test/mocks'; +import { mockSdk, defaultNodes, makeMockNode } from '../../test/mocks'; vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -67,72 +67,43 @@ describe('ExperienceToolbar (Experience Auditor)', () => { expect(locateButton).toBeDisabled(); }); - it('applies a one-click fix via setContentProperty and re-audits', async () => { - const user = userEvent.setup(); + it('surfaces a deterministic fix as read-only advice (no write path on 4.59)', async () => { // A node whose alt text has stray whitespace yields a finding with a - // "Trim whitespace" fix. setContentProperty trims it and clears the finding. - const setContentProperty = vi.fn().mockResolvedValue(undefined); - let altValue = ' spaced alt '; - const fixNode = { - id: 'hero', - nodeType: 'Component', - onChange: vi.fn().mockReturnValue(vi.fn()), - getProperties: vi.fn().mockImplementation(() => - Promise.resolve([ - { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, - { key: 'altText', area: 'content', value: altValue }, - ]) - ), - setContentProperty: vi.fn().mockImplementation(async (key: string, value: string) => { - if (key === 'altText') altValue = value; - return setContentProperty(key, value); - }), - }; + // "Trim whitespace" fix. The 4.59 surface has no setContentProperty, so the + // fix is advisory: the trimmed value is shown, not applied. + const fixNode = makeMockNode('hero', 'Component', [ + { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, + { key: 'altText', area: 'content', value: ' spaced alt ' }, + ]); mockSdk.exo.experience.getRootNodes.mockReturnValue([fixNode]); mockSdk.exo.experience.getNode.mockReturnValue(fixNode); - const { getAllByTestId, queryByText } = render(); + const { getAllByTestId, getByTestId } = render(); await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); - const fixButton = within(getAllByTestId('finding')[0]).getByText('Trim whitespace'); - await user.click(fixButton); - expect(setContentProperty).toHaveBeenCalledWith('altText', 'spaced alt'); - await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.')); - // Re-audit ran on the trimmed value: the whitespace finding is gone. - await waitFor(() => expect(queryByText('Trim whitespace')).toBeNull()); + // The advice shows the trimmed value; there is no Apply/Fix button to click. + const advice = getByTestId('deterministic-advice'); + expect(advice).toHaveTextContent('Trim whitespace'); + expect(advice).toHaveTextContent('spaced alt'); + expect(mockSdk.notifier.success).not.toHaveBeenCalled(); }); - it('applies a suggested fix with the edited value via setContentProperty', async () => { - const user = userEvent.setup(); - const setContentProperty = vi.fn().mockResolvedValue(undefined); - const metaNode = { - id: 'page', - nodeType: 'Component', - onChange: vi.fn().mockReturnValue(vi.fn()), - resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'e' }), - getProperties: vi.fn().mockResolvedValue([ - { key: 'heading', area: 'content', value: 'Spring Sale' }, - { key: 'metaTitle', area: 'content', value: '' }, - ]), - setContentProperty, - }; + it('surfaces a suggested fix as a read-only value (no editable apply)', async () => { + const metaNode = makeMockNode('page', 'Component', [ + { key: 'heading', area: 'content', value: 'Spring Sale' }, + { key: 'metaTitle', area: 'content', value: '' }, + ]); mockSdk.exo.experience.getRootNodes.mockReturnValue([metaNode]); mockSdk.exo.experience.getNode.mockReturnValue(metaNode); - const { getByTestId } = render(); + const { getByTestId, queryByText } = render(); await waitFor(() => expect(getByTestId('suggested-fix')).toBeInTheDocument()); - const input = within(getByTestId('suggested-fix')).getByLabelText( - 'Suggested value' - ) as HTMLInputElement; - expect(input.value).toBe('Spring Sale'); - await user.clear(input); - await user.type(input, 'Spring Sale 2026'); - await user.click(within(getByTestId('suggested-fix')).getByText('Apply')); - - expect(setContentProperty).toHaveBeenCalledWith('metaTitle', 'Spring Sale 2026'); - await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.')); + // The derived suggestion is shown read-only; no editable input, no Apply. + expect(getByTestId('suggested-value')).toHaveTextContent('Spring Sale'); + expect(queryByText('Apply')).toBeNull(); + expect(mockSdk.notifier.success).not.toHaveBeenCalled(); }); it('renders Locate as disabled when selection is unsupported', async () => { diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index ce4c21cf7..081a1218d 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -26,9 +26,11 @@ import FindingList from '../components/FindingList'; * On mount (and whenever the experience changes) it walks the experience tree * with `sdk.exo.experience`, runs a set of pure audit rules, and renders a * scored list of findings. Each finding can be located on the canvas - * (`selection.set` + `selection.highlight`) and, where a safe deterministic fix - * exists, repaired in place (`getNode().setContentProperty`). Publishing is - * gated on there being no outstanding errors. + * (`selection.set` + `selection.highlight`). Where a finding has a derivable + * fix, the suggested value is surfaced as read-only advice — the app-sdk + * surface exposes no host call to write a node's content properties, so fixes + * are advisory rather than one-click. Publishing is gated on there being no + * outstanding errors. */ const ExperienceToolbar = () => { const sdk = useSDK(); @@ -37,8 +39,6 @@ const ExperienceToolbar = () => { const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); const [report, setReport] = useState(null); const [auditing, setAuditing] = useState(true); - const [canFix, setCanFix] = useState(false); - const [busyFindingId, setBusyFindingId] = useState(null); const [publishing, setPublishing] = useState(false); const [capabilities, setCapabilities] = useState(() => detectCapabilities(sdk.exo)); useEffect(() => setCapabilities(detectCapabilities(sdk.exo)), [sdk]); @@ -66,22 +66,6 @@ const ExperienceToolbar = () => { useEffect(() => sdk.exo.onContextChanged(setContext), [sdk]); useEffect(() => sdk.exo.onUiModeChanged(setUiMode), [sdk]); - // Resolve write permission once for UX gating (the host still enforces). - useEffect(() => { - let active = true; - sdk.access - .can('update', 'Entry') - .then((allowed) => { - if (active) setCanFix(allowed); - }) - .catch(() => { - if (active) setCanFix(false); - }); - return () => { - active = false; - }; - }, [sdk]); - // Initial audit + re-audit whenever the experience changes. // Simplification for the example: every onChange triggers a full traversal. // A production app editing rapidly would debounce this (e.g. trailing 300ms) @@ -105,46 +89,6 @@ const ExperienceToolbar = () => { [sdk] ); - const applyWrite = useCallback( - async (finding: AuditFinding, propertyKey: string, value: unknown) => { - setBusyFindingId(finding.id); - try { - const node = sdk.exo.experience.getNode(finding.nodeId); - if (!node) { - sdk.notifier.error('That component no longer exists.'); - return; - } - await node.setContentProperty(propertyKey, value); - sdk.notifier.success('Fix applied.'); - // Re-audit explicitly rather than relying on the setContentProperty - // write to round-trip back through onChange — keeps the panel in sync - // even if the host does not emit a change event for this write. - await audit(); - } catch { - sdk.notifier.error('Could not apply the fix. Please try again.'); - } finally { - setBusyFindingId(null); - } - }, - [sdk, audit] - ); - - const handleApplyDeterministic = useCallback( - (finding: AuditFinding) => { - if (finding.fix?.kind !== 'deterministic') return; - void applyWrite(finding, finding.fix.propertyKey, finding.fix.value); - }, - [applyWrite] - ); - - const handleApplySuggested = useCallback( - (finding: AuditFinding, value: string) => { - if (finding.fix?.kind !== 'suggested') return; - void applyWrite(finding, finding.fix.propertyKey, value); - }, - [applyWrite] - ); - const handlePublish = useCallback(async () => { if (!report || hasBlockingErrors(report)) return; @@ -202,15 +146,7 @@ const ExperienceToolbar = () => { )} {report && ( - + )} diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts index 5ac3b09bf..d7d402e63 100644 --- a/examples/experience-auditor/test/mocks/mockSdk.ts +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -15,10 +15,6 @@ export function makeMockNode( get: vi.fn().mockReturnValue({ id, nodeType }), onChange: vi.fn().mockReturnValue(noopUnsubscribe), getProperties: vi.fn().mockResolvedValue(properties), - resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'mock-entry' }), - getContentProperty: vi.fn().mockResolvedValue(undefined), - setContentProperty: vi.fn().mockResolvedValue(undefined), - onContentPropertyChanged: vi.fn().mockReturnValue(noopUnsubscribe), }; } From d149a5e8dc448b6c1118676896ef2250ec130235 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Wed, 24 Jun 2026 18:45:06 -0600 Subject: [PATCH 21/25] fix: adopt renamed Experience SDK surface in experience-auditor example [EXT-7497] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floor bump ^4.60.0; Exo*→Experience* + sdk.exo→sdk.experiences; mockExo.ts→mockExperiences.ts. --- examples/experience-auditor/README.md | 16 ++++----- examples/experience-auditor/package.json | 2 +- .../src/audit/capabilities.spec.ts | 12 +++---- .../src/audit/capabilities.ts | 8 ++--- .../experience-auditor/src/audit/types.ts | 10 +++--- .../src/demo/DemoProvider.tsx | 4 +-- .../demo/{mockExo.ts => mockExperiences.ts} | 4 +-- examples/experience-auditor/src/index.tsx | 2 +- .../src/locations/ExperienceToolbar.spec.tsx | 35 ++++++++++--------- .../src/locations/ExperienceToolbar.tsx | 30 ++++++++-------- .../experience-auditor/test/mocks/mockSdk.ts | 4 +-- 11 files changed, 66 insertions(+), 61 deletions(-) rename examples/experience-auditor/src/demo/{mockExo.ts => mockExperiences.ts} (95%) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index 81945631e..dbb251673 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -12,14 +12,14 @@ experience tree as the author works.** > Looking for the bare-bones starter instead? See the > [`experience-toolbar`](../experience-toolbar) example, which demonstrates the -> minimal `sdk.exo` patterns. Experience Auditor builds on those to show a +> minimal `sdk.experiences` patterns. Experience Auditor builds on those to show a > complete, opinionated app. ## What it does - **Live audit** — walks the experience tree with `getRootNodes()` → `getProperties()`, runs a set of pure rules, and re-runs automatically on - `sdk.exo.experience.onChange()`. + `sdk.experiences.experience.onChange()`. - **Scored dashboard** — a 0–100 health score with error / warning / info counts. - **Locate on canvas** — clicking **Locate** calls @@ -73,7 +73,7 @@ each kind to the right UI and the rules stay declarative about what they offer. ### Capability-aware behavior -Not every `sdk.exo` surface is backed by every host, and surfaces can roll out +Not every `sdk.experiences` surface is backed by every host, and surfaces can roll out incrementally. Rather than call a method and catch the failure, the app probes up front which surfaces are available ([`src/audit/capabilities.ts`](src/audit/capabilities.ts)) and adapts its UI to @@ -94,8 +94,8 @@ src/ rules.ts Pure audit rules (per-node + the cross-node heading-order rule) engine.ts Runs rules, aggregates findings, computes the score fixes.ts Pure derivation of suggested fix values (e.g. meta from heading) - capabilities.ts Probes which optional sdk.exo surfaces the host backs - collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[] + capabilities.ts Probes which optional sdk.experiences surfaces the host backs + collect.ts The only SDK-coupled piece: walks sdk.experiences.experience → CollectedNode[] components/ ScoreSummary.tsx FindingList.tsx Groups findings by severity; renders locate + fix affordances @@ -103,7 +103,7 @@ src/ EmptyState.tsx demo/ DemoProvider.tsx Dev-only: renders the toolbar against the seeded mock (?demo) - mockExo.ts Seeded in-memory sdk.exo for the demo + mockExperiences.ts Seeded in-memory sdk.experiences for the demo locations/ ConfigScreen.tsx ExperienceToolbar.tsx Wires the SDK to the engine (collect → audit → locate/fix/publish) @@ -170,7 +170,7 @@ pointing the app at `http://localhost:3000`. This app is built against the published `@contentful/app-sdk@4.58.0` types, which are the contract for the toolbar location. The host renderer that serves -`sdk.exo` at runtime is still rolling out, so the app is **type-verified and +`sdk.experiences` at runtime is still rolling out, so the app is **type-verified and unit-tested against a mocked SDK** — 40 tests cover the audit rules, scoring, the collector and its binding resolution, capability detection, the suggested- fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is @@ -188,4 +188,4 @@ match the published types exactly. ## Libraries - [Forma 36](https://f36.contentful.com/) — Contentful's design system -- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) — the `sdk.exo` reference +- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) — the `sdk.experiences` reference diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json index 8301ff522..bb69e3f41 100644 --- a/examples/experience-auditor/package.json +++ b/examples/experience-auditor/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "^4.59.0", + "@contentful/app-sdk": "^4.60.0", "@contentful/f36-components": "4.81.1", "@contentful/f36-icons": "^4.28.0", "@contentful/f36-tokens": "4.2.0", diff --git a/examples/experience-auditor/src/audit/capabilities.spec.ts b/examples/experience-auditor/src/audit/capabilities.spec.ts index 32a673dc3..dffa02143 100644 --- a/examples/experience-auditor/src/audit/capabilities.spec.ts +++ b/examples/experience-auditor/src/audit/capabilities.spec.ts @@ -3,19 +3,19 @@ import { detectCapabilities } from './capabilities'; describe('detectCapabilities', () => { it('reports selection supported when the experience exposes a selection API', () => { - const exo: any = { + const experiences: any = { experience: { selection: { set: () => {}, highlight: () => {} } }, }; - expect(detectCapabilities(exo)).toEqual({ selection: true }); + expect(detectCapabilities(experiences)).toEqual({ selection: true }); }); it('reports selection unsupported when selection is absent', () => { - const exo: any = { experience: {} }; - expect(detectCapabilities(exo)).toEqual({ selection: false }); + const experiences: any = { experience: {} }; + expect(detectCapabilities(experiences)).toEqual({ selection: false }); }); it('reports selection unsupported when set/highlight are not functions', () => { - const exo: any = { experience: { selection: { set: null, highlight: undefined } } }; - expect(detectCapabilities(exo)).toEqual({ selection: false }); + const experiences: any = { experience: { selection: { set: null, highlight: undefined } } }; + expect(detectCapabilities(experiences)).toEqual({ selection: false }); }); }); diff --git a/examples/experience-auditor/src/audit/capabilities.ts b/examples/experience-auditor/src/audit/capabilities.ts index 60f69bb94..5683e8051 100644 --- a/examples/experience-auditor/src/audit/capabilities.ts +++ b/examples/experience-auditor/src/audit/capabilities.ts @@ -1,8 +1,8 @@ -import type { ExoSDK } from '@contentful/app-sdk'; +import type { ExperienceSDK } from '@contentful/app-sdk'; import type { Capabilities } from './types'; /** - * Probes which optional host surfaces the live `sdk.exo` actually backs. + * Probes which optional host surfaces the live `sdk.experiences` actually backs. * * The toolbar host bridge intentionally degrades some surfaces (notably * selection) to "not supported" until later host work lands. Rather than call @@ -10,8 +10,8 @@ import type { Capabilities } from './types'; * an informative, disabled affordance instead. This is the pattern a * well-behaved app uses when a host capability is still rolling out. */ -export function detectCapabilities(exo: ExoSDK): Capabilities { - const selection = exo.experience?.selection; +export function detectCapabilities(experiences: ExperienceSDK): Capabilities { + const selection = experiences.experience?.selection; return { selection: typeof selection?.set === 'function' && typeof selection?.highlight === 'function', }; diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts index 7be5c1010..08125e298 100644 --- a/examples/experience-auditor/src/audit/types.ts +++ b/examples/experience-auditor/src/audit/types.ts @@ -1,15 +1,15 @@ -import type { ComponentPropertyDescriptor, ExoNodeType } from '@contentful/app-sdk'; +import type { ComponentPropertyDescriptor, ExperienceNodeType } from '@contentful/app-sdk'; export type Severity = 'error' | 'warning' | 'info'; /** * A node collected from the experience tree, paired with its resolved * properties. This is the SDK-independent shape the audit rules operate on, so - * the rules can be unit-tested without a live `sdk.exo`. + * the rules can be unit-tested without a live `sdk.experiences`. */ export interface CollectedNode { id: string; - nodeType: ExoNodeType; + nodeType: ExperienceNodeType; properties: ComponentPropertyDescriptor[]; } @@ -39,7 +39,7 @@ export interface AuditFinding { id: string; ruleId: string; nodeId: string; - nodeType: ExoNodeType; + nodeType: ExperienceNodeType; propertyKey?: string; severity: Severity; title: string; @@ -62,7 +62,7 @@ export interface AuditReport { nodeCount: number; } -/** Which optional host surfaces are backed by the live `sdk.exo`. */ +/** Which optional host surfaces are backed by the live `sdk.experiences`. */ export interface Capabilities { /** Selection/highlight (Locate-on-canvas). Not backed on the experience route yet. */ selection: boolean; diff --git a/examples/experience-auditor/src/demo/DemoProvider.tsx b/examples/experience-auditor/src/demo/DemoProvider.tsx index 332697b7c..ebff0b58c 100644 --- a/examples/experience-auditor/src/demo/DemoProvider.tsx +++ b/examples/experience-auditor/src/demo/DemoProvider.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { SDKContext } from '@contentful/react-apps-toolkit'; import { GlobalStyles } from '@contentful/f36-components'; -import { demoSdk } from './mockExo'; +import { demoSdk } from './mockExperiences'; import ExperienceToolbar from '../locations/ExperienceToolbar'; /** - * DEMO ONLY — renders the toolbar against a seeded in-memory `sdk.exo` so the + * DEMO ONLY — renders the toolbar against a seeded in-memory `sdk.experiences` so the * audit -> suggested-fix -> re-score loop is clickable via `npm start` before * the host renderer is broadly available. Never used on the real app path. * diff --git a/examples/experience-auditor/src/demo/mockExo.ts b/examples/experience-auditor/src/demo/mockExperiences.ts similarity index 95% rename from examples/experience-auditor/src/demo/mockExo.ts rename to examples/experience-auditor/src/demo/mockExperiences.ts index 38854a244..2f59e9237 100644 --- a/examples/experience-auditor/src/demo/mockExo.ts +++ b/examples/experience-auditor/src/demo/mockExperiences.ts @@ -41,7 +41,7 @@ const nodes = [ ]; /** - * DEMO ONLY — a minimal seeded `sdk.exo`. `experience.selection` is deliberately + * DEMO ONLY — a minimal seeded `sdk.experiences`. `experience.selection` is deliberately * omitted so the demo exercises the graceful "Locate not available" degradation * (`detectCapabilities` reports `selection: false` and the button renders * disabled). @@ -55,7 +55,7 @@ export const demoSdk = { error: (m: string) => console.warn('[demo notifier]', m), warning: (m: string) => console.warn('[demo notifier]', m), }, - exo: { + experiences: { context: { type: 'experience' as const, entityId: 'demo-experience' }, onContextChanged: () => () => {}, getUiMode: () => 'visual' as const, diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx index 472705157..9156fae43 100644 --- a/examples/experience-auditor/src/index.tsx +++ b/examples/experience-auditor/src/index.tsx @@ -17,7 +17,7 @@ const demoMode = if (demoMode) { // Standalone demo: `npm start` + `?demo` renders the toolbar against a seeded - // in-memory sdk.exo. Dynamically imported so the demo scaffolding stays out of + // in-memory sdk.experiences. Dynamically imported so the demo scaffolding stays out of // the production bundle. void import('./demo/DemoProvider').then(({ default: DemoProvider }) => { root.render(); diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index 0b17550c6..69b620f50 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -12,16 +12,16 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ describe('ExperienceToolbar (Experience Auditor)', () => { beforeEach(() => { vi.clearAllMocks(); - mockSdk.exo.getUiMode.mockReturnValue('visual'); + mockSdk.experiences.getUiMode.mockReturnValue('visual'); mockSdk.access.can.mockResolvedValue(true); - mockSdk.exo.experience.selection = { + mockSdk.experiences.experience.selection = { get: vi.fn().mockReturnValue({ nodeId: null }), onChange: vi.fn().mockReturnValue(vi.fn()), set: vi.fn(), highlight: vi.fn(), }; - mockSdk.exo.experience.getRootNodes.mockReturnValue(defaultNodes); - mockSdk.exo.experience.getNode.mockImplementation( + mockSdk.experiences.experience.getRootNodes.mockReturnValue(defaultNodes); + mockSdk.experiences.experience.getNode.mockImplementation( (id: string) => defaultNodes.find((n) => n.id === id) ?? null ); }); @@ -38,7 +38,7 @@ describe('ExperienceToolbar (Experience Auditor)', () => { const { getByTestId } = render(); await waitFor(() => expect(getByTestId('publish-blocked')).toBeInTheDocument()); - expect(mockSdk.exo.experience.publish).not.toHaveBeenCalled(); + expect(mockSdk.experiences.experience.publish).not.toHaveBeenCalled(); }); it('locates a finding via selection + highlight', async () => { @@ -50,15 +50,18 @@ describe('ExperienceToolbar (Experience Auditor)', () => { const firstFinding = getAllByTestId('finding')[0]; await user.click(within(firstFinding).getByText('Locate')); - expect(mockSdk.exo.experience.selection.set).toHaveBeenCalledOnce(); - expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith(expect.any(String), { - flash: true, - scrollIntoView: true, - }); + expect(mockSdk.experiences.experience.selection.set).toHaveBeenCalledOnce(); + expect(mockSdk.experiences.experience.selection.highlight).toHaveBeenCalledWith( + expect.any(String), + { + flash: true, + scrollIntoView: true, + } + ); }); it('disables locate in form mode', async () => { - mockSdk.exo.getUiMode.mockReturnValue('form'); + mockSdk.experiences.getUiMode.mockReturnValue('form'); const { getAllByTestId } = render(); await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); @@ -75,8 +78,8 @@ describe('ExperienceToolbar (Experience Auditor)', () => { { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, { key: 'altText', area: 'content', value: ' spaced alt ' }, ]); - mockSdk.exo.experience.getRootNodes.mockReturnValue([fixNode]); - mockSdk.exo.experience.getNode.mockReturnValue(fixNode); + mockSdk.experiences.experience.getRootNodes.mockReturnValue([fixNode]); + mockSdk.experiences.experience.getNode.mockReturnValue(fixNode); const { getAllByTestId, getByTestId } = render(); @@ -94,8 +97,8 @@ describe('ExperienceToolbar (Experience Auditor)', () => { { key: 'heading', area: 'content', value: 'Spring Sale' }, { key: 'metaTitle', area: 'content', value: '' }, ]); - mockSdk.exo.experience.getRootNodes.mockReturnValue([metaNode]); - mockSdk.exo.experience.getNode.mockReturnValue(metaNode); + mockSdk.experiences.experience.getRootNodes.mockReturnValue([metaNode]); + mockSdk.experiences.experience.getNode.mockReturnValue(metaNode); const { getByTestId, queryByText } = render(); await waitFor(() => expect(getByTestId('suggested-fix')).toBeInTheDocument()); @@ -107,7 +110,7 @@ describe('ExperienceToolbar (Experience Auditor)', () => { }); it('renders Locate as disabled when selection is unsupported', async () => { - mockSdk.exo.experience.selection = undefined; + mockSdk.experiences.experience.selection = undefined; const { getAllByTestId } = render(); await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); const locate = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button'); diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index 081a1218d..55c49002f 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { ExoContext, ExperienceEditorToolbarAppSDK, UiMode } from '@contentful/app-sdk'; +import type { ExperienceContext, ExperienceEditorToolbarAppSDK, UiMode } from '@contentful/app-sdk'; import { Badge, Box, @@ -21,10 +21,10 @@ import ScoreSummary from '../components/ScoreSummary'; import FindingList from '../components/FindingList'; /** - * Experience Auditor — a selection-aware ExO toolbar app. + * Experience Auditor — a selection-aware Experience toolbar app. * * On mount (and whenever the experience changes) it walks the experience tree - * with `sdk.exo.experience`, runs a set of pure audit rules, and renders a + * with `sdk.experiences.experience`, runs a set of pure audit rules, and renders a * scored list of findings. Each finding can be located on the canvas * (`selection.set` + `selection.highlight`). Where a finding has a derivable * fix, the suggested value is surfaced as read-only advice — the app-sdk @@ -35,13 +35,15 @@ import FindingList from '../components/FindingList'; const ExperienceToolbar = () => { const sdk = useSDK(); - const [context, setContext] = useState(() => sdk.exo.context); - const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); + const [context, setContext] = useState(() => sdk.experiences.context); + const [uiMode, setUiMode] = useState(() => sdk.experiences.getUiMode()); const [report, setReport] = useState(null); const [auditing, setAuditing] = useState(true); const [publishing, setPublishing] = useState(false); - const [capabilities, setCapabilities] = useState(() => detectCapabilities(sdk.exo)); - useEffect(() => setCapabilities(detectCapabilities(sdk.exo)), [sdk]); + const [capabilities, setCapabilities] = useState(() => + detectCapabilities(sdk.experiences) + ); + useEffect(() => setCapabilities(detectCapabilities(sdk.experiences)), [sdk]); // Guard against state updates after unmount / stale async audits. const runIdRef = useRef(0); @@ -50,7 +52,7 @@ const ExperienceToolbar = () => { const runId = ++runIdRef.current; setAuditing(true); try { - const nodes = await collectNodes(sdk.exo.experience); + const nodes = await collectNodes(sdk.experiences.experience); const next = runAudit(nodes); if (runId === runIdRef.current) { setReport(next); @@ -63,8 +65,8 @@ const ExperienceToolbar = () => { }, [sdk]); // Keep context and ui mode in sync. - useEffect(() => sdk.exo.onContextChanged(setContext), [sdk]); - useEffect(() => sdk.exo.onUiModeChanged(setUiMode), [sdk]); + useEffect(() => sdk.experiences.onContextChanged(setContext), [sdk]); + useEffect(() => sdk.experiences.onUiModeChanged(setUiMode), [sdk]); // Initial audit + re-audit whenever the experience changes. // Simplification for the example: every onChange triggers a full traversal. @@ -72,16 +74,16 @@ const ExperienceToolbar = () => { // so a burst of edits collapses into a single re-audit instead of N+1 passes. useEffect(() => { void audit(); - return sdk.exo.experience.onChange(() => { + return sdk.experiences.experience.onChange(() => { void audit(); }); }, [sdk, audit]); const handleLocate = useCallback( (finding: AuditFinding) => { - sdk.exo.experience.selection.set(finding.nodeId); + sdk.experiences.experience.selection.set(finding.nodeId); // Highlight is a no-op in form mode; the button is disabled there anyway. - sdk.exo.experience.selection.highlight(finding.nodeId, { + sdk.experiences.experience.selection.highlight(finding.nodeId, { flash: true, scrollIntoView: true, }); @@ -99,7 +101,7 @@ const ExperienceToolbar = () => { sdk.notifier.error('You do not have permission to publish this experience.'); return; } - await sdk.exo.experience.publish(); + await sdk.experiences.experience.publish(); sdk.notifier.success('Experience published.'); } catch { sdk.notifier.error('Publish failed. Please try again.'); diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts index d7d402e63..2c1022034 100644 --- a/examples/experience-auditor/test/mocks/mockSdk.ts +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -3,7 +3,7 @@ import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; const noopUnsubscribe = vi.fn(); -/** Builds a mock ExoNodeAPI whose getProperties resolves the given descriptors. */ +/** Builds a mock ExperienceNodeAPI whose getProperties resolves the given descriptors. */ export function makeMockNode( id: string, nodeType: 'Component' | 'Fragment' | 'InlineFragment' | 'Slot', @@ -46,7 +46,7 @@ const mockSdk: any = { error: vi.fn(), warning: vi.fn(), }, - exo: { + experiences: { context: { type: 'experience', entityId: 'experience-123' }, onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe), getUiMode: vi.fn().mockReturnValue('visual'), From f40ec8baa9b8f0575c34347cdeb83d35b0e435a2 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Mon, 6 Jul 2026 10:35:29 -0600 Subject: [PATCH 22/25] docs: correct experience-auditor README to match advisory behavior [EXT-7476] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README described a write-capable "One-click fixes" feature (findings writing back via getNode().setContentProperty()), but the app has no such method and treats all fixes as read-only advice — the app-sdk surface exposes no content-property write. Rewrites the headline, feature list, and fixes section to match the code: fixes are surfaced as advice the author applies manually. Also corrects the app-sdk version (4.58.0 -> 4.60.0, where sdk.experiences first exists), drops customer-facing "ExO" phrasing, notes the getRootNodes() empty-stub caveat, and fixes the test count (40 -> 37). Co-Authored-By: Claude Opus 4.8 --- examples/experience-auditor/README.md | 66 +++++++++++++++------------ 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index dbb251673..b39ee367d 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -2,13 +2,13 @@ A polished, real-world example app for the **Experience Editor toolbar** — the `experience-toolbar` location introduced in -[`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk). +[`@contentful/app-sdk@4.60.0`](https://www.npmjs.com/package/@contentful/app-sdk). -Experience Auditor runs alongside the Experience Orchestration (ExO) editor and -continuously audits the experience you are editing for **accessibility, SEO, and +Experience Auditor runs alongside the Experience Editor and continuously audits +the experience you are editing for **accessibility, SEO, and content-completeness** issues. It demonstrates the standout capability of the -toolbar location: **live, selection-aware tooling that reads _and_ mutates the -experience tree as the author works.** +toolbar location: **live, selection-aware tooling that reads the experience tree +as the author works and surfaces actionable, advisory fixes.** > Looking for the bare-bones starter instead? See the > [`experience-toolbar`](../experience-toolbar) example, which demonstrates the @@ -26,9 +26,11 @@ experience tree as the author works.** `selection.set(nodeId)` + `selection.highlight(nodeId, { flash, scrollIntoView })` to jump straight to the offending component (visual mode only, and only where the host backs the selection surface — see _Capability-aware behavior_). -- **One-click fixes** — findings can carry a fix that writes back via - `getNode().setContentProperty()`, permission-checked with `sdk.access.can()` - and confirmed through `sdk.notifier`. Fixes come in two kinds (see below). +- **Advisory fixes** — findings can carry a derived fix that is surfaced as + read-only advice for the author to apply manually. The current app-sdk surface + exposes no host call to write a node's content properties, so the auditor + computes the suggestion but does not write it. Fixes come in two kinds (see + below). - **Pre-publish gate** — `experience.publish()` is blocked while any error-level finding remains. @@ -52,24 +54,26 @@ functions over a SDK-independent `CollectedNode` shape, so they are fully unit-tested without a live SDK. Adding a per-node rule is a matter of dropping another `AuditRule` into `AUDIT_RULES`. -### One-click fixes +### Advisory fixes -A finding can offer a fix, and there are two kinds — the distinction matters -because one is safe to apply blindly and the other is not: +A finding can offer a fix, and there are two kinds — the distinction shapes how +the suggestion is presented, since one is unambiguous and the other is a +judgement call: -- **Deterministic** — exactly one correct result, applied immediately on click. - Examples: trimming surrounding whitespace from alt text, or setting a skipped - heading to the level that keeps the outline sequential. There is nothing to - review, so the app just writes the value. -- **Suggested** — a proposed value the author reviews and edits _before_ it is - written. Example: deriving an SEO meta value from the component's heading. The - app pre-fills the suggestion with its provenance, and the author can accept it - as-is or change it. **A suggested value is never written silently** — the - write only happens after the author confirms. +- **Deterministic** — exactly one correct result. Examples: trimming surrounding + whitespace from alt text, or the level that keeps a skipped heading's outline + sequential. The app surfaces the corrected value directly as a hint. +- **Suggested** — a proposed value the author should review before using. + Example: deriving an SEO meta value from the component's heading. The app + shows the suggestion with its provenance so the author can judge it. -The fix shapes are modelled as a discriminated union in -[`src/audit/types.ts`](src/audit/types.ts) (`AutoFix`), so the toolbar can route -each kind to the right UI and the rules stay declarative about what they offer. +In both cases the value is **surfaced as read-only advice** — the current +app-sdk surface has no host call to write a node's content properties, so the +author applies the change manually in the editor. The fix shapes are modelled as +a discriminated union in [`src/audit/types.ts`](src/audit/types.ts) (`AutoFix`), +so the toolbar can route each kind to the right UI and the rules stay +declarative about what they offer. If a content-write surface lands later, this +is where a one-click apply path would attach. ### Capability-aware behavior @@ -168,15 +172,21 @@ pointing the app at `http://localhost:3000`. ## A note on verification -This app is built against the published `@contentful/app-sdk@4.58.0` types, +This app is built against the published `@contentful/app-sdk@4.60.0` types, which are the contract for the toolbar location. The host renderer that serves `sdk.experiences` at runtime is still rolling out, so the app is **type-verified and -unit-tested against a mocked SDK** — 40 tests cover the audit rules, scoring, +unit-tested against a mocked SDK** — 37 tests cover the audit rules, scoring, the collector and its binding resolution, capability detection, the suggested- fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is -not yet verified end-to-end inside a live ExO editor; that live verification is -tracked separately as the host renderer rolls out. The API shapes used here -match the published types exactly. +not yet verified end-to-end inside a live Experience Editor; that live +verification is tracked separately as the host renderer rolls out. The API +shapes used here match the published types exactly. + +> **Note on `getRootNodes()`.** The audit traversal starts from +> `sdk.experiences.experience.getRootNodes()`, which currently resolves to an +> empty list until the host wires up experience-tree sync. Against a live host +> today the audit will report no findings; the demo mode below exercises the full +> loop against a seeded in-memory experience. ## Available scripts From cff02e9941390d99aac8178e86d9fc192cdd11b3 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Wed, 8 Jul 2026 11:19:24 -0600 Subject: [PATCH 23/25] fix: drop UI mode usage from experience-auditor example [EXT-7476] app-sdk 4.63.1 removes the experience getUiMode()/onUiModeChanged() surface. Drop the mode state, the form-mode notice, and the mode condition on the Locate action, which now keys solely off the capability probe for the selection surface. Bump the app-sdk pin to ^4.63.1. Co-Authored-By: Claude Opus 4.8 --- examples/experience-auditor/README.md | 6 ++-- examples/experience-auditor/package.json | 2 +- .../src/demo/mockExperiences.ts | 2 -- .../src/locations/ExperienceToolbar.spec.tsx | 11 -------- .../src/locations/ExperienceToolbar.tsx | 28 +++---------------- .../experience-auditor/test/mocks/mockSdk.ts | 2 -- 6 files changed, 8 insertions(+), 43 deletions(-) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index b39ee367d..0b39e38b8 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -24,8 +24,8 @@ as the author works and surfaces actionable, advisory fixes.** counts. - **Locate on canvas** — clicking **Locate** calls `selection.set(nodeId)` + `selection.highlight(nodeId, { flash, scrollIntoView })` - to jump straight to the offending component (visual mode only, and only where - the host backs the selection surface — see _Capability-aware behavior_). + to jump straight to the offending component (only where the host backs the + selection surface — see _Capability-aware behavior_). - **Advisory fixes** — findings can carry a derived fix that is surfaced as read-only advice for the author to apply manually. The current app-sdk surface exposes no host call to write a node's content properties, so the auditor @@ -175,7 +175,7 @@ pointing the app at `http://localhost:3000`. This app is built against the published `@contentful/app-sdk@4.60.0` types, which are the contract for the toolbar location. The host renderer that serves `sdk.experiences` at runtime is still rolling out, so the app is **type-verified and -unit-tested against a mocked SDK** — 37 tests cover the audit rules, scoring, +unit-tested against a mocked SDK** — 36 tests cover the audit rules, scoring, the collector and its binding resolution, capability detection, the suggested- fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is not yet verified end-to-end inside a live Experience Editor; that live diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json index bb69e3f41..72a9d18db 100644 --- a/examples/experience-auditor/package.json +++ b/examples/experience-auditor/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "^4.60.0", + "@contentful/app-sdk": "^4.63.1", "@contentful/f36-components": "4.81.1", "@contentful/f36-icons": "^4.28.0", "@contentful/f36-tokens": "4.2.0", diff --git a/examples/experience-auditor/src/demo/mockExperiences.ts b/examples/experience-auditor/src/demo/mockExperiences.ts index 2f59e9237..c9466f57c 100644 --- a/examples/experience-auditor/src/demo/mockExperiences.ts +++ b/examples/experience-auditor/src/demo/mockExperiences.ts @@ -58,8 +58,6 @@ export const demoSdk = { experiences: { context: { type: 'experience' as const, entityId: 'demo-experience' }, onContextChanged: () => () => {}, - getUiMode: () => 'visual' as const, - onUiModeChanged: () => () => {}, experience: { get: () => ({ sys: { id: 'demo-experience' } }), onChange: () => () => {}, diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index 69b620f50..7bcec2d85 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -12,7 +12,6 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ describe('ExperienceToolbar (Experience Auditor)', () => { beforeEach(() => { vi.clearAllMocks(); - mockSdk.experiences.getUiMode.mockReturnValue('visual'); mockSdk.access.can.mockResolvedValue(true); mockSdk.experiences.experience.selection = { get: vi.fn().mockReturnValue({ nodeId: null }), @@ -60,16 +59,6 @@ describe('ExperienceToolbar (Experience Auditor)', () => { ); }); - it('disables locate in form mode', async () => { - mockSdk.experiences.getUiMode.mockReturnValue('form'); - const { getAllByTestId } = render(); - - await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0)); - - const locateButton = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button'); - expect(locateButton).toBeDisabled(); - }); - it('surfaces a deterministic fix as read-only advice (no write path on 4.59)', async () => { // A node whose alt text has stray whitespace yields a finding with a // "Trim whitespace" fix. The 4.59 surface has no setContentProperty, so the diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index 55c49002f..dd3779c6b 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -1,16 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { ExperienceContext, ExperienceEditorToolbarAppSDK, UiMode } from '@contentful/app-sdk'; -import { - Badge, - Box, - Button, - Flex, - Heading, - Note, - Spinner, - Stack, - Text, -} from '@contentful/f36-components'; +import type { ExperienceContext, ExperienceEditorToolbarAppSDK } from '@contentful/app-sdk'; +import { Badge, Box, Button, Flex, Heading, Spinner, Stack, Text } from '@contentful/f36-components'; import { useSDK } from '@contentful/react-apps-toolkit'; import { collectNodes } from '../audit/collect'; @@ -36,7 +26,6 @@ const ExperienceToolbar = () => { const sdk = useSDK(); const [context, setContext] = useState(() => sdk.experiences.context); - const [uiMode, setUiMode] = useState(() => sdk.experiences.getUiMode()); const [report, setReport] = useState(null); const [auditing, setAuditing] = useState(true); const [publishing, setPublishing] = useState(false); @@ -64,9 +53,8 @@ const ExperienceToolbar = () => { } }, [sdk]); - // Keep context and ui mode in sync. + // Keep context in sync. useEffect(() => sdk.experiences.onContextChanged(setContext), [sdk]); - useEffect(() => sdk.experiences.onUiModeChanged(setUiMode), [sdk]); // Initial audit + re-audit whenever the experience changes. // Simplification for the example: every onChange triggers a full traversal. @@ -82,7 +70,6 @@ const ExperienceToolbar = () => { const handleLocate = useCallback( (finding: AuditFinding) => { sdk.experiences.experience.selection.set(finding.nodeId); - // Highlight is a no-op in form mode; the button is disabled there anyway. sdk.experiences.experience.selection.highlight(finding.nodeId, { flash: true, scrollIntoView: true, @@ -111,7 +98,7 @@ const ExperienceToolbar = () => { }, [sdk, report]); const blocked = report ? hasBlockingErrors(report) : false; - const canLocate = uiMode === 'visual' && capabilities.selection; + const canLocate = capabilities.selection; return ( @@ -132,13 +119,6 @@ const ExperienceToolbar = () => { - {uiMode === 'form' && ( - - You are in form mode. Findings still update live, but locating a - component on the canvas requires visual mode. - - )} - {report && } {auditing && !report && ( diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts index 2c1022034..65d3648e1 100644 --- a/examples/experience-auditor/test/mocks/mockSdk.ts +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -49,8 +49,6 @@ const mockSdk: any = { experiences: { context: { type: 'experience', entityId: 'experience-123' }, onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe), - getUiMode: vi.fn().mockReturnValue('visual'), - onUiModeChanged: vi.fn().mockReturnValue(noopUnsubscribe), experience: { get: vi.fn(), onChange: vi.fn().mockReturnValue(noopUnsubscribe), From d8784a7df94a7a87b161de0806b8a9b0c6c6348c Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 9 Jul 2026 11:59:39 -0600 Subject: [PATCH 24/25] chore: fix prettier formatting in ExperienceToolbar Co-authored-by: Cursor --- .../src/locations/ExperienceToolbar.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index dd3779c6b..23927bac5 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -1,6 +1,15 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { ExperienceContext, ExperienceEditorToolbarAppSDK } from '@contentful/app-sdk'; -import { Badge, Box, Button, Flex, Heading, Spinner, Stack, Text } from '@contentful/f36-components'; +import { + Badge, + Box, + Button, + Flex, + Heading, + Spinner, + Stack, + Text, +} from '@contentful/f36-components'; import { useSDK } from '@contentful/react-apps-toolkit'; import { collectNodes } from '../audit/collect'; From 57556b2c04fec84bb2a76f8a03ebe482400bc4c8 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 9 Jul 2026 14:38:36 -0600 Subject: [PATCH 25/25] fix: address Gauntlet doc drift on experience-auditor [EXT-7476] - Rewrite AutoFix JSDoc and drop dead propertyKey field (write path gone) - Fix broken-binding finding text to match structural entryId check - README: demo is review-only; broken-binding table; no-tree-data note - Distinguish empty getRootNodes() from a genuinely clean experience - Drop version-specific wording from advisory-fix test title Co-authored-by: Cursor --- examples/experience-auditor/README.md | 11 +++++---- .../src/audit/audit.spec.ts | 4 ---- .../experience-auditor/src/audit/rules.ts | 5 +--- .../experience-auditor/src/audit/types.ts | 17 +++++++------- .../src/components/EmptyState.tsx | 23 ++++++++++++++----- .../src/components/FindingList.spec.tsx | 2 +- .../src/components/FindingList.tsx | 5 ++-- .../src/locations/ExperienceToolbar.spec.tsx | 12 +++++++--- .../src/locations/ExperienceToolbar.tsx | 7 +++++- 9 files changed, 52 insertions(+), 34 deletions(-) diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md index 0b39e38b8..9179c1ba1 100644 --- a/examples/experience-auditor/README.md +++ b/examples/experience-auditor/README.md @@ -41,7 +41,7 @@ as the author works and surfaces actionable, advisory fixes.** | `a11y/image-alt-text` | error / warning | Images must have non-empty alt text; trims stray whitespace | | `content/required-empty` | warning | Headings/titles should not be empty | | `seo/missing-meta` | info | SEO meta fields should be populated | -| `content/broken-binding` | error | Entry-bound properties must resolve via the host's binding resolution | +| `content/broken-binding` | error | Entry-bound properties must include an `entryId` | | `a11y/heading-order` | warning | Heading levels should not skip (e.g. H2 → H4) | Most rules are **per-node**: they look at a single component's properties in @@ -103,7 +103,7 @@ src/ components/ ScoreSummary.tsx FindingList.tsx Groups findings by severity; renders locate + fix affordances - SuggestedFix.tsx Review-and-edit step for suggested fixes + SuggestedFix.tsx Read-only display for suggested fixes EmptyState.tsx demo/ DemoProvider.tsx Dev-only: renders the toolbar against the seeded mock (?demo) @@ -150,7 +150,7 @@ http://localhost:3000/?demo ``` The `?demo` flag renders the toolbar against a seeded, in-memory experience so -you can click an audit, accept or edit a suggested fix, and watch the score +you can click through findings, review a suggested fix, and watch the score update. It is a **dev-only convenience with no live canvas** — because there is no real selection surface behind it, **Locate is disabled in the demo** (an illustration of the capability-aware behavior described above). The demo @@ -185,8 +185,9 @@ shapes used here match the published types exactly. > **Note on `getRootNodes()`.** The audit traversal starts from > `sdk.experiences.experience.getRootNodes()`, which currently resolves to an > empty list until the host wires up experience-tree sync. Against a live host -> today the audit will report no findings; the demo mode below exercises the full -> loop against a seeded in-memory experience. +> today the toolbar shows a distinct "no components to audit yet" state rather +> than the all-clear celebration; demo mode (`/?demo`) exercises the full loop +> against a seeded in-memory experience. ## Available scripts diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts index ba1dab29f..e60ae2c7d 100644 --- a/examples/experience-auditor/src/audit/audit.spec.ts +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -46,7 +46,6 @@ describe('audit rules', () => { expect(finding?.fix).toEqual({ kind: 'deterministic', label: 'Trim whitespace', - propertyKey: 'altText', value: 'spaced', }); }); @@ -132,7 +131,6 @@ describe('audit rules', () => { expect(finding?.fix).toEqual({ kind: 'deterministic', label: 'Set to H3', - propertyKey: 'headingLevel', value: 3, }); }); @@ -156,7 +154,6 @@ describe('audit rules', () => { expect(finding?.fix).toEqual({ kind: 'suggested', label: 'Use heading as meta', - propertyKey: 'metaTitle', suggestedValue: 'Spring Sale', source: 'the heading on this component', }); @@ -174,7 +171,6 @@ describe('audit rules', () => { expect(finding?.fix).toEqual({ kind: 'suggested', label: 'Use heading as meta', - propertyKey: 'metaTitle', suggestedValue: 'Spring Sale', source: 'the heading on this component', }); diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts index b4559234f..2f22d9109 100644 --- a/examples/experience-auditor/src/audit/rules.ts +++ b/examples/experience-auditor/src/audit/rules.ts @@ -108,7 +108,6 @@ const altTextRule: AuditRule = { fix: { kind: 'deterministic', label: 'Trim whitespace', - propertyKey: alt.key, value: alt.value.trim(), }, }), @@ -172,7 +171,6 @@ const seoMetaRule: AuditRule = { ? { kind: 'suggested', label: 'Use heading as meta', - propertyKey: meta.key, suggestedValue: suggestion, source: 'the heading on this component', } @@ -205,7 +203,7 @@ const brokenBindingRule: AuditRule = { propertyKey: property.key, severity: 'error', title: 'Broken entry binding', - detail: `"${property.key}" is bound to an entry, but the reference does not resolve.`, + detail: `"${property.key}" is bound to an entry but has no entryId.`, }) ); } @@ -243,7 +241,6 @@ export function evaluateHeadingOrder(nodes: CollectedNode[]): AuditFinding[] { fix: { kind: 'deterministic', label: `Set to H${expected}`, - propertyKey: key, value: expected, }, }) diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts index 08125e298..27dcff385 100644 --- a/examples/experience-auditor/src/audit/types.ts +++ b/examples/experience-auditor/src/audit/types.ts @@ -14,21 +14,22 @@ export interface CollectedNode { } /** - * A one-click fix for a finding. + * Advisory fix attached to a finding. Both kinds are read-only — the app-sdk + * surface exposes no host call to write a node's content properties, so the + * auditor surfaces derived values for the author to apply manually. * * - `deterministic`: exactly one correct result (e.g. trimming whitespace). - * Applied immediately on click, no confirmation. - * - `suggested`: a proposed value the author reviews and edits before it is - * written. Used where the fix is helpful but not unambiguous (e.g. deriving a - * meta title from the heading). We never write a suggested value silently. + * Shown as a direct hint. + * - `suggested`: a proposed value the author should review before using (e.g. + * deriving a meta title from the heading). Shown with provenance so the + * author can judge it. */ export type AutoFix = - | { kind: 'deterministic'; label: string; propertyKey: string; value: unknown } + | { kind: 'deterministic'; label: string; value: unknown } | { kind: 'suggested'; label: string; - propertyKey: string; - /** Pre-filled, editable proposed value. */ + /** Pre-filled proposed value. */ suggestedValue: string; /** Human-readable provenance, e.g. "from the heading on this component". */ source: string; diff --git a/examples/experience-auditor/src/components/EmptyState.tsx b/examples/experience-auditor/src/components/EmptyState.tsx index 5c4e5fdb2..33a953793 100644 --- a/examples/experience-auditor/src/components/EmptyState.tsx +++ b/examples/experience-auditor/src/components/EmptyState.tsx @@ -1,11 +1,22 @@ import React from 'react'; import { Note } from '@contentful/f36-components'; -/** Celebration state shown when the experience passes every audit rule. */ -const EmptyState = () => ( - - 🎉 No issues found. This experience passes every audit rule. - -); +/** Celebration or empty-tree state depending on whether nodes were collected. */ +const EmptyState = ({ nodeCount }: { nodeCount: number }) => { + if (nodeCount === 0) { + return ( + + No components to audit yet. The experience tree may not be synced by the host — try demo + mode (/?demo) to exercise the full audit loop locally. + + ); + } + + return ( + + 🎉 No issues found. This experience passes every audit rule. + + ); +}; export default EmptyState; diff --git a/examples/experience-auditor/src/components/FindingList.spec.tsx b/examples/experience-auditor/src/components/FindingList.spec.tsx index 6214db85c..c747f9d5d 100644 --- a/examples/experience-auditor/src/components/FindingList.spec.tsx +++ b/examples/experience-auditor/src/components/FindingList.spec.tsx @@ -17,7 +17,6 @@ function suggestedFinding(suggestedValue: string): AuditFinding { fix: { kind: 'suggested', label: 'Use heading as meta', - propertyKey: 'metaTitle', suggestedValue, source: 'the heading on this component', }, @@ -27,6 +26,7 @@ function suggestedFinding(suggestedValue: string): AuditFinding { describe('FindingList suggested-fix display', () => { it('renders the derived suggestion as read-only advice', () => { const props = { + nodeCount: 1, canLocate: false, onLocate: vi.fn(), }; diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx index 7b42a049c..2a1771c1c 100644 --- a/examples/experience-auditor/src/components/FindingList.tsx +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -7,6 +7,7 @@ import EmptyState from './EmptyState'; interface FindingListProps { findings: AuditFinding[]; + nodeCount: number; canLocate: boolean; onLocate: (finding: AuditFinding) => void; } @@ -24,8 +25,8 @@ const SEVERITY_LABEL: Record = { info: 'Info', }; -const FindingList = ({ findings, canLocate, onLocate }: FindingListProps) => { - if (findings.length === 0) return ; +const FindingList = ({ findings, nodeCount, canLocate, onLocate }: FindingListProps) => { + if (findings.length === 0) return ; return ( diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx index 7bcec2d85..6a66dc0ea 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -59,10 +59,16 @@ describe('ExperienceToolbar (Experience Auditor)', () => { ); }); - it('surfaces a deterministic fix as read-only advice (no write path on 4.59)', async () => { + it('shows a no-tree-data state when the host returns no root nodes', async () => { + mockSdk.experiences.experience.getRootNodes.mockReturnValue([]); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('no-tree-data')).toBeInTheDocument()); + }); + + it('surfaces a deterministic fix as read-only advice', async () => { // A node whose alt text has stray whitespace yields a finding with a - // "Trim whitespace" fix. The 4.59 surface has no setContentProperty, so the - // fix is advisory: the trimmed value is shown, not applied. + // "Trim whitespace" fix. The app-sdk surface has no content-write call, so + // the fix is advisory: the trimmed value is shown, not applied. const fixNode = makeMockNode('hero', 'Component', [ { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } }, { key: 'altText', area: 'content', value: ' spaced alt ' }, diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx index 23927bac5..65e19c319 100644 --- a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -137,7 +137,12 @@ const ExperienceToolbar = () => { )} {report && ( - + )}