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..81945631e8 --- /dev/null +++ b/examples/experience-auditor/README.md @@ -0,0 +1,191 @@ +# 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()` → + `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, and only where + the host backs the selection surface — see _Capability-aware behavior_). +- **One-click fixes** — findings can carry a fix that writes back via + `getNode().setContentProperty()`, permission-checked with `sdk.access.can()` + and confirmed through `sdk.notifier`. Fixes come in two kinds (see below). +- **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 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, 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 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 — 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 + +```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 +``` + +### 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 +`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. 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 + +- `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..b907f0dc5f --- /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.2", + "@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..c02cf5bd71 --- /dev/null +++ b/examples/experience-auditor/src/audit/audit.spec.ts @@ -0,0 +1,294 @@ +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({ + kind: 'deterministic', + 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('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'); + 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('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', [ + { + key: 'title', + area: 'content', + value: null, + binding: { type: 'entry', entryId: '', fieldId: 'title' }, + }, + ]), + ]); + 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: { type: 'entry', entryId: 'entry-1', fieldId: 'title' }, + }, + ]), + ]); + 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('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', + nodeType: 'Component', + properties: [ + { + key: 'featured', + area: 'content', + value: null, + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, + }, + ], + resolvedBindings: { featured: { resolved: false } }, + }; + const finding = runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding'); + expect(finding?.severity).toBe('error'); + }); + + it('does not flag a binding that resolves', () => { + const n: CollectedNode = { + id: 'card', + nodeType: 'Component', + properties: [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, + }, + ], + resolvedBindings: { featured: { resolved: true } }, + }; + expect( + runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding') + ).toBeUndefined(); + }); + + 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', + '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/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', + }; +} 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..b5f55cd65a --- /dev/null +++ b/examples/experience-auditor/src/audit/collect.spec.ts @@ -0,0 +1,100 @@ +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'); + }); + + it('populates resolvedBindings when the node resolves an entry binding', async () => { + const okNode = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, + }, + ]); + okNode.resolveEntryBinding = vi.fn().mockResolvedValue({ entryId: 'e1' }); + const experience: any = { getRootNodes: vi.fn().mockReturnValue([okNode]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toEqual({ + featured: { resolved: true }, + }); + }); + + it('marks a binding unresolved when resolveEntryBinding returns null', async () => { + const brokenNode = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: null, + binding: { type: 'entry', entryId: 'gone', fieldId: 'featured' }, + }, + ]); + brokenNode.resolveEntryBinding = vi.fn().mockResolvedValue(null); + const experience: any = { getRootNodes: vi.fn().mockReturnValue([brokenNode]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toEqual({ + featured: { resolved: false }, + }); + }); + + it('omits resolvedBindings when no property is entry-bound', async () => { + const node = makeMockNode('plain', 'Component', [ + { key: 'body', area: 'content', value: 'hi' }, + ]); + const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toBeUndefined(); + }); + + it('omits resolvedBindings when the host does not implement resolveEntryBinding', async () => { + const node = makeMockNode('card', 'Component', [ + { + key: 'featured', + area: 'content', + value: 'x', + binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' }, + }, + ]); + // Simulate a partial host bridge that has not shipped resolveEntryBinding yet. + delete (node as { resolveEntryBinding?: unknown }).resolveEntryBinding; + const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) }; + const [collected] = await collectNodes(experience); + expect(collected.resolvedBindings).toBeUndefined(); + }); +}); diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts new file mode 100644 index 0000000000..ff602c1107 --- /dev/null +++ b/examples/experience-auditor/src/audit/collect.ts @@ -0,0 +1,59 @@ +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 + * 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(); + 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 + // failing the whole audit. + return null; + } + }) + ); + + return collected.filter((node): node is CollectedNode => node !== null); +} + +/** + * Resolves each entry-bound property via the host's `resolveEntryBinding`, + * capturing whether the reference actually resolves. Returns `undefined` when + * the host does not back resolution (older/partial bridge) or the node has no + * entry-bound properties, so the binding rule falls back to its structural check. + * SDK coupling lives here, not in the rules. + */ +async function resolveBindings( + node: ExoNodeAPI, + properties: ComponentPropertyDescriptor[] +): Promise | undefined> { + if (typeof node.resolveEntryBinding !== 'function') return undefined; + + const entries: Array<[string, ResolvedBinding]> = []; + for (const property of properties) { + if (property.binding?.type !== 'entry') continue; + const target = await node.resolveEntryBinding(property.key); + entries.push([property.key, { resolved: target !== null }]); + } + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/examples/experience-auditor/src/audit/engine.ts b/examples/experience-auditor/src/audit/engine.ts new file mode 100644 index 0000000000..db596938a4 --- /dev/null +++ b/examples/experience-auditor/src/audit/engine.ts @@ -0,0 +1,60 @@ +import type { AuditFinding, AuditReport, AuditRule, CollectedNode, Severity } from './types'; +import { AUDIT_RULES, evaluateHeadingOrder } 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)); + } + } + findings.push(...evaluateHeadingOrder(nodes)); + + 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/fixes.spec.ts b/examples/experience-auditor/src/audit/fixes.spec.ts new file mode 100644 index 0000000000..4eabb280f8 --- /dev/null +++ b/examples/experience-auditor/src/audit/fixes.spec.ts @@ -0,0 +1,40 @@ +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: '' }, + ]) + ); + 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: '' }, + ]) + ); + 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: '' }, + ]) + ); + 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..16a78f87f2 --- /dev/null +++ b/examples/experience-auditor/src/audit/fixes.ts @@ -0,0 +1,17 @@ +import type { CollectedNode } from './types'; +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). + */ +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)) + ); + 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..2e5be6f87f --- /dev/null +++ b/examples/experience-auditor/src/audit/keys.ts @@ -0,0 +1,15 @@ +/** 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; +// 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 new file mode 100644 index 0000000000..e226cdc599 --- /dev/null +++ b/examples/experience-auditor/src/audit/rules.ts @@ -0,0 +1,266 @@ +import type { ComponentPropertyDescriptor } from '@contentful/app-sdk'; +import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types'; +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, + matcher: RegExp, + predicate?: (p: ComponentPropertyDescriptor) => boolean +): ComponentPropertyDescriptor | undefined { + return node.properties.find( + (p) => matcher.test(stripNonAlpha(p.key)) && (!predicate || predicate(p)) + ); +} + +/** + * 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; + 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) { + // 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 []; + } + + 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: { + kind: 'deterministic', + 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) { + // 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)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)) + ); + 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)) { + const suggestion = suggestMetaFromHeading(node); + 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, + }), + ]; + } + return []; + }, +}; + +/** + * 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', + 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.type !== 'entry') continue; + + const resolution = node.resolvedBindings?.[property.key]; + const broken = resolution ? !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 does not resolve.`, + }) + ); + } + } + return findings; + }, +}; + +function headingLevelOf(node: CollectedNode): { level: number; key: string } | undefined { + const prop = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key))); + if (!prop || typeof prop.value !== 'number') return undefined; + return { level: prop.value, key: prop.key }; +} + +/** + * 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 heading = headingLevelOf(node); + if (heading === undefined) continue; + const { level, key } = heading; + if (previous !== undefined && level > previous + 1) { + const expected = previous + 1; + 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, + 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..da800c8c04 --- /dev/null +++ b/examples/experience-auditor/src/audit/types.ts @@ -0,0 +1,81 @@ +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[]; + /** + * Per-property-key binding resolution, populated by the collector when the + * host backs `resolveEntryBinding`. Absent when the host does not support it + * (the binding rule then falls back to a structural check). + */ + resolvedBindings?: Record; +} + +/** The resolution result for an entry-bound property, captured at collect time. */ +export interface ResolvedBinding { + /** True when `resolveEntryBinding` returned a target; false when it returned null. */ + resolved: boolean; +} + +/** + * 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 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. */ + 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; +} + +/** 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/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/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 new file mode 100644 index 0000000000..e1adfdfab2 --- /dev/null +++ b/examples/experience-auditor/src/components/FindingList.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +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[]; + canLocate: boolean; + canFix: boolean; + onLocate: (finding: AuditFinding) => void; + onApplyDeterministic: (finding: AuditFinding) => void; + onApplySuggested: (finding: AuditFinding, value: string) => void; + busyFindingId: string | null; +} + +const SEVERITY_VARIANT: Record = { + error: 'negative', + warning: 'warning', + 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, + onApplyDeterministic, + onApplySuggested, + busyFindingId, +}: FindingListProps) => { + if (findings.length === 0) return ; + + return ( + + {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' && ( + // Key on the suggested value so a *changed* suggestion (same + // finding.id, new derived value) remounts SuggestedFix and + // re-seeds its state; an unchanged suggestion keeps the same + // key, preserving the author's in-progress edit across a + // no-op re-audit. React's "reset state with a key" idiom — + // preferred over a useEffect-sync anti-pattern. + onApplySuggested(finding, value)} + /> + )} + + + + {finding.fix?.kind === 'deterministic' && ( + + )} + + + + ))} + + ); + })} + + ); +}; + +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..b4b910a0a1 --- /dev/null +++ b/examples/experience-auditor/src/components/ScoreSummary.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Badge, Flex, 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; + const color = scoreColor(score); + const radius = 28; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - score / 100); + + return ( + + +
+ + + + +
+ {score} +
+
+ + + Health 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/components/SuggestedFix.tsx b/examples/experience-auditor/src/components/SuggestedFix.tsx new file mode 100644 index 0000000000..97fc16d4b0 --- /dev/null +++ b/examples/experience-auditor/src/components/SuggestedFix.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { Box, Button, Flex, Text, TextInput } from '@contentful/f36-components'; +import tokens from '@contentful/f36-tokens'; + +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; 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 new file mode 100644 index 0000000000..4727051572 --- /dev/null +++ b/examples/experience-auditor/src/index.tsx @@ -0,0 +1,35 @@ +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!); + +// `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 + // 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 { + 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..601af0cca1 --- /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..fa3f4b75cb --- /dev/null +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx @@ -0,0 +1,146 @@ +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, defaultNodes } 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); + 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 () => { + 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(); + }); + + 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()); + }); + + 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 new file mode 100644 index 0000000000..ce4c21cf7f --- /dev/null +++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx @@ -0,0 +1,235 @@ +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 { detectCapabilities } from '../audit/capabilities'; +import type { AuditFinding, AuditReport, Capabilities } 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); + 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); + + 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. + // 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(() => { + 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 applyWrite = useCallback( + async (finding: AuditFinding, propertyKey: string, value: unknown) => { + setBusyFindingId(finding.id); + try { + const node = sdk.exo.experience.getNode(finding.nodeId); + if (!node) { + sdk.notifier.error('That component no longer exists.'); + return; + } + await node.setContentProperty(propertyKey, value); + sdk.notifier.success('Fix applied.'); + // Re-audit explicitly rather than relying on the setContentProperty + // write to round-trip back through onChange — keeps the panel in sync + // even if the host does not emit a change event for this write. + await audit(); + } catch { + sdk.notifier.error('Could not apply the fix. Please try again.'); + } finally { + setBusyFindingId(null); + } + }, + [sdk, audit] + ); + + const handleApplyDeterministic = useCallback( + (finding: AuditFinding) => { + if (finding.fix?.kind !== 'deterministic') return; + void applyWrite(finding, finding.fix.propertyKey, finding.fix.value); + }, + [applyWrite] + ); + + const handleApplySuggested = useCallback( + (finding: AuditFinding, value: string) => { + if (finding.fix?.kind !== 'suggested') return; + void applyWrite(finding, finding.fix.propertyKey, value); + }, + [applyWrite] + ); + + const handlePublish = useCallback(async () => { + if (!report || hasBlockingErrors(report)) return; + + 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' && capabilities.selection; + + 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..5ac3b09bf3 --- /dev/null +++ b/examples/experience-auditor/test/mocks/mockSdk.ts @@ -0,0 +1,76 @@ +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), + resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'mock-entry' }), + 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, + }, +});