Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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*
123 changes: 123 additions & 0 deletions examples/experience-auditor/README.md
Original file line number Diff line number Diff line change
@@ -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
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.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": "."
}
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