@rocksoliddigital/payload-lineage 0.0.0-stage → 0.2.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rock Solid Digital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # 🧬 @rocksoliddigital/payload-lineage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The content side of lineage, from a Payload config, as two pages in the admin:
4
+
5
+ - **Lineage** (`/admin/lineage`): collections, globals and blocks, and the fields that link them,
6
+ read left to right from what each is built from to where it is used: `media` →
7
+ `block:mediaBlock` → `pages`. Hover a node to trace everything upstream and downstream; click it
8
+ for the fields behind each link.
9
+ - **Data model** (`/admin/lineage/schema`): an ERD, with a table per collection and global listing
10
+ its fields, and a line from each field that references another collection, labelled with its
11
+ cardinality:
12
+
13
+ | Label | Means |
14
+ | -------------- | ------------------------------------------------------------------------- |
15
+ | `many-to-one` | One reference per document, which many documents can share |
16
+ | `many-to-many` | A list of references: `hasMany`, or inside an array or blocks field |
17
+ | `one-to-one` | A `unique` reference, or a global's single one (a global is one document) |
18
+ | `one-to-many` | A global's list of references |
19
+
20
+ Both are built from the running config on each visit (well under a millisecond), so they are never
21
+ out of date. A "Lineage" link in the nav opens them. To try them, run the
22
+ [example](https://github.com/rock-solid/lineage-kit/tree/main/examples/payload).
23
+
24
+ Works with Payload 3 on Next.js 15 or 16, with React 19.
25
+
26
+ > **Status:** early. Both pages come from the config only. Which pages use which blocks (from the
27
+ > documents), shared field configs (by import scan) and merging into the Storybook page are next;
28
+ > see the [roadmap](https://github.com/rock-solid/lineage-kit/blob/main/ROADMAP.md#-payload-plugin).
29
+
30
+ ## 📥 Install
31
+
32
+ Not published yet: the first release, 0.2.0, is on the
33
+ [roadmap](https://github.com/rock-solid/lineage-kit/blob/main/ROADMAP.md#-payload-plugin). Then:
34
+
35
+ ```sh
36
+ pnpm add @rocksoliddigital/payload-lineage
37
+ ```
38
+
39
+ ## ⚙️ Set up
40
+
41
+ Add the plugin to `payload.config.ts`:
42
+
43
+ ```ts
44
+ import { lineagePlugin } from '@rocksoliddigital/payload-lineage';
45
+
46
+ export default buildConfig({
47
+ // ...
48
+ plugins: [lineagePlugin()],
49
+ });
50
+ ```
51
+
52
+ Then update the import map, so the admin can find the pages. `next dev` does this on its own; for a
53
+ build, run:
54
+
55
+ ```sh
56
+ pnpm payload generate:importmap
57
+ ```
58
+
59
+ | Option | Default | |
60
+ | ------------ | ------- | --------------------------------------------------------------------------------- |
61
+ | `adminView` | `true` | The two admin pages and the nav link |
62
+ | `access` | none | `({ user, payload }) => boolean`, on top of admin access; see [Access](#-access) |
63
+ | `outputFile` | none | Where to write the lineage graph as JSON on each start, outside production only |
64
+ | `exclude` | `[]` | Slugs to leave out of both pages and the file; `block:text` names only that block |
65
+ | `enabled` | `true` | Set to `false` to turn the plugin off |
66
+
67
+ Payload's own collections (`payload-preferences`, `payload-migrations`, ...) are always left out. If
68
+ the file can't be written (e.g. a read-only filesystem), Payload logs a warning and starts anyway.
69
+
70
+ To leave out form-builder's field blocks, which crowd the lineage graph:
71
+
72
+ ```ts
73
+ lineagePlugin({
74
+ exclude: [
75
+ 'checkbox',
76
+ 'country',
77
+ 'email',
78
+ 'message',
79
+ 'number',
80
+ 'select',
81
+ 'state',
82
+ 'text',
83
+ 'textarea',
84
+ ].map((s) => `block:${s}`),
85
+ });
86
+ ```
87
+
88
+ Or build either yourself, e.g. in a script:
89
+
90
+ ```ts
91
+ import { buildContentGraph, buildSchema } from '@rocksoliddigital/payload-lineage';
92
+ import config from '@payload-config';
93
+
94
+ const graph = buildContentGraph(await config);
95
+ const schema = buildSchema(await config);
96
+ ```
97
+
98
+ ## 🔒 Access
99
+
100
+ The pages and their nav link are for users who can use the admin (Payload's `access.admin` on the
101
+ users collection, which Payload itself doesn't check on custom pages), and who pass the plugin's
102
+ `access`, if set:
103
+
104
+ ```ts
105
+ lineagePlugin({ access: ({ user }) => user.role === 'admin' });
106
+ ```
107
+
108
+ Anyone else is sent where Payload sends them for its own pages: the login page when logged out, the
109
+ unauthorized page when logged in, each with a `redirect` back. The pages show only the collections
110
+ and globals the user can read, along with every link to or from them; blocks have no access control
111
+ of their own, so they're all shown. Everything is checked and built on the server, so nothing a user
112
+ can't see reaches the browser.
113
+
114
+ ## 📄 The lineage file
115
+
116
+ ```json
117
+ {
118
+ "nodes": [
119
+ { "id": "collection:media", "kind": "collection", "slug": "media", "label": "Media" },
120
+ { "id": "block:mediaBlock", "kind": "block", "slug": "mediaBlock", "label": "Media Block" }
121
+ ],
122
+ "edges": [
123
+ {
124
+ "source": "collection:media",
125
+ "target": "block:mediaBlock",
126
+ "kind": "upload",
127
+ "fields": ["media"]
128
+ }
129
+ ]
130
+ }
131
+ ```
132
+
133
+ Node ids are `<kind>:<slug>`, so they can't clash with the file paths the Storybook addon uses as
134
+ ids. `source` is the dependency and `target` what uses it, as in the Storybook addon. Join fields are
135
+ left out, since each is the reverse of a relationship that is already an edge. A collection that
136
+ links to itself, like nested-docs' `parent`, keeps the edge to itself.
137
+
138
+ ## 🗂️ The data model
139
+
140
+ `buildSchema` flattens groups, rows, collapsibles and tabs into dotted rows (`hero.media`), and keeps
141
+ each array and blocks field as one row (`gallery[]`, `layout`). A reference inside one of those
142
+ attaches to that row, with its full path: `layout[mediaBlock].media`. Hidden fields (an auth
143
+ collection's `salt` and `hash`) and UI fields are left out. A join field is a row but not a line,
144
+ since it reverses a relationship that already is one.
@@ -0,0 +1,203 @@
1
+ // src/graph.ts
2
+ var INTERNAL_COLLECTION = /^payload-/;
3
+ function buildContentGraph(config, { exclude = [] } = {}) {
4
+ const excluded = (kind, slug) => kind === "collection" && INTERNAL_COLLECTION.test(slug) || exclude.includes(slug) || exclude.includes(`${kind}:${slug}`);
5
+ const collections = (config.collections ?? []).filter((c) => !excluded("collection", c.slug));
6
+ const globals = (config.globals ?? []).filter((g) => !excluded("global", g.slug));
7
+ const sharedBlocks = new Map((config.blocks ?? []).map((b) => [b.slug, b]));
8
+ const nodes = /* @__PURE__ */ new Map();
9
+ const addNode = (kind, slug, label) => {
10
+ const id = `${kind}:${slug}`;
11
+ if (!nodes.has(id)) nodes.set(id, { id, kind, slug, label });
12
+ return id;
13
+ };
14
+ for (const c of collections) addNode("collection", c.slug, labelOf(c, c.labels?.plural));
15
+ for (const g of globals) addNode("global", g.slug, labelOf(g, g.label));
16
+ const collectionIds = new Set(collections.map((c) => `collection:${c.slug}`));
17
+ const edges = /* @__PURE__ */ new Map();
18
+ const addEdge = (source, target, kind, field) => {
19
+ const key = `${source}\u2192${target}\u2192${kind}`;
20
+ const edge = edges.get(key) ?? { source, target, kind, fields: [] };
21
+ if (!edge.fields.includes(field)) edge.fields.push(field);
22
+ edges.set(key, edge);
23
+ };
24
+ const walkedBlocks = /* @__PURE__ */ new Set();
25
+ const walkBlock = (block) => {
26
+ const id = addNode("block", block.slug, labelOf(block, block.labels?.singular));
27
+ if (walkedBlocks.has(block)) return id;
28
+ walkedBlocks.add(block);
29
+ walkFields(block.fields, id, []);
30
+ return id;
31
+ };
32
+ const walkFields = (fields, owner, path) => {
33
+ for (const field of fields) {
34
+ const fieldPath = field.name ? [...path, field.name] : path;
35
+ const dotted = fieldPath.join(".");
36
+ if (field.type === "relationship" || field.type === "upload") {
37
+ for (const slug of toArray(field.relationTo)) {
38
+ const source = `collection:${slug}`;
39
+ if (collectionIds.has(source)) addEdge(source, owner, field.type, dotted);
40
+ }
41
+ }
42
+ if (field.type === "blocks") {
43
+ const blocks = [
44
+ ...field.blocks ?? [],
45
+ ...(field.blockReferences ?? []).map(
46
+ (b) => typeof b === "string" ? sharedBlocks.get(b) : b
47
+ )
48
+ ];
49
+ for (const block of blocks) {
50
+ if (block && !excluded("block", block.slug)) {
51
+ addEdge(walkBlock(block), owner, "blocks", dotted);
52
+ }
53
+ }
54
+ }
55
+ if (field.fields) walkFields(field.fields, owner, fieldPath);
56
+ for (const tab of field.tabs ?? []) {
57
+ walkFields(tab.fields, owner, tab.name ? [...fieldPath, tab.name] : fieldPath);
58
+ }
59
+ }
60
+ };
61
+ for (const c of collections) walkFields(c.fields, `collection:${c.slug}`, []);
62
+ for (const g of globals) walkFields(g.fields, `global:${g.slug}`, []);
63
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
64
+ }
65
+ function labelOf(entity, label) {
66
+ return typeof label === "string" ? label : entity.slug;
67
+ }
68
+ function toArray(value) {
69
+ if (value === void 0) return [];
70
+ return Array.isArray(value) ? value : [value];
71
+ }
72
+
73
+ // src/schema.ts
74
+ var INTERNAL_COLLECTION2 = /^payload-/;
75
+ var LAYOUT = /* @__PURE__ */ new Set(["row", "collapsible"]);
76
+ function buildSchema(config, { exclude = [] } = {}) {
77
+ const { collections = [], globals = [], blocks = [] } = config;
78
+ const excluded = (kind, slug) => kind === "collection" && INTERNAL_COLLECTION2.test(slug) || exclude.includes(slug) || exclude.includes(`${kind}:${slug}`);
79
+ const shownCollections = collections.filter((c) => !excluded("collection", c.slug));
80
+ const shownGlobals = globals.filter((g) => !excluded("global", g.slug));
81
+ const targets = new Set(shownCollections.map((c) => c.slug));
82
+ const sharedBlocks = new Map(blocks.map((b) => [b.slug, b]));
83
+ const relations = [];
84
+ const entity = (kind, e) => {
85
+ const id = `${kind}:${e.slug}`;
86
+ const fields = [];
87
+ const onPath = /* @__PURE__ */ new Set();
88
+ const walk = (list, path, row, many) => {
89
+ for (const field of list) {
90
+ if (field.type === "ui" || field.hidden) continue;
91
+ if (LAYOUT.has(field.type) || field.type === "group" && !field.name) {
92
+ walk(field.fields ?? [], path, row, many);
93
+ continue;
94
+ }
95
+ if (field.type === "tabs") {
96
+ for (const tab of field.tabs ?? []) {
97
+ walk(tab.fields, tab.name ? join(path, tab.name) : path, row, many);
98
+ }
99
+ continue;
100
+ }
101
+ const name = join(path, field.name ?? field.type);
102
+ if (field.type === "group") {
103
+ walk(field.fields ?? [], name, row, many);
104
+ continue;
105
+ }
106
+ if (field.type === "array") {
107
+ const own2 = row ?? add({ name: `${name}[]`, type: "array", required: field.required });
108
+ walk(field.fields ?? [], `${name}[]`, own2, true);
109
+ continue;
110
+ }
111
+ if (field.type === "blocks") {
112
+ const allowed = [
113
+ ...field.blocks ?? [],
114
+ ...(field.blockReferences ?? []).map(
115
+ (b) => typeof b === "string" ? sharedBlocks.get(b) : b
116
+ )
117
+ ].filter((b) => b !== void 0 && !excluded("block", b.slug));
118
+ const own2 = row ?? add({
119
+ name,
120
+ type: "blocks",
121
+ required: field.required,
122
+ blocks: allowed.map((b) => b.slug)
123
+ });
124
+ for (const block of allowed) {
125
+ if (onPath.has(block)) continue;
126
+ onPath.add(block);
127
+ walk(block.fields, `${name}[${block.slug}]`, own2, true);
128
+ onPath.delete(block);
129
+ }
130
+ continue;
131
+ }
132
+ const own = row ?? add({
133
+ name,
134
+ type: field.type,
135
+ required: field.required,
136
+ hasMany: field.hasMany,
137
+ localized: field.localized,
138
+ unique: field.unique,
139
+ ...(field.relationTo || field.collection) && {
140
+ relationTo: toArray2(field.relationTo ?? field.collection)
141
+ }
142
+ });
143
+ if (field.type === "relationship" || field.type === "upload") {
144
+ const to = toArray2(field.relationTo);
145
+ for (const slug of to) {
146
+ if (!targets.has(slug)) continue;
147
+ relations.push({
148
+ from: id,
149
+ field: own.name,
150
+ path: name,
151
+ to: `collection:${slug}`,
152
+ kind: field.type,
153
+ many: many || !!field.hasMany,
154
+ polymorphic: to.length > 1,
155
+ unique: !!field.unique
156
+ });
157
+ }
158
+ }
159
+ }
160
+ };
161
+ const add = (field) => {
162
+ const clean = Object.fromEntries(
163
+ Object.entries(field).filter(([, v]) => v !== void 0 && v !== false)
164
+ );
165
+ fields.push(clean);
166
+ return clean;
167
+ };
168
+ walk(e.fields, "", null, false);
169
+ if (kind === "collection" && !fields.some((f) => f.name === "id")) {
170
+ fields.unshift({ name: "id", type: "id", required: true });
171
+ }
172
+ return {
173
+ id,
174
+ kind,
175
+ slug: e.slug,
176
+ label: labelOf2(e, kind === "collection" ? e.labels?.plural : e.label),
177
+ singular: labelOf2(e, kind === "collection" ? e.labels?.singular : e.label),
178
+ ...kind === "collection" && e.auth ? { auth: true } : {},
179
+ ...kind === "collection" && e.upload ? { upload: true } : {},
180
+ fields
181
+ };
182
+ };
183
+ const entities = [
184
+ ...shownCollections.map((c) => entity("collection", c)),
185
+ ...shownGlobals.map((g) => entity("global", g))
186
+ ];
187
+ return { entities, relations };
188
+ }
189
+ function join(path, name) {
190
+ return path ? `${path}.${name}` : name;
191
+ }
192
+ function labelOf2(entity, label) {
193
+ return typeof label === "string" ? label : entity.slug;
194
+ }
195
+ function toArray2(value) {
196
+ if (value === void 0) return [];
197
+ return Array.isArray(value) ? value : [value];
198
+ }
199
+
200
+ export {
201
+ buildContentGraph,
202
+ buildSchema
203
+ };
@@ -0,0 +1,104 @@
1
+ import React from 'react';
2
+
3
+ type ContentKind = 'collection' | 'global' | 'block';
4
+ type ContentNode = {
5
+ /** `<kind>:<slug>`, e.g. `collection:pages` or `block:hero`. */
6
+ id: string;
7
+ kind: ContentKind;
8
+ slug: string;
9
+ label: string;
10
+ };
11
+ /** What makes one node depend on another. */
12
+ type ContentEdgeKind = 'relationship' | 'upload' | 'blocks';
13
+ /**
14
+ * `source` is the dependency, `target` the collection, global or block whose fields point at it,
15
+ * so the graph reads left to right like the Storybook addon's: `media` → `pages`.
16
+ */
17
+ type ContentEdge = {
18
+ source: string;
19
+ target: string;
20
+ kind: ContentEdgeKind;
21
+ /** Dotted paths of the fields that make the link, e.g. `layout` or `hero.image`. */
22
+ fields: string[];
23
+ };
24
+ type ContentGraph = {
25
+ nodes: ContentNode[];
26
+ edges: ContentEdge[];
27
+ };
28
+
29
+ type SchemaField = {
30
+ /** Dotted from the top level; arrays end in `[]`, e.g. `hero.media` or `gallery[]`. */
31
+ name: string;
32
+ type: string;
33
+ required?: boolean;
34
+ /** A relationship, upload, select, text or number field holding a list. */
35
+ hasMany?: boolean;
36
+ localized?: boolean;
37
+ unique?: boolean;
38
+ /** Collections a relationship, upload or join field points at. */
39
+ relationTo?: string[];
40
+ /** Block slugs a blocks field allows. */
41
+ blocks?: string[];
42
+ };
43
+ type SchemaEntity = {
44
+ /** `collection:<slug>` or `global:<slug>`, as in the content graph. */
45
+ id: string;
46
+ kind: 'collection' | 'global';
47
+ slug: string;
48
+ /** Plural for a collection (`Pages`), the global's name for a global. */
49
+ label: string;
50
+ /** One document: `Page`. A global's name, since it is one already. */
51
+ singular: string;
52
+ /** An auth collection (users) or an upload collection (media). */
53
+ auth?: boolean;
54
+ upload?: boolean;
55
+ fields: SchemaField[];
56
+ };
57
+ type SchemaRelation = {
58
+ /** The entity holding the reference. */
59
+ from: string;
60
+ /** The row in `from` it belongs to: the field itself, or the array or blocks field around it. */
61
+ field: string;
62
+ /** The full path of the referencing field, e.g. `layout[mediaBlock].media`. */
63
+ path: string;
64
+ /** The collection referenced. */
65
+ to: string;
66
+ kind: 'relationship' | 'upload';
67
+ /** Many references per document: `hasMany`, or inside an array or blocks field. */
68
+ many: boolean;
69
+ /** The field can point at more than one collection. */
70
+ polymorphic: boolean;
71
+ /** No two documents may reference the same one: a `unique` field. */
72
+ unique: boolean;
73
+ };
74
+ type Schema = {
75
+ entities: SchemaEntity[];
76
+ relations: SchemaRelation[];
77
+ };
78
+
79
+ type ErdCanvasProps = {
80
+ schema: Schema;
81
+ /** Admin URLs of the collections and globals, by entity id, for the "Open" links. */
82
+ adminURLs: Record<string, string>;
83
+ };
84
+ /** The data model as an ERD: a table per collection and global, a line per reference. */
85
+ declare const ErdCanvas: React.FC<ErdCanvasProps>;
86
+
87
+ type LineageCanvasProps = {
88
+ graph: ContentGraph;
89
+ /** Admin URLs of the collections and globals, by node id, for the "Open" links. */
90
+ adminURLs: Record<string, string>;
91
+ };
92
+ /**
93
+ * The content graph, left to right from what an entity is built from to where it is used. Hover to
94
+ * trace, click for the fields behind each link.
95
+ */
96
+ declare const LineageCanvas: React.FC<LineageCanvasProps>;
97
+
98
+ /**
99
+ * "Lineage" in the admin's nav, after the collections and globals, marked like theirs on either
100
+ * lineage page. Rendered by the server component `LineageNavLink`, which checks access first.
101
+ */
102
+ declare const LineageNavLinkClient: React.FC;
103
+
104
+ export { ErdCanvas, type ErdCanvasProps, LineageCanvas, type LineageCanvasProps, LineageNavLinkClient };