From 02542393298e395a4c8525a0adaa4c38ce45714e Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Wed, 3 Jun 2026 14:43:01 -0600 Subject: [PATCH 01/19] 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 0000000000..d258ba0341 --- /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 0000000000..7b2c96e84f --- /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 0000000000..cf65f5e579 --- /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 0000000000..4383fc531f --- /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 0000000000..5db9499a33 --- /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 0000000000..0a1491cb6f --- /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 0000000000..2e850a391f --- /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 0000000000..f2e7272037 --- /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 0000000000..2ab29209dc --- /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 0000000000..7a47801159 --- /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 0000000000..b45f2ee03d --- /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 0000000000..ab3ec33408 --- /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 0000000000..20be7a2c53 --- /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 0000000000..e9251f6fe4 --- /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 0000000000..d106627190 --- /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 0000000000..3254792092 --- /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 0000000000..41aba1695f --- /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 0000000000..619be1998a --- /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 0000000000..eb82e0f2f0 --- /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 0000000000..9ddacc64b4 --- /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 0000000000..5149fe8ba5 --- /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 0000000000..697fd88e96 --- /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 0000000000..438203f62d --- /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 03f243185515f727887212e20f2e382fafcaded5 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 4 Jun 2026 15:39:13 -0600 Subject: [PATCH 02/19] 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 0a1491cb6f..748bbb11a4 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 7a47801159..4081f86e28 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 41aba1695f..8523cda079 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 619be1998a..08b27715b8 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 74b120f3dab8fc89d0076e58021e87c25d038853 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:27:08 -0600 Subject: [PATCH 03/19] 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 748bbb11a4..d6e9fe3cf2 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 4081f86e28..6f4517dcf0 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 b45f2ee03d..240c099188 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 08b27715b8..af3828878e 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 5d9f842a2666c7981df76e7858ab3711f0472ba4 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:31:38 -0600 Subject: [PATCH 04/19] 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 0000000000..32a673dc35 --- /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 0000000000..60f69bb947 --- /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 a49504f42e465fcf3ce385de23da44591dc274a7 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:41:21 -0600 Subject: [PATCH 05/19] 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 0000000000..f79db64dfc --- /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 0000000000..43176a0b21 --- /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 3a02fb84a10d66c7449e2e451eff1469be1d1029 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:46:31 -0600 Subject: [PATCH 06/19] 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 d6e9fe3cf2..cc800e8e45 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 2ab29209dc..db596938a4 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 43176a0b21..064f43c3ae 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 0000000000..612e89f126 --- /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 6f4517dcf0..b263744020 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 e1a1f95d3a7a4c16926083e862d2297c797e21d5 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:54:00 -0600 Subject: [PATCH 07/19] 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 cc800e8e45..c83e6c1e53 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 064f43c3ae..fc37b8b8e8 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 612e89f126..2e5be6f87f 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 b263744020..3707af5469 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 2d0d18edc528c4fe8839df473cb37b427a2eb027 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 10:57:18 -0600 Subject: [PATCH 08/19] 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 2e850a391f..b2663755e2 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 f2e7272037..3cead42401 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 5149fe8ba5..5ac3b09bf3 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 a35f47a9055b23abd4a52124160cc9f3e9d9dcaa Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:04:04 -0600 Subject: [PATCH 09/19] 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 c83e6c1e53..46479199a9 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 b2663755e2..7c9034106c 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 3cead42401..908e299e85 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 3707af5469..d64674f762 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 240c099188..da800c8c04 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 e411ebed374071f007bc6b478754ba8d409704b1 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:05:57 -0600 Subject: [PATCH 10/19] 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 0000000000..e6de354944 --- /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 e80be786b57a9087a7d1da9b3d53971b2d2c90c1 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:09:25 -0600 Subject: [PATCH 11/19] 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 0000000000..5c4e5fdb2f --- /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 e9251f6fe4..b4b910a0a1 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 9dba0a8db2d4e49c31a1991e1b25c3ea39c50f8f Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:15:04 -0600 Subject: [PATCH 12/19] 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 ab3ec33408..7fe250491f 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 8523cda079..fa3f4b75cb 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 af3828878e..ce4c21cf7f 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 595bab81a0add0f07d91fd90ba069dec104e8928 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:22:46 -0600 Subject: [PATCH 13/19] 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 0000000000..332697b7c0 --- /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 0000000000..2790f1ac65 --- /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 d106627190..024d71dffa 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 45dfb83720e15f0d00e4f3c01d3c9e7b42e103b1 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:29:57 -0600 Subject: [PATCH 14/19] 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 024d71dffa..4727051572 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 50a2a2253ca958c86a96845b81063d9ab24e63d9 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:33:19 -0600 Subject: [PATCH 15/19] 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 7b2c96e84f..7bb773b468 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 03cf8e305087d0532cbed8cb55d06e4d269f3cef Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 11:36:37 -0600 Subject: [PATCH 16/19] 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 7bb773b468..81945631e8 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 3d005cb30849a24db05edf355b476da5d9b7dc5a Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Mon, 8 Jun 2026 12:14:09 -0600 Subject: [PATCH 17/19] 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 46479199a9..bbd4c35e16 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 f79db64dfc..4eabb280f8 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 fc37b8b8e8..16a78f87f2 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 d64674f762..ee6e39b0ed 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 0000000000..7ee430015d --- /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 7fe250491f..e1adfdfab2 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 01b4e7ae2fbb147c9096f5b8f24bd4f2f56d69f8 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Tue, 9 Jun 2026 14:45:22 -0600 Subject: [PATCH 18/19] 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 4383fc531f..da47d2995e 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 bbd4c35e16..c02cf5bd71 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 7c9034106c..b5f55cd65a 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 908e299e85..ff602c1107 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 ee6e39b0ed..e226cdc599 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 803965090766ea11b4960a2671a0ad60a7fc78c0 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Fri, 12 Jun 2026 13:40:30 -0600 Subject: [PATCH 19/19] 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 da47d2995e..b907f0dc5f 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 3254792092..601af0cca1 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.