Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/cms/src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/cms/src/lib/dal/getAllDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface FindOptions {
overrideAccess?: boolean;
locale?: Locale;
draft?: boolean;
context?: { visualEditing?: boolean };
pagination: false;
}

Expand All @@ -45,6 +46,7 @@ export async function getAllDocuments<TSlug extends CollectionSlug>(

const result = await find({
collection,
context: { visualEditing: draft },
depth,
draft,
locale,
Expand Down
8 changes: 7 additions & 1 deletion apps/cms/src/lib/dal/getGlobals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ export function revalidateGlobalTags(params: { collection: GlobalSlug; locale: L

async function getGlobal(slug: GlobalSlug, depth = 0, locale?: Locale, draft?: boolean) {
const payload = await getPayloadClient();
return await payload.findGlobal({ depth, draft, locale, slug });
return await payload.findGlobal({
context: { visualEditing: draft },
depth,
draft,
locale,
slug,
});
}

export const getCachedGlobal = (
Expand Down
1 change: 1 addition & 0 deletions apps/cms/src/lib/dal/getPostBySlug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ async function getPostBySlugQuery(

const result = await payload.find({
collection: BLOG_CONFIG.collection,
context: { visualEditing: draft },
draft,
limit: 1,
locale: resolvedLocale,
Expand Down
1 change: 1 addition & 0 deletions apps/cms/src/lib/dal/getRelatedPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function getRelatedPosts({

const { docs: backfillPosts } = await payload.find({
collection: BLOG_CONFIG.collection,
context: { visualEditing: draft },
depth: 1,
draft,
limit: remaining,
Expand Down
1 change: 1 addition & 0 deletions apps/cms/src/lib/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export const plugins: Plugin[] = [

visualEditingPlugin({
adminBasePath: "/admin",
enrichment: "explicit",
skipCollections: [
"users",
"media",
Expand Down
2 changes: 1 addition & 1 deletion packages/payload-plugin-visual-editing/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Three layers. Each runs in a different context.
- **Group key must include collection + docId + path + anchor.** See `src/client/overlay/markerExtractor.ts`. Keying by `path` alone conflates sibling-rendered docs (three FeatureSet cards with `path="title"` collapse to one LCA-wide target). Anchor alone does not differentiate either — plain text nodes have `anchor=null`.
- **Scan skips `<script>`, `<style>`, `<noscript>`, `<template>`.** See `src/client/overlay/stegaScanner.ts`. JSON-LD emitted via `JSON.stringify` preserves stega zero-width chars; decoding those drags real targets up to the LCA of the script and the rendered element.
- **Payload virtualizes field groups below the fold** via `<RenderIfInViewport>` (IntersectionObserver with `rootMargin: 1000px`). Before polling for a row/collapsible/input id, scroll the deepest-mounted `field-<ancestor>` into view — see `scrollNearestAncestorIntoView` in `src/admin/expandAndFocus.ts`.
- **Stega is only embedded on `draft + Local API` reads.** See `src/internal/gate.ts`. REST reads (including those the admin panel makes) skip enrichment so stega doesn't leak into form inputs.
- **Stega is only embedded on `draft + Local API` reads.** See `src/internal/gate.ts`. REST reads (including those the admin panel makes) skip enrichment so stega doesn't leak into form inputs. `context.visualEditing` (boolean) overrides the gate; with `enrichment: 'explicit'` it is the only way in — no guessing, so server-side draft reads (plugins, jobs) stay clean.
- **`beforeOperationHook` filters on `operation === 'read'`** — that's Payload's v3 coarse-grained operation label for `find`/`findByID`/`findVersions`/etc. Don't "fix" it to `'find'`.
- **Stega zero-width chars get stripped from the live DOM** by `stegaBookkeeper` after scanning (to avoid layout surprises). Originals are restored on teardown. If you need the raw pre-scan HTML, fetch it server-side.
- **Field exclusion is per consumer config.** Multi-tenant consumers typically pass `excludeFieldNames: ['tenant']`; the schema cache respects this.
Expand Down
32 changes: 29 additions & 3 deletions packages/payload-plugin-visual-editing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ export default buildConfig({
skipGlobals: [], // global slugs to exclude
excludeFieldNames: ['tenant'], // extra field names to strip (merged with '_status', 'folder', 'slug')
adminBasePath: '/admin', // Payload admin base path (default '/admin')
enrichment: 'explicit', // which reads get stega (default 'auto') — see "Enrichment modes"
}),
],
})
```

This adds enrichment hooks to every non-skipped collection and global, registers the `VisualEditingBridgeProvider` admin component, and adds a `beforeChange` hook that strips stega from writes so markers can never be persisted to the database.
This adds enrichment hooks to every non-skipped collection and global, registers the `VisualEditingBridgeProvider` admin component, and adds a `beforeChange` hook that strips intact stega markers from writes.

### 2. Enable drafts on editable collections

Expand Down Expand Up @@ -131,6 +132,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
}
```

With `enrichment: 'explicit'`, also pass `context: { visualEditing: draft }` — see [Enrichment modes](#enrichment-modes).

### 6. Wrap the frontend layout

Mount the provider (gated on draft mode), the toggle, and the overlay:
Expand Down Expand Up @@ -225,15 +228,37 @@ Clicking the Edit badge on an upload opens the media document's admin page (not

**Uploads require `depth ≥ 1`** so Payload returns the populated media document alongside its `_meta`. At `depth: 0` the value is a bare id string and no overlay is drawn.

## Enrichment modes

The `enrichment` option decides which reads get stega.

- **`'auto'`** (default) — every Local API read with `draft: true` outside the admin base path is enriched. The plugin can't tell a preview render from any other server code that reads drafts, so plugins, jobs, hooks and scripts receive stega too.
- **`'explicit'`** — only reads that pass `context: { visualEditing: true }` are enriched. Everything else gets clean data.

```ts
const { isEnabled: draft } = await draftMode()

await payload.find({
collection: 'pages',
draft,
context: { visualEditing: draft },
where: { slug: { equals: slug } },
})
```

In either mode `context.visualEditing` wins when set: `true` always enriches, `false` never does. Server code that reads drafts for its own processing should pass `context: { visualEditing: false }` under `'auto'`. The plugin augments Payload's `RequestContext`, so the key is type-checked.

> **Why it matters.** The `beforeChange` strip only removes intact markers. If server code receives stega, transforms the text (an LLM translation, truncation, concatenation) and writes it back, fragments of a damaged marker survive the strip and end up in the database. Use `'explicit'` whenever server code reads drafts and writes derived content.

## How it works

### Server pipeline

1. `beforeOperation` stamps `req.context` with the draft flag.
2. `afterRead` (gated to draft reads served to the frontend Local API — admin-panel reads, identified by their admin pathname, and REST reads are skipped) walks the returned document, attaches `_meta.path` markers to leaf-ish objects, and for rich-text / primitive-terminal types sets `_meta.terminal = true` so outer collection walks don't clobber them.
2. `afterRead` (gated by the [enrichment mode](#enrichment-modes) — under `'auto'`, draft reads served to the frontend Local API; admin-panel reads, identified by their admin pathname, and REST reads are skipped) walks the returned document, attaches `_meta.path` markers to leaf-ish objects, and for rich-text / primitive-terminal types sets `_meta.terminal = true` so outer collection walks don't clobber them.
3. `afterOperation` uses the collection's schema to embed Vercel stega into text fields, carrying the field path all the way through SSR into the client DOM.
4. Before stega is embedded, a small pre-pass walks each enriched doc's schema against its data. Populated `upload:<slug>` values are flipped to `_meta.terminal = true` so their identity is preserved for wrapper-attr consumption on the client (`<img {...withVisualEditingPath(upload)} />`), without embedding zero-width stega into `alt` or `filename`.
5. `beforeChange` strips stega from every write as a safety net, so markers can never be persisted even if a read is ever mis-classified.
5. `beforeChange` strips intact stega markers from every write as a safety net. Markers damaged by a text transformation are not recognized — keep stega away from such code with the [enrichment mode](#enrichment-modes).

The schema cache (per slug, memoized) resolves relationship targets lazily so you don't pay for unused collections.

Expand All @@ -249,6 +274,7 @@ The schema cache (per slug, memoized) resolves relationship targets lazily so yo
| `skipGlobals` | `string[]` | `[]` | Exclude these global slugs from enrichment. |
| `excludeFieldNames` | `string[]` | `[]` | Extra field names to strip from the serialized schema. Always merged with `_status`, `folder`, `slug`. |
| `adminBasePath` | `string` | `/admin` | Payload admin base path. Used to exclude admin reads from enrichment and by the bridge for URL parsing and admin-tab navigation. |
| `enrichment` | `'auto' \| 'explicit'` | `'auto'` | Which reads get stega: any frontend draft read, or only reads passing `context: { visualEditing: true }`. See [Enrichment modes](#enrichment-modes). |

The `VisualEditing.Provider` (client) also accepts `framedOnly` (restrict the overlay to the CMS preview iframe) and `adminBasePath` (must match the server option).

Expand Down
21 changes: 19 additions & 2 deletions packages/payload-plugin-visual-editing/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Plugin } from "payload";

import type { ValueExcludePredicate } from "./excludeValues.js";
import type { Enrichment } from "./internal/gate.js";

import { defaultExcludeValues } from "./excludeValues.js";
import { createAfterOperationHook } from "./internal/afterOperationHook.js";
Expand All @@ -22,6 +23,12 @@ export {
type ValueExcludePredicate,
} from "./excludeValues.js";

declare module "payload" {
interface RequestContext {
visualEditing?: boolean;
}
}

const INTERNAL_COLLECTIONS = [
"payload-preferences",
"payload-migrations",
Expand All @@ -40,6 +47,9 @@ export type VisualEditingPluginConfig = {
* Omit to use `defaultExcludeValues` (URL / slug / hash / ISO-date). */
excludeValues?: ValueExcludePredicate[];
adminBasePath?: string;
/** `'auto'` (default) enriches any Local API draft read outside the admin.
* `'explicit'` enriches only reads that pass `context: { visualEditing: true }`. */
enrichment?: Enrichment;
};

export const visualEditingPlugin =
Expand All @@ -49,18 +59,25 @@ export const visualEditingPlugin =
const skipGlobals = new Set(pluginConfig.skipGlobals);
const adminBasePath = pluginConfig.adminBasePath ?? "/admin";
const excludeValues = pluginConfig.excludeValues ?? defaultExcludeValues;
const enrichment = pluginConfig.enrichment ?? "auto";

const schemaCache = createSchemaCache({
excludeFieldNames: pluginConfig.excludeFieldNames,
});
const beforeOperation = createBeforeOperationHook();
const afterRead = createAfterReadHook(adminBasePath);
const afterOperation = createAfterOperationHook({ schemaCache, excludeValues, adminBasePath });
const afterRead = createAfterReadHook(adminBasePath, enrichment);
const afterOperation = createAfterOperationHook({
schemaCache,
excludeValues,
adminBasePath,
enrichment,
});
const globalBeforeRead = createGlobalBeforeReadHook();
const globalAfterRead = createGlobalAfterReadHook({
schemaCache,
excludeValues,
adminBasePath,
enrichment,
});
const stripStega = createStripStegaHook();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CollectionAfterOperationHook, CollectionSlug } from "payload";
import type { ValueExcludePredicate } from "../excludeValues.js";
import type { SchemaCache } from "./schemaCache.js";
import type { Enrichment } from "./gate.js";

import { encodeStega } from "./encodeStega.js";
import { shouldEnrich } from "./gate.js";
Expand All @@ -9,6 +10,7 @@ type Args = {
schemaCache: SchemaCache;
excludeValues: readonly ValueExcludePredicate[];
adminBasePath: string;
enrichment?: Enrichment;
};

// Payload fires afterOperation exactly once per outermost operation.
Expand All @@ -21,10 +23,11 @@ export const createAfterOperationHook = ({
schemaCache,
excludeValues,
adminBasePath,
enrichment,
}: Args): CollectionAfterOperationHook => {
return ({ operation, result, req }) => {
if (!ENCODING_OPERATIONS.has(operation)) return result;
if (!shouldEnrich(req, adminBasePath)) return result;
if (!shouldEnrich(req, adminBasePath, enrichment)) return result;

const resolve = (slug: CollectionSlug) => schemaCache.get(slug, req.payload);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { CollectionAfterReadHook } from "payload";
import type { Enrichment } from "./gate.js";

import { enrichWithPathMeta } from "./enrichWithPathMeta.js";
import { shouldEnrich } from "./gate.js";

export const createAfterReadHook = (adminBasePath: string): CollectionAfterReadHook => {
export const createAfterReadHook = (
adminBasePath: string,
enrichment?: Enrichment
): CollectionAfterReadHook => {
return ({ doc, collection, req }) => {
if (!shouldEnrich(req, adminBasePath)) return doc;
if (!shouldEnrich(req, adminBasePath, enrichment)) return doc;
if (!doc || typeof doc !== "object") return doc;

const docId =
Expand Down
10 changes: 9 additions & 1 deletion packages/payload-plugin-visual-editing/src/internal/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import type { PayloadRequest } from "payload";

import { DRAFT_CONTEXT_KEY } from "./beforeOperationHook.js";

export const shouldEnrich = (req: PayloadRequest, adminBasePath: string): boolean => {
export type Enrichment = "auto" | "explicit";

export const shouldEnrich = (
req: PayloadRequest,
adminBasePath: string,
enrichment: Enrichment = "auto"
): boolean => {
const context = req.context;

const override = context?.visualEditing;
if (typeof override === "boolean") return override;

if (enrichment === "explicit") return false;

if (context?.[DRAFT_CONTEXT_KEY] !== true) return false;
if (req.payloadAPI !== "local") return false;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GlobalAfterReadHook } from "payload";
import type { ValueExcludePredicate } from "../excludeValues.js";
import type { SchemaCache } from "./schemaCache.js";
import type { Enrichment } from "./gate.js";

import { encodeStega } from "./encodeStega.js";
import { enrichWithPathMeta } from "./enrichWithPathMeta.js";
Expand All @@ -10,6 +11,7 @@ type Args = {
schemaCache: SchemaCache;
excludeValues: readonly ValueExcludePredicate[];
adminBasePath: string;
enrichment?: Enrichment;
};

// Globals: no afterOperation hook exists, so enrichment + encoding happen here.
Expand All @@ -19,9 +21,10 @@ export const createGlobalAfterReadHook = ({
schemaCache,
excludeValues,
adminBasePath,
enrichment,
}: Args): GlobalAfterReadHook => {
return ({ doc, global, req }) => {
if (!shouldEnrich(req, adminBasePath)) return doc;
if (!shouldEnrich(req, adminBasePath, enrichment)) return doc;
if (!doc || typeof doc !== "object") return doc;

const enriched = enrichWithPathMeta(doc, {
Expand Down
39 changes: 39 additions & 0 deletions packages/payload-plugin-visual-editing/tests/gate.int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,42 @@ describe("shouldEnrich", () => {
expect(shouldEnrich(makeReq({ pathname: undefined }), "/admin")).toBe(true);
});
});

describe("shouldEnrich — explicit enrichment", () => {
const withContext = (context: Record<string, unknown>, overrides: Partial<PayloadRequest> = {}) =>
makeReq({ ...overrides, context: context as PayloadRequest["context"] });

it("skips a frontend draft local read without the visualEditing signal", () => {
expect(shouldEnrich(makeReq({ pathname: "/" }), "/admin", "explicit")).toBe(false);
});

it("enriches when context.visualEditing=true", () => {
const req = withContext({ [DRAFT_CONTEXT_KEY]: true, visualEditing: true });
expect(shouldEnrich(req, "/admin", "explicit")).toBe(true);
});

it("enriches on the signal alone, regardless of draft, API or pathname", () => {
const req = withContext(
{ [DRAFT_CONTEXT_KEY]: false, visualEditing: true },
{ payloadAPI: "REST", pathname: "/admin/collections/pages/2" }
);
expect(shouldEnrich(req, "/admin", "explicit")).toBe(true);
});

it("skips when context.visualEditing=false", () => {
const req = withContext({ [DRAFT_CONTEXT_KEY]: true, visualEditing: false });
expect(shouldEnrich(req, "/admin", "explicit")).toBe(false);
});

it("ignores a non-boolean visualEditing value", () => {
const req = withContext({ [DRAFT_CONTEXT_KEY]: true, visualEditing: "true" });
expect(shouldEnrich(req, "/admin", "explicit")).toBe(false);
});
});

describe("shouldEnrich — auto enrichment", () => {
it("is the default mode", () => {
const req = makeReq({ pathname: "/" });
expect(shouldEnrich(req, "/admin", "auto")).toBe(shouldEnrich(req, "/admin"));
});
});
Loading