@abinnovision/payloadcms-viewfinder 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ //#region src/config/plugin.ts
2
+ /**
3
+ * Payload resolves admin components by import path, so this string has to
4
+ * match the `./admin` export. Consumers must run
5
+ * `payload generate:importmap` after adding the plugin, as for any plugin
6
+ * that contributes admin components.
7
+ */ const BRIDGE_COMPONENT = "@abinnovision/payloadcms-viewfinder/admin#ViewfinderFormBridge";
8
+ const append = (existing) => {
9
+ const components = existing ?? [];
10
+ return components.includes(BRIDGE_COMPONENT) ? void 0 : [...components, BRIDGE_COMPONENT];
11
+ };
12
+ /**
13
+ * `beforeDocumentControls` is the mount point because it renders inside the
14
+ * document `<Form>`, which is what gives the bridge access to form state. The
15
+ * global `admin.components.providers` slot wraps the dashboard from outside
16
+ * every form, so a provider mounted there could never resolve a field path.
17
+ *
18
+ * Collections nest that slot under `components.edit` and globals under
19
+ * `components.elements`, hence the two shapes.
20
+ */ const withCollectionBridge = (entity) => {
21
+ const edit = entity.admin?.components?.edit;
22
+ const beforeDocumentControls = append(edit?.beforeDocumentControls);
23
+ if (!beforeDocumentControls) return entity;
24
+ return {
25
+ ...entity,
26
+ admin: {
27
+ ...entity.admin,
28
+ components: {
29
+ ...entity.admin?.components,
30
+ edit: {
31
+ ...edit,
32
+ beforeDocumentControls
33
+ }
34
+ }
35
+ }
36
+ };
37
+ };
38
+ const withGlobalBridge = (entity) => {
39
+ const elements = entity.admin?.components?.elements;
40
+ const beforeDocumentControls = append(elements?.beforeDocumentControls);
41
+ if (!beforeDocumentControls) return entity;
42
+ return {
43
+ ...entity,
44
+ admin: {
45
+ ...entity.admin,
46
+ components: {
47
+ ...entity.admin?.components,
48
+ elements: {
49
+ ...elements,
50
+ beforeDocumentControls
51
+ }
52
+ }
53
+ }
54
+ };
55
+ };
56
+ const apply = (entities, slugs, transform) => (entities ?? []).map((entity) => slugs === void 0 || slugs.includes(entity.slug) ? transform(entity) : entity);
57
+ /**
58
+ * Makes documents addressable from their live preview.
59
+ *
60
+ * The bridge it mounts is inert until a framed page announces itself, so
61
+ * enabling it for a collection that has no live preview configured costs
62
+ * nothing beyond the component itself.
63
+ */ const viewfinderPlugin = (args = {}) => {
64
+ const plugin = (config) => ({
65
+ ...config,
66
+ collections: apply(config.collections, args.collections, withCollectionBridge),
67
+ globals: apply(config.globals, args.globals, withGlobalBridge)
68
+ });
69
+ plugin.slug = "viewfinder";
70
+ return plugin;
71
+ };
72
+ //#endregion
73
+ export { viewfinderPlugin };
@@ -0,0 +1,4 @@
1
+ import { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, BlockMarkerAttributes, FIELD_ATTRIBUTE, FieldMarkerAttributes, markBlock, markField } from "./attributes.mjs";
2
+ import { AdminMessage, BlockAddress, PreviewMessage, VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, previewMessage } from "./protocol.mjs";
3
+ import { FormStateLike, resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath } from "./resolve-path.mjs";
4
+ export { type AdminMessage, BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, type BlockAddress, type BlockMarkerAttributes, FIELD_ATTRIBUTE, type FieldMarkerAttributes, type FormStateLike, type PreviewMessage, VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, markBlock, markField, previewMessage, resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath };
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, FIELD_ATTRIBUTE, markBlock, markField } from "./attributes.mjs";
2
+ import { VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, previewMessage } from "./protocol.mjs";
3
+ import { resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath } from "./resolve-path.mjs";
4
+ export { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, FIELD_ATTRIBUTE, VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, markBlock, markField, previewMessage, resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath };
@@ -0,0 +1,51 @@
1
+ //#region src/protocol.d.ts
2
+ /**
3
+ * Both windows are untrusted from the other's point of view: the preview is a
4
+ * consumer page and the admin is a separate origin. Every message therefore
5
+ * carries a source tag and a version, and is validated structurally on
6
+ * arrival rather than cast.
7
+ */
8
+ declare const VIEWFINDER_SOURCE = "viewfinder";
9
+ /**
10
+ * Bumped only on a breaking envelope change. A mismatched version is dropped
11
+ * silently, so a stale frontend deployment cannot drive a newer admin.
12
+ */
13
+ declare const VIEWFINDER_PROTOCOL_VERSION = 1;
14
+ /**
15
+ * What one message points at. `field` is relative to the block, which is what
16
+ * lets the same address survive the block moving to a different index.
17
+ */
18
+ interface BlockAddress {
19
+ id: string;
20
+ blockType?: string;
21
+ field?: string;
22
+ }
23
+ interface Envelope<TType extends string> {
24
+ source: typeof VIEWFINDER_SOURCE;
25
+ version: typeof VIEWFINDER_PROTOCOL_VERSION;
26
+ type: TType;
27
+ }
28
+ interface AddressedEnvelope<TType extends string> extends Envelope<TType> {
29
+ address: BlockAddress;
30
+ }
31
+ /** Sent by the rendered page, in the iframe, up to the admin. */
32
+ type PreviewMessage = AddressedEnvelope<"hover"> | AddressedEnvelope<"select"> | Envelope<"leave"> | Envelope<"ready">;
33
+ /** Sent by the admin down into the preview iframe. */
34
+ type AdminMessage = AddressedEnvelope<"highlight"> | AddressedEnvelope<"scrollTo"> | Envelope<"clear">;
35
+ /** Narrows the untrusted `event.data` of a `message` event from the iframe. */
36
+ declare const isPreviewMessage: (value: unknown) => value is PreviewMessage;
37
+ /** Narrows the untrusted `event.data` of a `message` event from the admin. */
38
+ declare const isAdminMessage: (value: unknown) => value is AdminMessage;
39
+ declare const previewMessage: {
40
+ readonly ready: () => PreviewMessage;
41
+ readonly leave: () => PreviewMessage;
42
+ readonly hover: (address: BlockAddress) => PreviewMessage;
43
+ readonly select: (address: BlockAddress) => PreviewMessage;
44
+ };
45
+ declare const adminMessage: {
46
+ readonly clear: () => AdminMessage;
47
+ readonly highlight: (address: BlockAddress) => AdminMessage;
48
+ readonly scrollTo: (address: BlockAddress) => AdminMessage;
49
+ };
50
+ //#endregion
51
+ export { AdminMessage, BlockAddress, PreviewMessage, VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, previewMessage };
@@ -0,0 +1,63 @@
1
+ //#region src/protocol.ts
2
+ /**
3
+ * Both windows are untrusted from the other's point of view: the preview is a
4
+ * consumer page and the admin is a separate origin. Every message therefore
5
+ * carries a source tag and a version, and is validated structurally on
6
+ * arrival rather than cast.
7
+ */ const VIEWFINDER_SOURCE = "viewfinder";
8
+ /**
9
+ * Bumped only on a breaking envelope change. A mismatched version is dropped
10
+ * silently, so a stale frontend deployment cannot drive a newer admin.
11
+ */ const VIEWFINDER_PROTOCOL_VERSION = 1;
12
+ const PREVIEW_TYPES = /* @__PURE__ */ new Set([
13
+ "hover",
14
+ "select",
15
+ "leave",
16
+ "ready"
17
+ ]);
18
+ const ADMIN_TYPES = /* @__PURE__ */ new Set([
19
+ "highlight",
20
+ "scrollTo",
21
+ "clear"
22
+ ]);
23
+ const ADDRESSED_TYPES = /* @__PURE__ */ new Set([
24
+ "hover",
25
+ "select",
26
+ "highlight",
27
+ "scrollTo"
28
+ ]);
29
+ const isAddress = (value) => {
30
+ if (typeof value !== "object" || value === null) return false;
31
+ const candidate = value;
32
+ return typeof candidate["id"] === "string" && candidate["id"].length > 0 && (candidate["blockType"] === void 0 || typeof candidate["blockType"] === "string") && (candidate["field"] === void 0 || typeof candidate["field"] === "string");
33
+ };
34
+ const isEnvelope = (value, types) => {
35
+ if (typeof value !== "object" || value === null) return false;
36
+ const candidate = value;
37
+ if (candidate["source"] !== "viewfinder" || candidate["version"] !== 1 || typeof candidate["type"] !== "string" || !types.has(candidate["type"])) return false;
38
+ return !ADDRESSED_TYPES.has(candidate["type"]) || isAddress(candidate["address"]);
39
+ };
40
+ /** Narrows the untrusted `event.data` of a `message` event from the iframe. */ const isPreviewMessage = (value) => isEnvelope(value, PREVIEW_TYPES);
41
+ /** Narrows the untrusted `event.data` of a `message` event from the admin. */ const isAdminMessage = (value) => isEnvelope(value, ADMIN_TYPES);
42
+ const bare = (type) => ({
43
+ source: VIEWFINDER_SOURCE,
44
+ version: 1,
45
+ type
46
+ });
47
+ const addressed = (type, address) => ({
48
+ ...bare(type),
49
+ address
50
+ });
51
+ const previewMessage = {
52
+ ready: () => bare("ready"),
53
+ leave: () => bare("leave"),
54
+ hover: (address) => addressed("hover", address),
55
+ select: (address) => addressed("select", address)
56
+ };
57
+ const adminMessage = {
58
+ clear: () => bare("clear"),
59
+ highlight: (address) => addressed("highlight", address),
60
+ scrollTo: (address) => addressed("scrollTo", address)
61
+ };
62
+ //#endregion
63
+ export { VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, previewMessage };
@@ -0,0 +1,46 @@
1
+ import { BlockAddress } from "./protocol.mjs";
2
+ //#region src/resolve-path.d.ts
3
+ /**
4
+ * The single place this package assumes anything about Payload internals.
5
+ *
6
+ * Payload's admin form state is already flat-keyed by field path — a block
7
+ * three levels deep appears as discrete `layout.0.modules.2.heading` entries,
8
+ * not as a nested document. That is what makes id lookup a scan rather than a
9
+ * tree walk, and it is why nothing here needs to reconstruct the document or
10
+ * know the collection's schema. If a Payload upgrade changes the key shape,
11
+ * this file is the only one that has to move.
12
+ */
13
+ type FormStateLike = Readonly<Record<string, {
14
+ value?: unknown;
15
+ } | undefined>>;
16
+ /**
17
+ * Resolves a Payload row `id` to its form path (`layout.0.modules.2`).
18
+ *
19
+ * Ids are unique in practice; if two rows somehow carry the same one, the
20
+ * shallowest path wins so the result stays deterministic rather than
21
+ * depending on key order.
22
+ */
23
+ declare const resolveBlockPath: (formState: FormStateLike, id: string) => string | undefined;
24
+ /** Joins a block path and a block-relative field name into a form path. */
25
+ declare const resolveFieldPath: (blockPath: string, field: string) => string;
26
+ /**
27
+ * Resolves a whole address to the form path the admin should reveal: the
28
+ * block itself, or a field inside it when the preview named one.
29
+ */
30
+ declare const resolveAddressPath: (formState: FormStateLike, address: BlockAddress) => string | undefined;
31
+ /**
32
+ * The inverse, for the admin-to-preview direction: given any form path, finds
33
+ * the id of the nearest enclosing block.
34
+ *
35
+ * Walks ancestors deepest-first and requires a sibling `blockType`, which is
36
+ * what distinguishes a block row from a plain array row — array rows also
37
+ * carry an `id`, but the preview knows nothing about them.
38
+ */
39
+ declare const resolveBlockIdForPath: (formState: FormStateLike, path: string) => string | undefined;
40
+ /**
41
+ * The address to send into the preview for a form path, carrying the field
42
+ * suffix when the path pointed inside a block rather than at it.
43
+ */
44
+ declare const resolveAddressForPath: (formState: FormStateLike, path: string) => BlockAddress | undefined;
45
+ //#endregion
46
+ export { FormStateLike, resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath };
@@ -0,0 +1,63 @@
1
+ //#region src/resolve-path.ts
2
+ const ID_SUFFIX = ".id";
3
+ const BLOCK_TYPE_SUFFIX = ".blockType";
4
+ /**
5
+ * The document's own `id` lives at the bare key `"id"`, which has no dot and
6
+ * so is never a candidate. Every other `*.id` is a block row or an array row.
7
+ */ const isRowIdKey = (key) => key.endsWith(ID_SUFFIX);
8
+ /**
9
+ * Resolves a Payload row `id` to its form path (`layout.0.modules.2`).
10
+ *
11
+ * Ids are unique in practice; if two rows somehow carry the same one, the
12
+ * shallowest path wins so the result stays deterministic rather than
13
+ * depending on key order.
14
+ */ const resolveBlockPath = (formState, id) => {
15
+ let best;
16
+ for (const key of Object.keys(formState)) {
17
+ if (!isRowIdKey(key) || formState[key]?.value !== id) continue;
18
+ const path = key.slice(0, -3);
19
+ if (best === void 0 || path.length < best.length) best = path;
20
+ }
21
+ return best;
22
+ };
23
+ /** Joins a block path and a block-relative field name into a form path. */ const resolveFieldPath = (blockPath, field) => `${blockPath}.${field}`;
24
+ /**
25
+ * Resolves a whole address to the form path the admin should reveal: the
26
+ * block itself, or a field inside it when the preview named one.
27
+ */ const resolveAddressPath = (formState, address) => {
28
+ const blockPath = resolveBlockPath(formState, address.id);
29
+ if (blockPath === void 0 || address.field === void 0) return blockPath;
30
+ return resolveFieldPath(blockPath, address.field);
31
+ };
32
+ /**
33
+ * The inverse, for the admin-to-preview direction: given any form path, finds
34
+ * the id of the nearest enclosing block.
35
+ *
36
+ * Walks ancestors deepest-first and requires a sibling `blockType`, which is
37
+ * what distinguishes a block row from a plain array row — array rows also
38
+ * carry an `id`, but the preview knows nothing about them.
39
+ */ const resolveBlockIdForPath = (formState, path) => {
40
+ const segments = path.split(".");
41
+ for (let end = segments.length; end > 0; end--) {
42
+ const prefix = segments.slice(0, end).join(".");
43
+ if (formState[`${prefix}${BLOCK_TYPE_SUFFIX}`] === void 0) continue;
44
+ const id = formState[`${prefix}${ID_SUFFIX}`]?.value;
45
+ if (typeof id === "string" && id.length > 0) return id;
46
+ }
47
+ };
48
+ /**
49
+ * The address to send into the preview for a form path, carrying the field
50
+ * suffix when the path pointed inside a block rather than at it.
51
+ */ const resolveAddressForPath = (formState, path) => {
52
+ const id = resolveBlockIdForPath(formState, path);
53
+ if (id === void 0) return;
54
+ const blockPath = resolveBlockPath(formState, id);
55
+ const blockType = formState[`${blockPath ?? path}${BLOCK_TYPE_SUFFIX}`]?.value;
56
+ return {
57
+ id,
58
+ ...typeof blockType === "string" ? { blockType } : {},
59
+ ...blockPath === void 0 || blockPath === path ? {} : { field: path.slice(blockPath.length + 1) }
60
+ };
61
+ };
62
+ //#endregion
63
+ export { resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath };
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@abinnovision/payloadcms-viewfinder",
4
+ "version": "1.0.0-beta.1",
5
+ "description": "Two-way block addressing between a rendered frontend and the Payload CMS admin form.",
6
+ "keywords": [
7
+ "payload",
8
+ "payloadcms",
9
+ "live-preview",
10
+ "visual-editing",
11
+ "blocks"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/abinnovision/payloadcms-commons.git",
16
+ "directory": "packages/viewfinder"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "abi group GmbH",
21
+ "email": "info@abigroup.io",
22
+ "url": "https://abigroup.io"
23
+ },
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "default": "./dist/index.mjs"
29
+ },
30
+ "./client": {
31
+ "types": "./dist/client/index.d.mts",
32
+ "default": "./dist/client/index.mjs"
33
+ },
34
+ "./config": {
35
+ "types": "./dist/config/index.d.mts",
36
+ "default": "./dist/config/index.mjs"
37
+ },
38
+ "./admin": {
39
+ "types": "./dist/admin/index.d.mts",
40
+ "default": "./dist/admin/index.mjs"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "LICENSE",
46
+ "README.md"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "format:check": "prettier --check 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
51
+ "format:fix": "prettier --write 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
52
+ "lint:check": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts'",
53
+ "lint:fix": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts' --fix",
54
+ "test-unit": "vitest --run --coverage --config vitest.config.mts",
55
+ "test-unit:watch": "vitest --config vitest.config.mts",
56
+ "typecheck": "tsc --noEmit"
57
+ },
58
+ "lint-staged": {
59
+ "src/**/*.{ts,tsx}": [
60
+ "eslint --fix",
61
+ "prettier --write"
62
+ ],
63
+ "*.{json{,5},md,y{,a}ml}": "prettier --write"
64
+ },
65
+ "prettier": "@abinnovision/prettier-config",
66
+ "devDependencies": {
67
+ "@abinnovision/eslint-config-base": "^3.4.2",
68
+ "@abinnovision/prettier-config": "^2.2.0",
69
+ "@arethetypeswrong/core": "^0.18.5",
70
+ "@payloadcms/ui": "3.88.0",
71
+ "@swc/core": "^1.16.1",
72
+ "@types/node": "^26.3.0",
73
+ "@types/react": "^19.2.18",
74
+ "@vitest/coverage-v8": "^4.1.10",
75
+ "eslint": "^10.9.1",
76
+ "payload": "3.88.0",
77
+ "prettier": "^3.9.5",
78
+ "publint": "^0.3.24",
79
+ "react": "^19.2.8",
80
+ "react-dom": "^19.2.8",
81
+ "tsdown": "^0.22.14",
82
+ "typescript": "^6.0.3",
83
+ "unplugin-swc": "^1.5.11",
84
+ "vitest": "^4.1.10"
85
+ },
86
+ "peerDependencies": {
87
+ "@payloadcms/ui": ">=3.88.0 <4",
88
+ "payload": ">=3.88.0 <4",
89
+ "react": "^19",
90
+ "react-dom": "^19"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@payloadcms/ui": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "publishConfig": {
98
+ "ghpr": true,
99
+ "npm": true,
100
+ "npmAccess": "public"
101
+ }
102
+ }