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, + }, +});