@next-buildr/payload 0.0.0-stage → 2.0.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 +21 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/adapter/adapter.test.d.ts +2 -0
- package/dist/adapter/adapter.test.d.ts.map +1 -0
- package/dist/adapter/adapter.test.js +282 -0
- package/dist/adapter/canvas-data-source.d.ts +9 -0
- package/dist/adapter/canvas-data-source.d.ts.map +1 -0
- package/dist/adapter/canvas-data-source.js +24 -0
- package/dist/adapter/document-adapter.d.ts +20 -0
- package/dist/adapter/document-adapter.d.ts.map +1 -0
- package/dist/adapter/document-adapter.js +183 -0
- package/dist/adapter/http.d.ts +34 -0
- package/dist/adapter/http.d.ts.map +1 -0
- package/dist/adapter/http.js +72 -0
- package/dist/adapter/index.d.ts +4 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +5 -0
- package/dist/admin/index.d.ts +4 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/layout-field-connected.d.ts +6 -0
- package/dist/admin/layout-field-connected.d.ts.map +1 -0
- package/dist/admin/layout-field-connected.js +16 -0
- package/dist/admin/layout-field-view.d.ts +16 -0
- package/dist/admin/layout-field-view.d.ts.map +1 -0
- package/dist/admin/layout-field-view.js +14 -0
- package/dist/admin/layout-field.d.ts +6 -0
- package/dist/admin/layout-field.d.ts.map +1 -0
- package/dist/admin/layout-field.js +13 -0
- package/dist/admin/layout-field.test.d.ts +2 -0
- package/dist/admin/layout-field.test.d.ts.map +1 -0
- package/dist/admin/layout-field.test.js +41 -0
- package/dist/contract.d.ts +289 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +176 -0
- package/dist/data/build-context.d.ts +20 -0
- package/dist/data/build-context.d.ts.map +1 -0
- package/dist/data/build-context.js +25 -0
- package/dist/data/data.test.d.ts +2 -0
- package/dist/data/data.test.d.ts.map +1 -0
- package/dist/data/data.test.js +261 -0
- package/dist/data/index.d.ts +10 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +5 -0
- package/dist/data/normalize.d.ts +14 -0
- package/dist/data/normalize.d.ts.map +1 -0
- package/dist/data/normalize.js +128 -0
- package/dist/data/payload-data-source.d.ts +42 -0
- package/dist/data/payload-data-source.d.ts.map +1 -0
- package/dist/data/payload-data-source.js +179 -0
- package/dist/data/payload-data-source.test.d.ts +2 -0
- package/dist/data/payload-data-source.test.d.ts.map +1 -0
- package/dist/data/payload-data-source.test.js +160 -0
- package/dist/data/resolve-layout.d.ts +45 -0
- package/dist/data/resolve-layout.d.ts.map +1 -0
- package/dist/data/resolve-layout.js +111 -0
- package/dist/data/schema-from-fields.d.ts +63 -0
- package/dist/data/schema-from-fields.d.ts.map +1 -0
- package/dist/data/schema-from-fields.js +178 -0
- package/dist/data/where.d.ts +53 -0
- package/dist/data/where.d.ts.map +1 -0
- package/dist/data/where.js +210 -0
- package/dist/mcp/http-backend.d.ts +35 -0
- package/dist/mcp/http-backend.d.ts.map +1 -0
- package/dist/mcp/http-backend.js +337 -0
- package/dist/mcp/http-backend.test.d.ts +2 -0
- package/dist/mcp/http-backend.test.d.ts.map +1 -0
- package/dist/mcp/http-backend.test.js +178 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/route.d.ts +63 -0
- package/dist/mcp/route.d.ts.map +1 -0
- package/dist/mcp/route.js +230 -0
- package/dist/next/get-document.d.ts +64 -0
- package/dist/next/get-document.d.ts.map +1 -0
- package/dist/next/get-document.js +208 -0
- package/dist/next/get-document.test.d.ts +2 -0
- package/dist/next/get-document.test.d.ts.map +1 -0
- package/dist/next/get-document.test.js +155 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +4 -0
- package/dist/next/next-helpers.test.d.ts +2 -0
- package/dist/next/next-helpers.test.d.ts.map +1 -0
- package/dist/next/next-helpers.test.js +122 -0
- package/dist/next/revalidate.d.ts +44 -0
- package/dist/next/revalidate.d.ts.map +1 -0
- package/dist/next/revalidate.js +45 -0
- package/dist/next/seo.d.ts +43 -0
- package/dist/next/seo.d.ts.map +1 -0
- package/dist/next/seo.js +53 -0
- package/dist/next/tags.d.ts +23 -0
- package/dist/next/tags.d.ts.map +1 -0
- package/dist/next/tags.js +31 -0
- package/dist/plugin/access.d.ts +19 -0
- package/dist/plugin/access.d.ts.map +1 -0
- package/dist/plugin/access.js +32 -0
- package/dist/plugin/collections/form-submissions.d.ts +10 -0
- package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
- package/dist/plugin/collections/form-submissions.js +37 -0
- package/dist/plugin/collections/templates.d.ts +13 -0
- package/dist/plugin/collections/templates.d.ts.map +1 -0
- package/dist/plugin/collections/templates.js +118 -0
- package/dist/plugin/endpoints/access.test.d.ts +2 -0
- package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/access.test.js +91 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
- package/dist/plugin/endpoints/context.d.ts +65 -0
- package/dist/plugin/endpoints/context.d.ts.map +1 -0
- package/dist/plugin/endpoints/context.js +113 -0
- package/dist/plugin/endpoints/data-context.d.ts +8 -0
- package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-context.js +63 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
- package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-query.test.js +96 -0
- package/dist/plugin/endpoints/data-schema.d.ts +5 -0
- package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-schema.js +21 -0
- package/dist/plugin/endpoints/data.d.ts +10 -0
- package/dist/plugin/endpoints/data.d.ts.map +1 -0
- package/dist/plugin/endpoints/data.js +96 -0
- package/dist/plugin/endpoints/document.d.ts +11 -0
- package/dist/plugin/endpoints/document.d.ts.map +1 -0
- package/dist/plugin/endpoints/document.js +119 -0
- package/dist/plugin/endpoints/documents.d.ts +10 -0
- package/dist/plugin/endpoints/documents.d.ts.map +1 -0
- package/dist/plugin/endpoints/documents.js +145 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test.js +209 -0
- package/dist/plugin/endpoints/forms.d.ts +16 -0
- package/dist/plugin/endpoints/forms.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.js +199 -0
- package/dist/plugin/endpoints/forms.test.d.ts +2 -0
- package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.test.js +346 -0
- package/dist/plugin/endpoints/guards.d.ts +9 -0
- package/dist/plugin/endpoints/guards.d.ts.map +1 -0
- package/dist/plugin/endpoints/guards.js +35 -0
- package/dist/plugin/endpoints/index.d.ts +6 -0
- package/dist/plugin/endpoints/index.d.ts.map +1 -0
- package/dist/plugin/endpoints/index.js +32 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
- package/dist/plugin/endpoints/locales.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales.test.js +128 -0
- package/dist/plugin/endpoints/manifest.d.ts +5 -0
- package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
- package/dist/plugin/endpoints/manifest.js +18 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-off.test.js +33 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-route.test.js +132 -0
- package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp.test.js +231 -0
- package/dist/plugin/endpoints/media.d.ts +7 -0
- package/dist/plugin/endpoints/media.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.js +115 -0
- package/dist/plugin/endpoints/media.test.d.ts +2 -0
- package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.test.js +95 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
- package/dist/plugin/endpoints/publish.d.ts +9 -0
- package/dist/plugin/endpoints/publish.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish.js +81 -0
- package/dist/plugin/endpoints/rename.d.ts +9 -0
- package/dist/plugin/endpoints/rename.d.ts.map +1 -0
- package/dist/plugin/endpoints/rename.js +47 -0
- package/dist/plugin/endpoints/respond.d.ts +9 -0
- package/dist/plugin/endpoints/respond.d.ts.map +1 -0
- package/dist/plugin/endpoints/respond.js +10 -0
- package/dist/plugin/endpoints/samples.d.ts +5 -0
- package/dist/plugin/endpoints/samples.d.ts.map +1 -0
- package/dist/plugin/endpoints/samples.js +42 -0
- package/dist/plugin/endpoints/save.d.ts +9 -0
- package/dist/plugin/endpoints/save.d.ts.map +1 -0
- package/dist/plugin/endpoints/save.js +58 -0
- package/dist/plugin/endpoints/session.d.ts +5 -0
- package/dist/plugin/endpoints/session.d.ts.map +1 -0
- package/dist/plugin/endpoints/session.js +30 -0
- package/dist/plugin/endpoints/templates.test.d.ts +2 -0
- package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/templates.test.js +191 -0
- package/dist/plugin/fields.d.ts +28 -0
- package/dist/plugin/fields.d.ts.map +1 -0
- package/dist/plugin/fields.js +62 -0
- package/dist/plugin/forms/forms.test.d.ts +2 -0
- package/dist/plugin/forms/forms.test.d.ts.map +1 -0
- package/dist/plugin/forms/forms.test.js +88 -0
- package/dist/plugin/forms/notify.d.ts +6 -0
- package/dist/plugin/forms/notify.d.ts.map +1 -0
- package/dist/plugin/forms/notify.js +13 -0
- package/dist/plugin/forms/rate-limit.d.ts +24 -0
- package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
- package/dist/plugin/forms/rate-limit.js +36 -0
- package/dist/plugin/forms/validate.d.ts +22 -0
- package/dist/plugin/forms/validate.d.ts.map +1 -0
- package/dist/plugin/forms/validate.js +79 -0
- package/dist/plugin/hooks/layout-hook.d.ts +9 -0
- package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.js +33 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.test.js +191 -0
- package/dist/plugin/hooks/process-layout.d.ts +32 -0
- package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/process-layout.js +68 -0
- package/dist/plugin/hooks/read-layout.d.ts +17 -0
- package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/read-layout.js +39 -0
- package/dist/plugin/index.d.ts +18 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +105 -0
- package/dist/plugin/locales.d.ts +32 -0
- package/dist/plugin/locales.d.ts.map +1 -0
- package/dist/plugin/locales.js +56 -0
- package/dist/plugin/options.d.ts +77 -0
- package/dist/plugin/options.d.ts.map +1 -0
- package/dist/plugin/options.js +123 -0
- package/dist/plugin/plugin.test.d.ts +2 -0
- package/dist/plugin/plugin.test.d.ts.map +1 -0
- package/dist/plugin/plugin.test.js +186 -0
- package/dist/plugin/write-guard.d.ts +14 -0
- package/dist/plugin/write-guard.d.ts.map +1 -0
- package/dist/plugin/write-guard.js +18 -0
- package/package.json +107 -3
- package/README.md +0 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createEmptyDocument, createRegistryMeta, p, s, } from '@next-buildr/core';
|
|
5
|
+
import { sqliteAdapter } from '@payloadcms/db-sqlite';
|
|
6
|
+
import { buildConfig, getPayload, handleEndpoints } from 'payload';
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { buildrPlugin } from '../index.js';
|
|
9
|
+
import { BUILDR_WRITE } from '../write-guard.js';
|
|
10
|
+
import { processLayout } from './process-layout.js';
|
|
11
|
+
const widget = {
|
|
12
|
+
type: 'buildr/widget',
|
|
13
|
+
version: 2,
|
|
14
|
+
label: 'Widget',
|
|
15
|
+
category: 'content',
|
|
16
|
+
props: { title: p.text({ label: 'Title', default: 'Hello' }) },
|
|
17
|
+
contentCategories: ['flow'],
|
|
18
|
+
styles: { groups: [] },
|
|
19
|
+
runtime: 'shared',
|
|
20
|
+
slots: {},
|
|
21
|
+
};
|
|
22
|
+
const page = {
|
|
23
|
+
...widget,
|
|
24
|
+
type: 'buildr/page',
|
|
25
|
+
version: 1,
|
|
26
|
+
label: 'Page',
|
|
27
|
+
props: {},
|
|
28
|
+
capabilities: { root: true },
|
|
29
|
+
slots: { default: {} },
|
|
30
|
+
};
|
|
31
|
+
const meta = createRegistryMeta({ components: [page, widget] });
|
|
32
|
+
// The widget renamed `label` to `title` in version 2.
|
|
33
|
+
const migrations = {
|
|
34
|
+
'buildr/page': { currentVersion: 1, steps: [] },
|
|
35
|
+
'buildr/widget': {
|
|
36
|
+
currentVersion: 2,
|
|
37
|
+
steps: [
|
|
38
|
+
{
|
|
39
|
+
from: 1,
|
|
40
|
+
to: 2,
|
|
41
|
+
migrate: (props) => {
|
|
42
|
+
const { label, ...rest } = props ?? {};
|
|
43
|
+
return { ...rest, title: label };
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const withWidget = (props, widgetVersion = 2) => ({
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
root: 'root',
|
|
52
|
+
nodes: {
|
|
53
|
+
root: { id: 'root', type: 'buildr/page', slots: { default: ['widget0001'] } },
|
|
54
|
+
widget0001: { id: 'widget0001', type: 'buildr/widget', props },
|
|
55
|
+
},
|
|
56
|
+
components: { 'buildr/page': 1, 'buildr/widget': widgetVersion },
|
|
57
|
+
});
|
|
58
|
+
describe('processLayout', () => {
|
|
59
|
+
const limits = { maxNodes: 5000, maxBytes: 2_000_000 };
|
|
60
|
+
const registry = { meta, migrations };
|
|
61
|
+
it('gives a missing layout the empty document', () => {
|
|
62
|
+
const result = processLayout(undefined, { limits });
|
|
63
|
+
expect(result).toEqual({ ok: true, doc: createEmptyDocument(), warnings: [] });
|
|
64
|
+
});
|
|
65
|
+
it('rejects what is not a document, with a readable message', () => {
|
|
66
|
+
for (const value of ['text', [], { nodes: {} }]) {
|
|
67
|
+
const result = processLayout(value, { limits });
|
|
68
|
+
expect(result.ok).toBe(false);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it('applies the limits, tightening the defaults only', () => {
|
|
72
|
+
const doc = withWidget({ title: s('a') });
|
|
73
|
+
expect(processLayout(doc, { limits: { maxNodes: 1, maxBytes: 2_000_000 } }).ok).toBe(false);
|
|
74
|
+
expect(processLayout(doc, { limits: { maxNodes: 10 ** 9, maxBytes: 10 ** 9 } }).ok).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('migrates components to the current version', () => {
|
|
77
|
+
const result = processLayout(withWidget({ label: s('Old') }, 1), { limits, registry });
|
|
78
|
+
expect(result.ok).toBe(true);
|
|
79
|
+
if (!result.ok)
|
|
80
|
+
return;
|
|
81
|
+
expect(result.doc.components['buildr/widget']).toBe(2);
|
|
82
|
+
expect(result.doc.nodes['widget0001']?.props?.['title']).toEqual(s('Old'));
|
|
83
|
+
});
|
|
84
|
+
it('rejects a component written by newer code', () => {
|
|
85
|
+
const result = processLayout(withWidget({ title: s('a') }, 9), { limits, registry });
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it('rejects an unknown prop value when a registry is configured', () => {
|
|
89
|
+
const doc = withWidget({ title: { kind: 'static', value: 42 } });
|
|
90
|
+
expect(processLayout(doc, { limits }).ok).toBe(true);
|
|
91
|
+
expect(processLayout(doc, { limits, registry }).ok).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
let dir;
|
|
95
|
+
let payload;
|
|
96
|
+
const warn = vi.fn();
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
dir = mkdtempSync(join(tmpdir(), 'buildr-payload-hooks-'));
|
|
99
|
+
const config = buildConfig({
|
|
100
|
+
secret: 'test-secret',
|
|
101
|
+
collections: [
|
|
102
|
+
{
|
|
103
|
+
slug: 'pages',
|
|
104
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
105
|
+
versions: { drafts: { autosave: true } },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
db: sqliteAdapter({ client: { url: `file:${join(dir, 'db.sqlite')}` } }),
|
|
109
|
+
plugins: [
|
|
110
|
+
buildrPlugin({ collections: { pages: { context: 'page' } }, registry: { meta, migrations } }),
|
|
111
|
+
],
|
|
112
|
+
typescript: { autoGenerate: false },
|
|
113
|
+
});
|
|
114
|
+
payload = await getPayload({ config, key: 'layout-hook' });
|
|
115
|
+
payload.logger.warn = warn;
|
|
116
|
+
});
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
try {
|
|
119
|
+
rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Windows keeps the database file open a moment longer.
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
describe('the layout hook', () => {
|
|
126
|
+
const write = (data, id) => id === undefined
|
|
127
|
+
? payload.create({ collection: 'pages', data, draft: true })
|
|
128
|
+
: payload.update({
|
|
129
|
+
collection: 'pages',
|
|
130
|
+
id,
|
|
131
|
+
data,
|
|
132
|
+
draft: true,
|
|
133
|
+
context: { [BUILDR_WRITE]: true },
|
|
134
|
+
});
|
|
135
|
+
const layoutOf = async (id) => (await payload.findByID({ collection: 'pages', id, draft: true })).layout;
|
|
136
|
+
it('creates a document with the empty layout', async () => {
|
|
137
|
+
const doc = await write({ title: 'New' });
|
|
138
|
+
expect(await layoutOf(doc.id)).toEqual(createEmptyDocument());
|
|
139
|
+
});
|
|
140
|
+
it('rejects an invalid layout through the Local API, naming the field', async () => {
|
|
141
|
+
await expect(write({ title: 'Bad', layout: { schemaVersion: 1, nodes: {} } })).rejects.toThrow(/layout/i);
|
|
142
|
+
const doc = await write({ title: 'Ok' });
|
|
143
|
+
const rejected = await write({ layout: 'nonsense' }, doc.id).catch((error) => error);
|
|
144
|
+
expect(rejected.data?.errors?.[0]?.message).toMatch(/document object/);
|
|
145
|
+
expect(await layoutOf(doc.id)).toEqual(createEmptyDocument());
|
|
146
|
+
});
|
|
147
|
+
it('migrates on save', async () => {
|
|
148
|
+
const doc = await write({ title: 'Migrating' });
|
|
149
|
+
await write({ layout: withWidget({ label: s('Old') }, 1) }, doc.id);
|
|
150
|
+
const stored = await layoutOf(doc.id);
|
|
151
|
+
expect(stored.components['buildr/widget']).toBe(2);
|
|
152
|
+
expect(stored.nodes['widget0001']?.props?.['title']).toEqual(s('Old'));
|
|
153
|
+
});
|
|
154
|
+
it('rejects an invalid layout over REST with a readable field error', async () => {
|
|
155
|
+
await payload.create({
|
|
156
|
+
collection: 'users',
|
|
157
|
+
data: { email: 'editor@example.com', password: 'secret-password' },
|
|
158
|
+
});
|
|
159
|
+
const login = await payload.login({
|
|
160
|
+
collection: 'users',
|
|
161
|
+
data: { email: 'editor@example.com', password: 'secret-password' },
|
|
162
|
+
});
|
|
163
|
+
const response = await handleEndpoints({
|
|
164
|
+
config: payload.config,
|
|
165
|
+
request: new Request('http://localhost/api/pages', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'content-type': 'application/json', authorization: `JWT ${login.token}` },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
title: 'Rest',
|
|
170
|
+
layout: { schemaVersion: 1, root: 'root', nodes: {} },
|
|
171
|
+
}),
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
175
|
+
expect(response.status).toBeLessThan(500);
|
|
176
|
+
const body = (await response.json());
|
|
177
|
+
const field = body.errors[0]?.data?.errors?.[0];
|
|
178
|
+
expect(field?.path).toBe('layout');
|
|
179
|
+
expect(field?.message.length).toBeGreaterThan(10);
|
|
180
|
+
});
|
|
181
|
+
it('does not validate a layout that the write-guard keeps', async () => {
|
|
182
|
+
const doc = await write({ title: 'Kept' });
|
|
183
|
+
await payload.update({
|
|
184
|
+
collection: 'pages',
|
|
185
|
+
id: doc.id,
|
|
186
|
+
data: { title: 'Renamed', layout: 'garbage' },
|
|
187
|
+
draft: true,
|
|
188
|
+
});
|
|
189
|
+
expect(await layoutOf(doc.id)).toEqual(createEmptyDocument());
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { LocaleConfig } from '@next-buildr/core';
|
|
2
|
+
import { type BuilderDocument, type Diagnostic, type DocumentLimits } from '@next-buildr/core';
|
|
3
|
+
import type { BuildrRegistry } from '../options.ts';
|
|
4
|
+
export interface ProcessLayoutOptions {
|
|
5
|
+
readonly registry?: BuildrRegistry | undefined;
|
|
6
|
+
readonly limits: {
|
|
7
|
+
readonly maxNodes: number;
|
|
8
|
+
readonly maxBytes: number;
|
|
9
|
+
};
|
|
10
|
+
/** The languages of the site: an `l10n` key outside them is reported (a warning, kept). */
|
|
11
|
+
readonly locales?: LocaleConfig | undefined;
|
|
12
|
+
}
|
|
13
|
+
export type ProcessedLayout = {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
readonly doc: BuilderDocument;
|
|
16
|
+
readonly warnings: readonly Diagnostic[];
|
|
17
|
+
} | {
|
|
18
|
+
readonly ok: false;
|
|
19
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
20
|
+
};
|
|
21
|
+
/** The limits of the plugin can only tighten the defaults of the model. */
|
|
22
|
+
export declare const documentLimits: (limits: ProcessLayoutOptions['limits']) => DocumentLimits;
|
|
23
|
+
/**
|
|
24
|
+
* What every write of `layout` goes through, whatever its path (Local API, REST, the builder
|
|
25
|
+
* endpoints): a missing document becomes an empty one; anything else is migrated to the current
|
|
26
|
+
* document schema, parsed against the limits, migrated to the current component versions and, when
|
|
27
|
+
* a registry is configured, validated. Errors reject the write; warnings are handed back to be logged.
|
|
28
|
+
*/
|
|
29
|
+
export declare function processLayout(value: unknown, options: ProcessLayoutOptions): ProcessedLayout;
|
|
30
|
+
/** A readable one-line-per-problem message for the admin UI (`code` and, when known, where). */
|
|
31
|
+
export declare function describeDiagnostics(diagnostics: readonly Diagnostic[]): string;
|
|
32
|
+
//# sourceMappingURL=process-layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/process-layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EACL,KAAK,eAAe,EAGpB,KAAK,UAAU,EACf,KAAK,cAAc,EAMpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1E,2FAA2F;IAC3F,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;CAC7C;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,GAC9F;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAC;AAExE,2EAA2E;AAC3E,eAAO,MAAM,cAAc,WAAY,oBAAoB,CAAC,QAAQ,CAAC,KAAG,cAItE,CAAC;AAOH;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,oBAAoB,GAAG,eAAe,CAyC5F;AAED,gGAAgG;AAChG,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAO9E"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createEmptyDocument, DEFAULT_DOCUMENT_LIMITS, migrateComponents, migrateDocument, parseDocument, validateDocument, } from '@next-buildr/core';
|
|
2
|
+
/** The limits of the plugin can only tighten the defaults of the model. */
|
|
3
|
+
export const documentLimits = (limits) => ({
|
|
4
|
+
...DEFAULT_DOCUMENT_LIMITS,
|
|
5
|
+
maxNodes: Math.min(limits.maxNodes, DEFAULT_DOCUMENT_LIMITS.maxNodes),
|
|
6
|
+
maxDocumentBytes: Math.min(limits.maxBytes, DEFAULT_DOCUMENT_LIMITS.maxDocumentBytes),
|
|
7
|
+
});
|
|
8
|
+
const failure = (code, message) => ({
|
|
9
|
+
ok: false,
|
|
10
|
+
diagnostics: [{ code, message, severity: 'error' }],
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* What every write of `layout` goes through, whatever its path (Local API, REST, the builder
|
|
14
|
+
* endpoints): a missing document becomes an empty one; anything else is migrated to the current
|
|
15
|
+
* document schema, parsed against the limits, migrated to the current component versions and, when
|
|
16
|
+
* a registry is configured, validated. Errors reject the write; warnings are handed back to be logged.
|
|
17
|
+
*/
|
|
18
|
+
export function processLayout(value, options) {
|
|
19
|
+
if (value === undefined || value === null) {
|
|
20
|
+
return { ok: true, doc: createEmptyDocument(), warnings: [] };
|
|
21
|
+
}
|
|
22
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
23
|
+
return failure('layout.not-an-object', 'the layout must be a document object');
|
|
24
|
+
}
|
|
25
|
+
const raw = value;
|
|
26
|
+
if (typeof raw.schemaVersion !== 'number') {
|
|
27
|
+
return failure('layout.no-schema-version', 'the layout has no numeric "schemaVersion"');
|
|
28
|
+
}
|
|
29
|
+
const limits = documentLimits(options.limits);
|
|
30
|
+
const migratedShape = migrateDocument(value);
|
|
31
|
+
if (!migratedShape.ok)
|
|
32
|
+
return { ok: false, diagnostics: [migratedShape.error] };
|
|
33
|
+
const parsed = parseDocument(migratedShape.value.doc, limits);
|
|
34
|
+
if (!parsed.ok)
|
|
35
|
+
return { ok: false, diagnostics: parsed.error };
|
|
36
|
+
let doc = parsed.value;
|
|
37
|
+
const warnings = [];
|
|
38
|
+
const registry = options.registry;
|
|
39
|
+
if (registry?.migrations !== undefined) {
|
|
40
|
+
const migrated = migrateComponents(doc, registry.migrations);
|
|
41
|
+
if (migrated.readOnlyReasons.length > 0) {
|
|
42
|
+
return { ok: false, diagnostics: migrated.readOnlyReasons };
|
|
43
|
+
}
|
|
44
|
+
warnings.push(...migrated.diagnostics);
|
|
45
|
+
doc = migrated.doc;
|
|
46
|
+
}
|
|
47
|
+
if (registry !== undefined) {
|
|
48
|
+
const result = validateDocument(doc, {
|
|
49
|
+
registry: registry.meta,
|
|
50
|
+
limits,
|
|
51
|
+
locales: options.locales,
|
|
52
|
+
});
|
|
53
|
+
const errors = result.issues.filter((issue) => issue.severity === 'error');
|
|
54
|
+
if (!result.ok || errors.length > 0)
|
|
55
|
+
return { ok: false, diagnostics: errors };
|
|
56
|
+
warnings.push(...result.issues);
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, doc, warnings };
|
|
59
|
+
}
|
|
60
|
+
/** A readable one-line-per-problem message for the admin UI (`code` and, when known, where). */
|
|
61
|
+
export function describeDiagnostics(diagnostics) {
|
|
62
|
+
return diagnostics
|
|
63
|
+
.map((diagnostic) => {
|
|
64
|
+
const where = diagnostic.path === undefined ? '' : ` (${diagnostic.path.join('.')})`;
|
|
65
|
+
return `${diagnostic.message}${where}`;
|
|
66
|
+
})
|
|
67
|
+
.join('; ');
|
|
68
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type BuilderDocument, type Diagnostic } from '@next-buildr/core';
|
|
2
|
+
import { type ProcessLayoutOptions } from './process-layout.ts';
|
|
3
|
+
export type ReadLayout = {
|
|
4
|
+
readonly ok: true;
|
|
5
|
+
readonly doc: BuilderDocument;
|
|
6
|
+
readonly readOnly: boolean;
|
|
7
|
+
} | {
|
|
8
|
+
readonly ok: false;
|
|
9
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A stored layout as the editor opens it: migrated in memory (nothing is written), and marked
|
|
13
|
+
* read-only when a component was written by newer code than this build knows (ADR-014).
|
|
14
|
+
* Validation is not repeated here: a stored document was validated when it was written.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readLayout(value: unknown, options: ProcessLayoutOptions): ReadLayout;
|
|
17
|
+
//# sourceMappingURL=read-layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-layout.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/read-layout.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,eAAe,EAEpB,KAAK,UAAU,EAKhB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,oBAAoB,GAAG,UAAU,CA6BpF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createEmptyDocument, migrateComponents, migrateDocument, parseDocument, } from '@next-buildr/core';
|
|
2
|
+
import { documentLimits } from './process-layout.js';
|
|
3
|
+
/**
|
|
4
|
+
* A stored layout as the editor opens it: migrated in memory (nothing is written), and marked
|
|
5
|
+
* read-only when a component was written by newer code than this build knows (ADR-014).
|
|
6
|
+
* Validation is not repeated here: a stored document was validated when it was written.
|
|
7
|
+
*/
|
|
8
|
+
export function readLayout(value, options) {
|
|
9
|
+
if (value === undefined || value === null) {
|
|
10
|
+
return { ok: true, doc: createEmptyDocument(), readOnly: false };
|
|
11
|
+
}
|
|
12
|
+
if (typeof value !== 'object' || typeof value.schemaVersion !== 'number') {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
diagnostics: [
|
|
16
|
+
{
|
|
17
|
+
code: 'layout.not-a-document',
|
|
18
|
+
message: 'the stored layout is not a document',
|
|
19
|
+
severity: 'error',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const migrated = migrateDocument(value);
|
|
25
|
+
if (!migrated.ok)
|
|
26
|
+
return { ok: false, diagnostics: [migrated.error] };
|
|
27
|
+
const parsed = parseDocument(migrated.value.doc, documentLimits(options.limits));
|
|
28
|
+
if (!parsed.ok)
|
|
29
|
+
return { ok: false, diagnostics: parsed.error };
|
|
30
|
+
const registry = options.registry;
|
|
31
|
+
if (registry?.migrations === undefined)
|
|
32
|
+
return { ok: true, doc: parsed.value, readOnly: false };
|
|
33
|
+
const components = migrateComponents(parsed.value, registry.migrations);
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
doc: components.readOnlyReasons.length > 0 ? parsed.value : components.doc,
|
|
37
|
+
readOnly: components.readOnlyReasons.length > 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Plugin } from 'payload';
|
|
2
|
+
import { type BuildrPluginOptions } from './options.ts';
|
|
3
|
+
export { FORM_SUBMISSIONS_COLLECTION } from './collections/form-submissions.ts';
|
|
4
|
+
export { FORM_TYPE, HONEYPOT_FIELD } from './endpoints/forms.ts';
|
|
5
|
+
export { LAYOUT_FIELD_COMPONENT, TEMPLATES_COLLECTION } from './fields.ts';
|
|
6
|
+
export type { MemoryRateLimiterOptions, RateLimiter, RateLimitResult } from './forms/rate-limit.ts';
|
|
7
|
+
export { createMemoryRateLimiter } from './forms/rate-limit.ts';
|
|
8
|
+
export type { ProcessedLayout, ProcessLayoutOptions } from './hooks/process-layout.ts';
|
|
9
|
+
export { describeDiagnostics, documentLimits, processLayout } from './hooks/process-layout.ts';
|
|
10
|
+
export type { AccessArgs, AccessFn, BuildrPluginOptions, BuildrRegistry, ResolvedOptions, } from './options.ts';
|
|
11
|
+
export { resolveOptions } from './options.ts';
|
|
12
|
+
export { BUILDR_WRITE } from './write-guard.ts';
|
|
13
|
+
/**
|
|
14
|
+
* The one-line install (docs/payload.md): adds the `layout`, `buildrRevision` and `template` fields
|
|
15
|
+
* to the configured collections and guards them against writes that do not come from the builder.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildrPlugin(input: BuildrPluginOptions): Plugin;
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA4B,MAAM,EAAE,MAAM,SAAS,CAAC;AAahE,OAAO,EAAE,KAAK,mBAAmB,EAAkB,MAAM,cAAc,CAAC;AAGxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC3E,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,YAAY,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/F,YAAY,EACV,UAAU,EACV,QAAQ,EACR,mBAAmB,EACnB,cAAc,EACd,eAAe,GAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAOhD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAoG/D"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { formSubmissionsCollection } from './collections/form-submissions.js';
|
|
2
|
+
import { templatesCollection } from './collections/templates.js';
|
|
3
|
+
import { buildrEndpoints } from './endpoints/index.js';
|
|
4
|
+
import { buildrFields, RESERVED_FIELDS, RESERVED_MCP_FIELDS, TEMPLATES_COLLECTION, } from './fields.js';
|
|
5
|
+
import { isAllowedRecipient } from './forms/notify.js';
|
|
6
|
+
import { createMemoryRateLimiter } from './forms/rate-limit.js';
|
|
7
|
+
import { layoutHook } from './hooks/layout-hook.js';
|
|
8
|
+
import { resolveOptions } from './options.js';
|
|
9
|
+
import { writeGuard } from './write-guard.js';
|
|
10
|
+
export { FORM_SUBMISSIONS_COLLECTION } from './collections/form-submissions.js';
|
|
11
|
+
export { FORM_TYPE, HONEYPOT_FIELD } from './endpoints/forms.js';
|
|
12
|
+
export { LAYOUT_FIELD_COMPONENT, TEMPLATES_COLLECTION } from './fields.js';
|
|
13
|
+
export { createMemoryRateLimiter } from './forms/rate-limit.js';
|
|
14
|
+
export { describeDiagnostics, documentLimits, processLayout } from './hooks/process-layout.js';
|
|
15
|
+
export { resolveOptions } from './options.js';
|
|
16
|
+
export { BUILDR_WRITE } from './write-guard.js';
|
|
17
|
+
const hasDrafts = (collection) => {
|
|
18
|
+
const versions = collection.versions;
|
|
19
|
+
return typeof versions === 'object' && versions !== null && Boolean(versions.drafts);
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The one-line install (docs/payload.md): adds the `layout`, `buildrRevision` and `template` fields
|
|
23
|
+
* to the configured collections and guards them against writes that do not come from the builder.
|
|
24
|
+
*/
|
|
25
|
+
export function buildrPlugin(input) {
|
|
26
|
+
const options = resolveOptions(input);
|
|
27
|
+
const forms = options.forms;
|
|
28
|
+
if (forms.enabled && options.registry === undefined) {
|
|
29
|
+
throw new Error('buildrPlugin: forms need the "registry" option (the form schema is derived from it)');
|
|
30
|
+
}
|
|
31
|
+
const stray = forms.notifyTo.find((address) => !isAllowedRecipient(address, forms.notifyAllowlist));
|
|
32
|
+
if (stray !== undefined) {
|
|
33
|
+
throw new Error(`buildrPlugin: forms.notifyTo "${stray}" is not in forms.notifyAllowlist`);
|
|
34
|
+
}
|
|
35
|
+
const rateLimiter = forms.enabled
|
|
36
|
+
? (forms.rateLimiter ?? createMemoryRateLimiter(forms.rateLimit))
|
|
37
|
+
: undefined;
|
|
38
|
+
const writeLimiter = options.mcp.enabled
|
|
39
|
+
? (options.mcp.rateLimiter ?? createMemoryRateLimiter(options.mcp.rateLimit))
|
|
40
|
+
: undefined;
|
|
41
|
+
return (config) => {
|
|
42
|
+
const collections = config.collections ?? [];
|
|
43
|
+
const authSlugs = collections.filter((c) => Boolean(c.auth)).map((c) => c.slug);
|
|
44
|
+
if (options.mcp.enabled) {
|
|
45
|
+
const keyed = collections.some((c) => typeof c.auth === 'object' && Boolean(c.auth.useAPIKey));
|
|
46
|
+
if (!keyed) {
|
|
47
|
+
throw new Error('buildrPlugin: mcp.enabled needs an auth collection with `auth: { useAPIKey: true }` (a dedicated, low-privilege agent user; see docs/mcp.md)');
|
|
48
|
+
}
|
|
49
|
+
const unknown = (options.mcp.collections ?? []).find((slug) => options.collections[slug] === undefined);
|
|
50
|
+
if (unknown !== undefined) {
|
|
51
|
+
throw new Error(`buildrPlugin: mcp.collections "${unknown}" is not a configured builder collection`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const slug of Object.keys(options.collections)) {
|
|
55
|
+
const collection = collections.find((candidate) => candidate.slug === slug);
|
|
56
|
+
if (collection === undefined) {
|
|
57
|
+
throw new Error(`buildrPlugin: the configured collection "${slug}" does not exist`);
|
|
58
|
+
}
|
|
59
|
+
if (!hasDrafts(collection)) {
|
|
60
|
+
throw new Error(`buildrPlugin: the collection "${slug}" must enable versions.drafts (the builder saves drafts and autosaves)`);
|
|
61
|
+
}
|
|
62
|
+
const taken = collection.fields.find((field) => field.name !== undefined &&
|
|
63
|
+
(RESERVED_FIELDS.includes(field.name) ||
|
|
64
|
+
(options.mcp.enabled && RESERVED_MCP_FIELDS.includes(field.name))));
|
|
65
|
+
if (taken !== undefined) {
|
|
66
|
+
throw new Error(`buildrPlugin: the collection "${slug}" already has a "${taken.name}" field`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const targets = Object.entries(options.collections)
|
|
70
|
+
.filter(([, own]) => own.templates)
|
|
71
|
+
.map(([slug]) => slug);
|
|
72
|
+
if (targets.length > 0 && collections.some((c) => c.slug === TEMPLATES_COLLECTION)) {
|
|
73
|
+
throw new Error(`buildrPlugin: the collection "${TEMPLATES_COLLECTION}" is added by the plugin and must not be defined`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...config,
|
|
77
|
+
endpoints: [
|
|
78
|
+
...(config.endpoints ?? []),
|
|
79
|
+
...buildrEndpoints({ options, rateLimiter, writeLimiter }),
|
|
80
|
+
],
|
|
81
|
+
collections: [
|
|
82
|
+
...collections.map((collection) => {
|
|
83
|
+
const own = options.collections[collection.slug];
|
|
84
|
+
if (own === undefined)
|
|
85
|
+
return collection;
|
|
86
|
+
return {
|
|
87
|
+
...collection,
|
|
88
|
+
fields: [
|
|
89
|
+
...collection.fields,
|
|
90
|
+
...buildrFields({
|
|
91
|
+
templates: own.templates,
|
|
92
|
+
editorRoute: options.routes.editor,
|
|
93
|
+
guard: writeGuard,
|
|
94
|
+
layoutHook: layoutHook(options),
|
|
95
|
+
updatedBy: options.mcp.enabled ? authSlugs : undefined,
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
...(targets.length === 0 ? [] : [templatesCollection({ resolved: options, targets })]),
|
|
101
|
+
...(forms.enabled ? [formSubmissionsCollection()] : []),
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { LocaleConfig } from '@next-buildr/core';
|
|
2
|
+
import type { PayloadRequest, SanitizedConfig } from 'payload';
|
|
3
|
+
/**
|
|
4
|
+
* The languages of the site as Payload's `localization` configures them, or `undefined` when it is
|
|
5
|
+
* not configured (docs/i18n.md#payload). `intl` carries the display names for the language switcher.
|
|
6
|
+
*/
|
|
7
|
+
export declare function configuredLocales(config: SanitizedConfig): LocaleConfig | undefined;
|
|
8
|
+
/** `configuredLocales`, or the single-locale configuration when localization is off. */
|
|
9
|
+
export declare const localeConfigOf: (config: SanitizedConfig) => LocaleConfig;
|
|
10
|
+
/** The options of a Local API read in a language (`locale`, `fallbackLocale`); empty when localization is off. */
|
|
11
|
+
export interface LocaleArgs {
|
|
12
|
+
readonly locale?: string;
|
|
13
|
+
readonly fallbackLocale?: false;
|
|
14
|
+
}
|
|
15
|
+
export type LocaleChoice = {
|
|
16
|
+
readonly ok: true;
|
|
17
|
+
/** The language asked for, or the default one; `undefined` without localization. */
|
|
18
|
+
readonly locale: string | undefined;
|
|
19
|
+
/** Spread into a Local API call. */
|
|
20
|
+
readonly args: LocaleArgs;
|
|
21
|
+
} | {
|
|
22
|
+
readonly ok: false;
|
|
23
|
+
readonly response: Response;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The language of a request (`requested`: the `locale` parameter or body field). Without Payload
|
|
27
|
+
* localization it is ignored, so an installation that has none behaves as it did. With it, no
|
|
28
|
+
* value means the default language and one that is not configured is a `400`; when the
|
|
29
|
+
* configuration turns `fallback` off, reads do not fall back to the default language either.
|
|
30
|
+
*/
|
|
31
|
+
export declare function chooseLocale(req: Pick<PayloadRequest, 'payload'>, requested: string | null | undefined): LocaleChoice;
|
|
32
|
+
//# sourceMappingURL=locales.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../src/plugin/locales.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAW/D;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,YAAY,GAAG,SAAS,CAkBnF;AAED,wFAAwF;AACxF,eAAO,MAAM,cAAc,WAAY,eAAe,KAAG,YACb,CAAC;AAE7C,kHAAkH;AAClH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC;CACjC;AAED,MAAM,MAAM,YAAY,GACpB;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,oFAAoF;IACpF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;CAC3B,GACD;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAExD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,SAAS,CAAC,EACpC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACnC,YAAY,CAed"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { fail } from './endpoints/respond.js';
|
|
2
|
+
/** The locale of an installation without Payload localization: one language, no fallback. */
|
|
3
|
+
const SINGLE_LOCALE = {
|
|
4
|
+
locales: ['en'],
|
|
5
|
+
default: 'en',
|
|
6
|
+
fallback: false,
|
|
7
|
+
intl: { en: 'English' },
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The languages of the site as Payload's `localization` configures them, or `undefined` when it is
|
|
11
|
+
* not configured (docs/i18n.md#payload). `intl` carries the display names for the language switcher.
|
|
12
|
+
*/
|
|
13
|
+
export function configuredLocales(config) {
|
|
14
|
+
const localization = config.localization;
|
|
15
|
+
if (localization === false || localization === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const intl = {};
|
|
18
|
+
for (const locale of localization.locales) {
|
|
19
|
+
const label = locale.label;
|
|
20
|
+
intl[locale.code] =
|
|
21
|
+
typeof label === 'string'
|
|
22
|
+
? label
|
|
23
|
+
: (label?.[localization.defaultLocale] ??
|
|
24
|
+
locale.code);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
locales: localization.localeCodes,
|
|
28
|
+
default: localization.defaultLocale,
|
|
29
|
+
fallback: localization.fallback !== false,
|
|
30
|
+
intl,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** `configuredLocales`, or the single-locale configuration when localization is off. */
|
|
34
|
+
export const localeConfigOf = (config) => configuredLocales(config) ?? SINGLE_LOCALE;
|
|
35
|
+
/**
|
|
36
|
+
* The language of a request (`requested`: the `locale` parameter or body field). Without Payload
|
|
37
|
+
* localization it is ignored, so an installation that has none behaves as it did. With it, no
|
|
38
|
+
* value means the default language and one that is not configured is a `400`; when the
|
|
39
|
+
* configuration turns `fallback` off, reads do not fall back to the default language either.
|
|
40
|
+
*/
|
|
41
|
+
export function chooseLocale(req, requested) {
|
|
42
|
+
const configured = configuredLocales(req.payload.config);
|
|
43
|
+
if (configured === undefined)
|
|
44
|
+
return { ok: true, locale: undefined, args: {} };
|
|
45
|
+
const locale = requested === undefined || requested === null || requested === ''
|
|
46
|
+
? configured.default
|
|
47
|
+
: requested;
|
|
48
|
+
if (!configured.locales.includes(locale)) {
|
|
49
|
+
return { ok: false, response: fail(400, `The locale "${locale}" is not configured.`) };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
locale,
|
|
54
|
+
args: { locale, ...(configured.fallback ? {} : { fallbackLocale: false }) },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentMigrations, RegistryMeta } from '@next-buildr/core';
|
|
2
|
+
import type { PayloadRequest } from 'payload';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { RateLimiter } from './forms/rate-limit.ts';
|
|
5
|
+
/** What the access functions get (`req.user`, ...). */
|
|
6
|
+
export interface AccessArgs {
|
|
7
|
+
readonly req: PayloadRequest;
|
|
8
|
+
}
|
|
9
|
+
export type AccessFn = (args: AccessArgs) => boolean | Promise<boolean>;
|
|
10
|
+
/** What the plugin needs of a registry: its metadata and, optionally, the component migrations. */
|
|
11
|
+
export interface BuildrRegistry {
|
|
12
|
+
readonly meta: RegistryMeta;
|
|
13
|
+
readonly migrations?: ComponentMigrations | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare const optionsSchema: z.ZodObject<{
|
|
16
|
+
registry: z.ZodOptional<z.ZodCustom<BuildrRegistry, BuildrRegistry>>;
|
|
17
|
+
routes: z.ZodDefault<z.ZodObject<{
|
|
18
|
+
editor: z.ZodDefault<z.ZodString>;
|
|
19
|
+
canvas: z.ZodDefault<z.ZodString>;
|
|
20
|
+
preview: z.ZodDefault<z.ZodString>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
collections: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
23
|
+
context: z.ZodString;
|
|
24
|
+
path: z.ZodOptional<z.ZodCustom<(doc: Record<string, unknown>) => string, (doc: Record<string, unknown>) => string>>;
|
|
25
|
+
templates: z.ZodDefault<z.ZodBoolean>;
|
|
26
|
+
depth: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
expectH1: z.ZodDefault<z.ZodBoolean>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
globals: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
30
|
+
queryable: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
31
|
+
fields: z.ZodArray<z.ZodString>;
|
|
32
|
+
sort: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
33
|
+
}, z.core.$strip>>>;
|
|
34
|
+
media: z.ZodOptional<z.ZodObject<{
|
|
35
|
+
collection: z.ZodString;
|
|
36
|
+
}, z.core.$strip>>;
|
|
37
|
+
forms: z.ZodDefault<z.ZodObject<{
|
|
38
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
39
|
+
notifyAllowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
40
|
+
notifyTo: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
41
|
+
rateLimit: z.ZodDefault<z.ZodObject<{
|
|
42
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
43
|
+
windowMs: z.ZodDefault<z.ZodNumber>;
|
|
44
|
+
}, z.core.$strip>>;
|
|
45
|
+
rateLimiter: z.ZodOptional<z.ZodCustom<RateLimiter, RateLimiter>>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
access: z.ZodDefault<z.ZodObject<{
|
|
48
|
+
edit: z.ZodOptional<z.ZodCustom<AccessFn, AccessFn>>;
|
|
49
|
+
publish: z.ZodOptional<z.ZodCustom<AccessFn, AccessFn>>;
|
|
50
|
+
unlockTemplates: z.ZodOptional<z.ZodCustom<AccessFn, AccessFn>>;
|
|
51
|
+
}, z.core.$strip>>;
|
|
52
|
+
mcp: z.ZodDefault<z.ZodObject<{
|
|
53
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
54
|
+
allowPublish: z.ZodDefault<z.ZodBoolean>;
|
|
55
|
+
collections: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
|
+
rateLimit: z.ZodDefault<z.ZodObject<{
|
|
57
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
58
|
+
windowMs: z.ZodDefault<z.ZodNumber>;
|
|
59
|
+
}, z.core.$strip>>;
|
|
60
|
+
rateLimiter: z.ZodOptional<z.ZodCustom<RateLimiter, RateLimiter>>;
|
|
61
|
+
}, z.core.$strip>>;
|
|
62
|
+
limits: z.ZodDefault<z.ZodObject<{
|
|
63
|
+
maxNodes: z.ZodDefault<z.ZodNumber>;
|
|
64
|
+
maxBytes: z.ZodDefault<z.ZodNumber>;
|
|
65
|
+
}, z.core.$strip>>;
|
|
66
|
+
a11y: z.ZodDefault<z.ZodObject<{
|
|
67
|
+
publish: z.ZodDefault<z.ZodEnum<{
|
|
68
|
+
block: "block";
|
|
69
|
+
warn: "warn";
|
|
70
|
+
}>>;
|
|
71
|
+
}, z.core.$strip>>;
|
|
72
|
+
}, z.core.$strip>;
|
|
73
|
+
export type BuildrPluginOptions = z.input<typeof optionsSchema>;
|
|
74
|
+
export type ResolvedOptions = z.output<typeof optionsSchema>;
|
|
75
|
+
/** Validates the options (a bad config fails at startup, with every problem listed). */
|
|
76
|
+
export declare function resolveOptions(input: BuildrPluginOptions): ResolvedOptions;
|
|
77
|
+
//# sourceMappingURL=options.d.ts.map
|