Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0254239
feat: add experience-auditor showcase example [EXT-7476]
jjolton-contentful Jun 3, 2026
03f2431
fix: harden audit rules + cover fix path in experience-auditor [EXT-7…
jjolton-contentful Jun 4, 2026
74b120f
refactor: add fix-kind discriminator and resolved-binding types to ex…
lewisjcs Jun 8, 2026
5d9f842
feat: detect backed sdk.exo capabilities in experience-auditor [EXT-7…
lewisjcs Jun 8, 2026
a49504f
feat: add pure suggested-meta-from-heading fix derivation [EXT-7476]
lewisjcs Jun 8, 2026
3a02fb8
feat: add heading-order rule, suggested meta fix, and real binding re…
lewisjcs Jun 8, 2026
e1a1f95
fix: tighten heading-level matching to avoid key collisions [EXT-7476]
lewisjcs Jun 8, 2026
2d0d18e
feat: resolve entry bindings in the collector seam [EXT-7476]
lewisjcs Jun 8, 2026
a35f47a
refactor: drop redundant isEntryBinding field, cover partial-host gua…
lewisjcs Jun 8, 2026
e411ebe
feat: add SuggestedFix confirm-step component [EXT-7476]
lewisjcs Jun 8, 2026
e80be78
feat: circular score gauge and celebration empty state [EXT-7476]
lewisjcs Jun 8, 2026
9dba0a8
feat: severity grouping, suggested-fix apply path, capability-gated L…
lewisjcs Jun 8, 2026
595bab8
feat: add standalone demo mode for experience-auditor [EXT-7476]
lewisjcs Jun 8, 2026
45dfb83
refactor: fold demo-mode URL parse out of production bundle [EXT-7476]
lewisjcs Jun 8, 2026
50a2a22
docs: document new rules, fix model, capability degradation, and demo…
lewisjcs Jun 8, 2026
03cf8e3
docs: correct live-audit traversal description in experience-auditor …
lewisjcs Jun 8, 2026
3d005cb
fix: address gauntlet review findings on experience-auditor [EXT-7476]
lewisjcs Jun 8, 2026
01b4e7a
fix: align experience-auditor with app-sdk 4.58.1 Binding union [EXT-…
jjolton-contentful Jun 9, 2026
8039650
fix: bump experience-auditor to app-sdk 4.58.2 and fix prettier [EXT-…
jjolton-contentful Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions examples/experience-auditor/.gitignore
Original file line number Diff line number Diff line change
@@ -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*
191 changes: 191 additions & 0 deletions examples/experience-auditor/README.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions examples/experience-auditor/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start`.
To create a production bundle, use `npm run build`.
-->
</body>
</html>
58 changes: 58 additions & 0 deletions examples/experience-auditor/package.json
Original file line number Diff line number Diff line change
@@ -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": "."
}
27 changes: 27 additions & 0 deletions examples/experience-auditor/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <Component /> : null;
};

export default App;
Loading
Loading