Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ce4f2c2
feat: add experience-auditor showcase example [EXT-7476]
jjolton-contentful Jun 3, 2026
f7200df
fix: harden audit rules + cover fix path in experience-auditor [EXT-7…
jjolton-contentful Jun 4, 2026
d860ac9
refactor: add fix-kind discriminator and resolved-binding types to ex…
lewisjcs Jun 8, 2026
0b4c533
feat: detect backed sdk.exo capabilities in experience-auditor [EXT-7…
lewisjcs Jun 8, 2026
0a43465
feat: add pure suggested-meta-from-heading fix derivation [EXT-7476]
lewisjcs Jun 8, 2026
48d70fa
feat: add heading-order rule, suggested meta fix, and real binding re…
lewisjcs Jun 8, 2026
16fc6de
fix: tighten heading-level matching to avoid key collisions [EXT-7476]
lewisjcs Jun 8, 2026
dd86f81
feat: resolve entry bindings in the collector seam [EXT-7476]
lewisjcs Jun 8, 2026
51efdef
refactor: drop redundant isEntryBinding field, cover partial-host gua…
lewisjcs Jun 8, 2026
695ad72
feat: add SuggestedFix confirm-step component [EXT-7476]
lewisjcs Jun 8, 2026
739bd5a
feat: circular score gauge and celebration empty state [EXT-7476]
lewisjcs Jun 8, 2026
973a747
feat: severity grouping, suggested-fix apply path, capability-gated L…
lewisjcs Jun 8, 2026
d45b953
feat: add standalone demo mode for experience-auditor [EXT-7476]
lewisjcs Jun 8, 2026
d6df14b
refactor: fold demo-mode URL parse out of production bundle [EXT-7476]
lewisjcs Jun 8, 2026
da86b1a
docs: document new rules, fix model, capability degradation, and demo…
lewisjcs Jun 8, 2026
b4b716c
docs: correct live-audit traversal description in experience-auditor …
lewisjcs Jun 8, 2026
bd05563
fix: address gauntlet review findings on experience-auditor [EXT-7476]
lewisjcs Jun 8, 2026
7fdee6c
fix: align experience-auditor with app-sdk 4.58.1 Binding union [EXT-…
jjolton-contentful Jun 9, 2026
29e748b
fix: bump experience-auditor to app-sdk 4.58.2 and fix prettier [EXT-…
jjolton-contentful Jun 12, 2026
4e0b3d3
fix: adapt experience-auditor to app-sdk 4.59 surface [EXT-7476]
lewisjcs Jun 24, 2026
d149a5e
fix: adopt renamed Experience SDK surface in experience-auditor examp…
lewisjcs Jun 25, 2026
f40ec8b
docs: correct experience-auditor README to match advisory behavior [E…
jjolton-contentful Jul 6, 2026
cff02e9
fix: drop UI mode usage from experience-auditor example [EXT-7476]
jjolton-contentful Jul 8, 2026
d8784a7
chore: fix prettier formatting in ExperienceToolbar
jjolton-contentful Jul 9, 2026
57556b2
fix: address Gauntlet doc drift on experience-auditor [EXT-7476]
jjolton-contentful Jul 9, 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*
202 changes: 202 additions & 0 deletions examples/experience-auditor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Experience Auditor

A polished, real-world example app for the **Experience Editor toolbar** — the
`experience-toolbar` location introduced in
[`@contentful/app-sdk@4.60.0`](https://www.npmjs.com/package/@contentful/app-sdk).

Experience Auditor runs alongside the Experience Editor and continuously audits
the experience you are editing for **accessibility, SEO, and
content-completeness** issues. It demonstrates the standout capability of the
toolbar location: **live, selection-aware tooling that reads the experience tree
as the author works and surfaces actionable, advisory fixes.**

> Looking for the bare-bones starter instead? See the
> [`experience-toolbar`](../experience-toolbar) example, which demonstrates the
> minimal `sdk.experiences` patterns. Experience Auditor builds on those to show a
> complete, opinionated app.

## What it does

- **Live audit** — walks the experience tree with `getRootNodes()` →
`getProperties()`, runs a set of pure rules, and re-runs automatically on
`sdk.experiences.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 (only where the host backs the
selection surface — see _Capability-aware behavior_).
- **Advisory fixes** — findings can carry a derived fix that is surfaced as
read-only advice for the author to apply manually. The current app-sdk surface
exposes no host call to write a node's content properties, so the auditor
computes the suggestion but does not write it. Fixes come in two kinds (see
below).
- **Pre-publish gate** — `experience.publish()` is blocked while any error-level
finding remains.

### 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 include an `entryId` |
| `a11y/heading-order` | warning | Heading levels should not skip (e.g. H2 → H4) |

Most rules are **per-node**: they look at a single component's properties in
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`.

### Advisory fixes

A finding can offer a fix, and there are two kinds — the distinction shapes how
the suggestion is presented, since one is unambiguous and the other is a
judgement call:

- **Deterministic** — exactly one correct result. Examples: trimming surrounding
whitespace from alt text, or the level that keeps a skipped heading's outline
sequential. The app surfaces the corrected value directly as a hint.
- **Suggested** — a proposed value the author should review before using.
Example: deriving an SEO meta value from the component's heading. The app
shows the suggestion with its provenance so the author can judge it.

In both cases the value is **surfaced as read-only advice** — the current
app-sdk surface has no host call to write a node's content properties, so the
author applies the change manually in the editor. The fix shapes are modelled as
a discriminated union in [`src/audit/types.ts`](src/audit/types.ts) (`AutoFix`),
so the toolbar can route each kind to the right UI and the rules stay
declarative about what they offer. If a content-write surface lands later, this
is where a one-click apply path would attach.

### Capability-aware behavior

Not every `sdk.experiences` surface is backed by every host, and surfaces can roll out
incrementally. Rather than call a method and catch the failure, the app probes
up front which surfaces are available
([`src/audit/capabilities.ts`](src/audit/capabilities.ts)) and adapts its UI to
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.experiences surfaces the host backs
collect.ts The only SDK-coupled piece: walks sdk.experiences.experience → CollectedNode[]
components/
ScoreSummary.tsx
FindingList.tsx Groups findings by severity; renders locate + fix affordances
SuggestedFix.tsx Read-only display for suggested fixes
EmptyState.tsx
demo/
DemoProvider.tsx Dev-only: renders the toolbar against the seeded mock (?demo)
mockExperiences.ts Seeded in-memory sdk.experiences for the demo
locations/
ConfigScreen.tsx
ExperienceToolbar.tsx Wires the SDK to the engine (collect → audit → locate/fix/publish)
```

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 through findings, review a suggested fix, and watch the score
update. It is a **dev-only convenience with no live canvas** — because there is
no real selection surface behind it, **Locate is disabled in the demo** (an
illustration of the capability-aware behavior described above). The demo
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.60.0` types,
which are the contract for the toolbar location. The host renderer that serves
`sdk.experiences` at runtime is still rolling out, so the app is **type-verified and
unit-tested against a mocked SDK** — 36 tests cover the audit rules, scoring,
the collector and its binding resolution, capability detection, the suggested-
fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is
not yet verified end-to-end inside a live Experience Editor; that live
verification is tracked separately as the host renderer rolls out. The API
shapes used here match the published types exactly.

> **Note on `getRootNodes()`.** The audit traversal starts from
> `sdk.experiences.experience.getRootNodes()`, which currently resolves to an
> empty list until the host wires up experience-tree sync. Against a live host
> today the toolbar shows a distinct "no components to audit yet" state rather
> than the all-clear celebration; demo mode (`/?demo`) exercises the full loop
> against a seeded in-memory experience.

## Available scripts

- `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.experiences` 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.63.1",
"@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