@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.
Files changed (250) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/adapter/adapter.test.d.ts +2 -0
  4. package/dist/adapter/adapter.test.d.ts.map +1 -0
  5. package/dist/adapter/adapter.test.js +282 -0
  6. package/dist/adapter/canvas-data-source.d.ts +9 -0
  7. package/dist/adapter/canvas-data-source.d.ts.map +1 -0
  8. package/dist/adapter/canvas-data-source.js +24 -0
  9. package/dist/adapter/document-adapter.d.ts +20 -0
  10. package/dist/adapter/document-adapter.d.ts.map +1 -0
  11. package/dist/adapter/document-adapter.js +183 -0
  12. package/dist/adapter/http.d.ts +34 -0
  13. package/dist/adapter/http.d.ts.map +1 -0
  14. package/dist/adapter/http.js +72 -0
  15. package/dist/adapter/index.d.ts +4 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +5 -0
  18. package/dist/admin/index.d.ts +4 -0
  19. package/dist/admin/index.d.ts.map +1 -0
  20. package/dist/admin/index.js +2 -0
  21. package/dist/admin/layout-field-connected.d.ts +6 -0
  22. package/dist/admin/layout-field-connected.d.ts.map +1 -0
  23. package/dist/admin/layout-field-connected.js +16 -0
  24. package/dist/admin/layout-field-view.d.ts +16 -0
  25. package/dist/admin/layout-field-view.d.ts.map +1 -0
  26. package/dist/admin/layout-field-view.js +14 -0
  27. package/dist/admin/layout-field.d.ts +6 -0
  28. package/dist/admin/layout-field.d.ts.map +1 -0
  29. package/dist/admin/layout-field.js +13 -0
  30. package/dist/admin/layout-field.test.d.ts +2 -0
  31. package/dist/admin/layout-field.test.d.ts.map +1 -0
  32. package/dist/admin/layout-field.test.js +41 -0
  33. package/dist/contract.d.ts +289 -0
  34. package/dist/contract.d.ts.map +1 -0
  35. package/dist/contract.js +176 -0
  36. package/dist/data/build-context.d.ts +20 -0
  37. package/dist/data/build-context.d.ts.map +1 -0
  38. package/dist/data/build-context.js +25 -0
  39. package/dist/data/data.test.d.ts +2 -0
  40. package/dist/data/data.test.d.ts.map +1 -0
  41. package/dist/data/data.test.js +261 -0
  42. package/dist/data/index.d.ts +10 -0
  43. package/dist/data/index.d.ts.map +1 -0
  44. package/dist/data/index.js +5 -0
  45. package/dist/data/normalize.d.ts +14 -0
  46. package/dist/data/normalize.d.ts.map +1 -0
  47. package/dist/data/normalize.js +128 -0
  48. package/dist/data/payload-data-source.d.ts +42 -0
  49. package/dist/data/payload-data-source.d.ts.map +1 -0
  50. package/dist/data/payload-data-source.js +179 -0
  51. package/dist/data/payload-data-source.test.d.ts +2 -0
  52. package/dist/data/payload-data-source.test.d.ts.map +1 -0
  53. package/dist/data/payload-data-source.test.js +160 -0
  54. package/dist/data/resolve-layout.d.ts +45 -0
  55. package/dist/data/resolve-layout.d.ts.map +1 -0
  56. package/dist/data/resolve-layout.js +111 -0
  57. package/dist/data/schema-from-fields.d.ts +63 -0
  58. package/dist/data/schema-from-fields.d.ts.map +1 -0
  59. package/dist/data/schema-from-fields.js +178 -0
  60. package/dist/data/where.d.ts +53 -0
  61. package/dist/data/where.d.ts.map +1 -0
  62. package/dist/data/where.js +210 -0
  63. package/dist/mcp/http-backend.d.ts +35 -0
  64. package/dist/mcp/http-backend.d.ts.map +1 -0
  65. package/dist/mcp/http-backend.js +337 -0
  66. package/dist/mcp/http-backend.test.d.ts +2 -0
  67. package/dist/mcp/http-backend.test.d.ts.map +1 -0
  68. package/dist/mcp/http-backend.test.js +178 -0
  69. package/dist/mcp/index.d.ts +2 -0
  70. package/dist/mcp/index.d.ts.map +1 -0
  71. package/dist/mcp/index.js +2 -0
  72. package/dist/mcp/route.d.ts +63 -0
  73. package/dist/mcp/route.d.ts.map +1 -0
  74. package/dist/mcp/route.js +230 -0
  75. package/dist/next/get-document.d.ts +64 -0
  76. package/dist/next/get-document.d.ts.map +1 -0
  77. package/dist/next/get-document.js +208 -0
  78. package/dist/next/get-document.test.d.ts +2 -0
  79. package/dist/next/get-document.test.d.ts.map +1 -0
  80. package/dist/next/get-document.test.js +155 -0
  81. package/dist/next/index.d.ts +9 -0
  82. package/dist/next/index.d.ts.map +1 -0
  83. package/dist/next/index.js +4 -0
  84. package/dist/next/next-helpers.test.d.ts +2 -0
  85. package/dist/next/next-helpers.test.d.ts.map +1 -0
  86. package/dist/next/next-helpers.test.js +122 -0
  87. package/dist/next/revalidate.d.ts +44 -0
  88. package/dist/next/revalidate.d.ts.map +1 -0
  89. package/dist/next/revalidate.js +45 -0
  90. package/dist/next/seo.d.ts +43 -0
  91. package/dist/next/seo.d.ts.map +1 -0
  92. package/dist/next/seo.js +53 -0
  93. package/dist/next/tags.d.ts +23 -0
  94. package/dist/next/tags.d.ts.map +1 -0
  95. package/dist/next/tags.js +31 -0
  96. package/dist/plugin/access.d.ts +19 -0
  97. package/dist/plugin/access.d.ts.map +1 -0
  98. package/dist/plugin/access.js +32 -0
  99. package/dist/plugin/collections/form-submissions.d.ts +10 -0
  100. package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
  101. package/dist/plugin/collections/form-submissions.js +37 -0
  102. package/dist/plugin/collections/templates.d.ts +13 -0
  103. package/dist/plugin/collections/templates.d.ts.map +1 -0
  104. package/dist/plugin/collections/templates.js +118 -0
  105. package/dist/plugin/endpoints/access.test.d.ts +2 -0
  106. package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
  107. package/dist/plugin/endpoints/access.test.js +91 -0
  108. package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
  109. package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
  110. package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
  111. package/dist/plugin/endpoints/context.d.ts +65 -0
  112. package/dist/plugin/endpoints/context.d.ts.map +1 -0
  113. package/dist/plugin/endpoints/context.js +113 -0
  114. package/dist/plugin/endpoints/data-context.d.ts +8 -0
  115. package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
  116. package/dist/plugin/endpoints/data-context.js +63 -0
  117. package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
  118. package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
  119. package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
  120. package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
  121. package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
  122. package/dist/plugin/endpoints/data-query.test.js +96 -0
  123. package/dist/plugin/endpoints/data-schema.d.ts +5 -0
  124. package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
  125. package/dist/plugin/endpoints/data-schema.js +21 -0
  126. package/dist/plugin/endpoints/data.d.ts +10 -0
  127. package/dist/plugin/endpoints/data.d.ts.map +1 -0
  128. package/dist/plugin/endpoints/data.js +96 -0
  129. package/dist/plugin/endpoints/document.d.ts +11 -0
  130. package/dist/plugin/endpoints/document.d.ts.map +1 -0
  131. package/dist/plugin/endpoints/document.js +119 -0
  132. package/dist/plugin/endpoints/documents.d.ts +10 -0
  133. package/dist/plugin/endpoints/documents.d.ts.map +1 -0
  134. package/dist/plugin/endpoints/documents.js +145 -0
  135. package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
  136. package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
  137. package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
  138. package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
  139. package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
  140. package/dist/plugin/endpoints/endpoints.test.js +209 -0
  141. package/dist/plugin/endpoints/forms.d.ts +16 -0
  142. package/dist/plugin/endpoints/forms.d.ts.map +1 -0
  143. package/dist/plugin/endpoints/forms.js +199 -0
  144. package/dist/plugin/endpoints/forms.test.d.ts +2 -0
  145. package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
  146. package/dist/plugin/endpoints/forms.test.js +346 -0
  147. package/dist/plugin/endpoints/guards.d.ts +9 -0
  148. package/dist/plugin/endpoints/guards.d.ts.map +1 -0
  149. package/dist/plugin/endpoints/guards.js +35 -0
  150. package/dist/plugin/endpoints/index.d.ts +6 -0
  151. package/dist/plugin/endpoints/index.d.ts.map +1 -0
  152. package/dist/plugin/endpoints/index.js +32 -0
  153. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
  154. package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
  155. package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
  156. package/dist/plugin/endpoints/locales.test.d.ts +2 -0
  157. package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
  158. package/dist/plugin/endpoints/locales.test.js +128 -0
  159. package/dist/plugin/endpoints/manifest.d.ts +5 -0
  160. package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
  161. package/dist/plugin/endpoints/manifest.js +18 -0
  162. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
  163. package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
  164. package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
  165. package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
  166. package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
  167. package/dist/plugin/endpoints/mcp-off.test.js +33 -0
  168. package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
  169. package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
  170. package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
  171. package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
  172. package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
  173. package/dist/plugin/endpoints/mcp-route.test.js +132 -0
  174. package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
  175. package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
  176. package/dist/plugin/endpoints/mcp.test.js +231 -0
  177. package/dist/plugin/endpoints/media.d.ts +7 -0
  178. package/dist/plugin/endpoints/media.d.ts.map +1 -0
  179. package/dist/plugin/endpoints/media.js +115 -0
  180. package/dist/plugin/endpoints/media.test.d.ts +2 -0
  181. package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
  182. package/dist/plugin/endpoints/media.test.js +95 -0
  183. package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
  184. package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
  185. package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
  186. package/dist/plugin/endpoints/publish.d.ts +9 -0
  187. package/dist/plugin/endpoints/publish.d.ts.map +1 -0
  188. package/dist/plugin/endpoints/publish.js +81 -0
  189. package/dist/plugin/endpoints/rename.d.ts +9 -0
  190. package/dist/plugin/endpoints/rename.d.ts.map +1 -0
  191. package/dist/plugin/endpoints/rename.js +47 -0
  192. package/dist/plugin/endpoints/respond.d.ts +9 -0
  193. package/dist/plugin/endpoints/respond.d.ts.map +1 -0
  194. package/dist/plugin/endpoints/respond.js +10 -0
  195. package/dist/plugin/endpoints/samples.d.ts +5 -0
  196. package/dist/plugin/endpoints/samples.d.ts.map +1 -0
  197. package/dist/plugin/endpoints/samples.js +42 -0
  198. package/dist/plugin/endpoints/save.d.ts +9 -0
  199. package/dist/plugin/endpoints/save.d.ts.map +1 -0
  200. package/dist/plugin/endpoints/save.js +58 -0
  201. package/dist/plugin/endpoints/session.d.ts +5 -0
  202. package/dist/plugin/endpoints/session.d.ts.map +1 -0
  203. package/dist/plugin/endpoints/session.js +30 -0
  204. package/dist/plugin/endpoints/templates.test.d.ts +2 -0
  205. package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
  206. package/dist/plugin/endpoints/templates.test.js +191 -0
  207. package/dist/plugin/fields.d.ts +28 -0
  208. package/dist/plugin/fields.d.ts.map +1 -0
  209. package/dist/plugin/fields.js +62 -0
  210. package/dist/plugin/forms/forms.test.d.ts +2 -0
  211. package/dist/plugin/forms/forms.test.d.ts.map +1 -0
  212. package/dist/plugin/forms/forms.test.js +88 -0
  213. package/dist/plugin/forms/notify.d.ts +6 -0
  214. package/dist/plugin/forms/notify.d.ts.map +1 -0
  215. package/dist/plugin/forms/notify.js +13 -0
  216. package/dist/plugin/forms/rate-limit.d.ts +24 -0
  217. package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
  218. package/dist/plugin/forms/rate-limit.js +36 -0
  219. package/dist/plugin/forms/validate.d.ts +22 -0
  220. package/dist/plugin/forms/validate.d.ts.map +1 -0
  221. package/dist/plugin/forms/validate.js +79 -0
  222. package/dist/plugin/hooks/layout-hook.d.ts +9 -0
  223. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
  224. package/dist/plugin/hooks/layout-hook.js +33 -0
  225. package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
  226. package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
  227. package/dist/plugin/hooks/layout-hook.test.js +191 -0
  228. package/dist/plugin/hooks/process-layout.d.ts +32 -0
  229. package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
  230. package/dist/plugin/hooks/process-layout.js +68 -0
  231. package/dist/plugin/hooks/read-layout.d.ts +17 -0
  232. package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
  233. package/dist/plugin/hooks/read-layout.js +39 -0
  234. package/dist/plugin/index.d.ts +18 -0
  235. package/dist/plugin/index.d.ts.map +1 -0
  236. package/dist/plugin/index.js +105 -0
  237. package/dist/plugin/locales.d.ts +32 -0
  238. package/dist/plugin/locales.d.ts.map +1 -0
  239. package/dist/plugin/locales.js +56 -0
  240. package/dist/plugin/options.d.ts +77 -0
  241. package/dist/plugin/options.d.ts.map +1 -0
  242. package/dist/plugin/options.js +123 -0
  243. package/dist/plugin/plugin.test.d.ts +2 -0
  244. package/dist/plugin/plugin.test.d.ts.map +1 -0
  245. package/dist/plugin/plugin.test.js +186 -0
  246. package/dist/plugin/write-guard.d.ts +14 -0
  247. package/dist/plugin/write-guard.d.ts.map +1 -0
  248. package/dist/plugin/write-guard.js +18 -0
  249. package/package.json +107 -3
  250. package/README.md +0 -3
@@ -0,0 +1,230 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
2
+ import { ok, toManifest } from '@next-buildr/core';
3
+ import { createBuildrMcpServer, createBuildrTools, createDiscoveryCache, createResources, createSessionStore, } from '@next-buildr/mcp';
4
+ import { handleEndpoints } from 'payload';
5
+ import { createMemoryRateLimiter } from '../plugin/forms/rate-limit.js';
6
+ import { createPayloadMcpBackend } from './http-backend.js';
7
+ const AUTHORIZATION = /^([A-Za-z0-9_-]{1,100}) API-Key (\S{8,512})$/;
8
+ const DEFAULT_SESSION_TTL_MS = 30 * 60_000;
9
+ const FAILURE_WINDOW_MS = 60_000;
10
+ const MAX_FAILURES = 30;
11
+ const reply = (status, error, headers = {}) => new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: error }, id: null }), { status, headers: { 'content-type': 'application/json', ...headers } });
12
+ const originOf = (value) => {
13
+ if (value === null || value === undefined || value === '')
14
+ return undefined;
15
+ try {
16
+ return new URL(value).origin;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ };
22
+ /**
23
+ * `createBuildrMcpRoute({ payload, registry })` returns the `POST`/`GET`/`DELETE` handlers of the
24
+ * MCP endpoint. Mount them at `app/(builder)/api/buildr/mcp/route.ts`.
25
+ *
26
+ * - Disabled (`404`) unless the plugin has `mcp.enabled`.
27
+ * - Only `Authorization: <collection> API-Key <key>` authenticates; cookies are never read (`401`),
28
+ * so a browser cannot drive the endpoint cross-site. A browser `Origin` must be the site's own,
29
+ * in Payload's `csrf` list or in `allowedOrigins`.
30
+ * - The tools run against a local backend: the builder API of the same Payload, called in-process
31
+ * (no network hop) as the agent user, so Payload's access control, the plugin's `access`, the
32
+ * write limits and the revision checks apply exactly as over the network.
33
+ * - Stateless: each request is one JSON-RPC exchange on a fresh server. Open documents live in a
34
+ * per-user session store held by this route (one per `createBuildrMcpRoute` call, never global);
35
+ * on serverless platforms it lives as long as the instance does, and an unknown session after a
36
+ * cold start is reported to the agent, which reopens the document.
37
+ */
38
+ export function createBuildrMcpRoute(options) {
39
+ const entries = new Map();
40
+ const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
41
+ const maxBodyBytes = options.maxBodyBytes ?? 1_000_000;
42
+ const limiter = options.rateLimiter ??
43
+ createMemoryRateLimiter(options.rateLimit ?? { limit: 120, windowMs: 60_000 });
44
+ // Clients that failed to authenticate too often are refused outright for a while.
45
+ const failures = createMemoryRateLimiter({ limit: MAX_FAILURES, windowMs: FAILURE_WINDOW_MS });
46
+ const blockedUntil = new Map();
47
+ let resolved;
48
+ const getPayload = () => {
49
+ if (typeof options.payload !== 'function')
50
+ return Promise.resolve(options.payload);
51
+ resolved ??= options.payload().catch((error) => {
52
+ resolved = undefined;
53
+ throw error;
54
+ });
55
+ return resolved;
56
+ };
57
+ const clientOf = (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
58
+ function sweep(now) {
59
+ for (const [key, entry] of entries) {
60
+ entry.store.sweep();
61
+ if (entry.store.size === 0 && now - entry.lastSeen >= sessionTtlMs)
62
+ entries.delete(key);
63
+ }
64
+ for (const [client, until] of blockedUntil)
65
+ if (until <= now)
66
+ blockedUntil.delete(client);
67
+ }
68
+ async function entryFor(payload, request, key, collection, apiKey, authorization) {
69
+ const existing = entries.get(key);
70
+ if (existing !== undefined) {
71
+ existing.authorization = authorization;
72
+ existing.lastSeen = Date.now();
73
+ return existing;
74
+ }
75
+ const site = options.siteUrl ?? new URL(request.url).origin;
76
+ const apiRoot = payload.config.routes?.api ?? '/api';
77
+ // Payload answers the builder API from memory; the header is the user's own key, refreshed per request.
78
+ const holder = {};
79
+ const inProcess = (input, init) => {
80
+ const headers = new Headers(init?.headers);
81
+ headers.set('authorization', holder.entry?.authorization ?? authorization);
82
+ return handleEndpoints({
83
+ config: payload.config,
84
+ request: new Request(input, { ...init, headers }),
85
+ });
86
+ };
87
+ const collections = options.collections ??
88
+ payload.config.collections
89
+ .filter((c) => c.fields.some((field) => field.name === 'buildrRevision'))
90
+ .map((c) => c.slug);
91
+ const http = createPayloadMcpBackend({
92
+ baseUrl: `${site}${apiRoot}`,
93
+ apiKey,
94
+ collection,
95
+ collections: [...collections],
96
+ siteUrl: site,
97
+ fetch: inProcess,
98
+ retries: 0,
99
+ ...(options.theme === undefined ? {} : { theme: options.theme }),
100
+ });
101
+ const backend = {
102
+ ...http,
103
+ async getManifest() {
104
+ const served = await http.getManifest();
105
+ if (served.ok || served.error.code !== 'not-found')
106
+ return served;
107
+ return ok(toManifest(options.registry.meta));
108
+ },
109
+ };
110
+ const store = createSessionStore({ backend, ttlMs: sessionTtlMs });
111
+ const discoveryCache = createDiscoveryCache();
112
+ const tools = await createBuildrTools({
113
+ store,
114
+ backend,
115
+ discoveryCache,
116
+ allowPublish: options.allowPublish === true,
117
+ });
118
+ const entry = {
119
+ store,
120
+ backend,
121
+ tools,
122
+ resources: createResources(discoveryCache),
123
+ authorization,
124
+ lastSeen: Date.now(),
125
+ };
126
+ holder.entry = entry;
127
+ entries.set(key, entry);
128
+ return entry;
129
+ }
130
+ /** The route is off unless the plugin registered the agent endpoints (`mcp.enabled`). */
131
+ const enabled = (payload) => (payload.config.endpoints ?? []).some((endpoint) => endpoint.path === '/buildr/documents');
132
+ async function POST(request) {
133
+ const payload = await getPayload();
134
+ if (!enabled(payload))
135
+ return reply(404, 'Not found.');
136
+ const now = Date.now();
137
+ sweep(now);
138
+ const origin = request.headers.get('origin');
139
+ if (origin !== null) {
140
+ const requested = originOf(origin);
141
+ const allowed = new Set([
142
+ originOf(request.url),
143
+ ...(payload.config.csrf ?? []).map(originOf),
144
+ ...(options.allowedOrigins ?? []).map(originOf),
145
+ ].filter((value) => value !== undefined));
146
+ if (requested === undefined || !allowed.has(requested)) {
147
+ return reply(403, 'The origin of the request is not allowed.');
148
+ }
149
+ }
150
+ const client = clientOf(request);
151
+ const until = blockedUntil.get(client);
152
+ if (until !== undefined && until > now) {
153
+ return reply(429, 'Too many failed attempts. Try again later.', {
154
+ 'retry-after': String(Math.ceil((until - now) / 1000)),
155
+ });
156
+ }
157
+ // Cookies are never read here: a session cookie alone is not credentials.
158
+ const deny = async () => {
159
+ const failed = await failures.hit(client);
160
+ if (!failed.allowed)
161
+ blockedUntil.set(client, now + failed.retryAfterSeconds * 1000);
162
+ return reply(401, 'Send an API key: Authorization: <collection> API-Key <key>.', {
163
+ 'www-authenticate': 'API-Key',
164
+ });
165
+ };
166
+ const authorization = request.headers.get('authorization') ?? '';
167
+ const match = AUTHORIZATION.exec(authorization);
168
+ if (match === null)
169
+ return deny();
170
+ const collection = match[1];
171
+ const apiKey = match[2];
172
+ let user;
173
+ try {
174
+ // Only the Authorization header is passed on, so Payload can only use the API-key strategy.
175
+ const auth = await payload.auth({ headers: new Headers({ authorization }) });
176
+ user = auth.user;
177
+ }
178
+ catch {
179
+ user = null;
180
+ }
181
+ if (user === null ||
182
+ user === undefined ||
183
+ user.id === undefined ||
184
+ user._strategy !== 'api-key') {
185
+ return deny();
186
+ }
187
+ const key = `${user.collection ?? collection}:${String(user.id)}`;
188
+ const limit = await limiter.hit(key);
189
+ if (!limit.allowed) {
190
+ return reply(429, 'Too many requests. Try again later.', {
191
+ 'retry-after': String(limit.retryAfterSeconds),
192
+ });
193
+ }
194
+ const declared = Number(request.headers.get('content-length'));
195
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
196
+ return reply(413, 'The request is too large.');
197
+ }
198
+ const body = await request.text();
199
+ if (body.length > maxBodyBytes)
200
+ return reply(413, 'The request is too large.');
201
+ const entry = await entryFor(payload, request, key, collection, apiKey, authorization);
202
+ const server = createBuildrMcpServer({
203
+ backend: entry.backend,
204
+ options: {
205
+ tools: entry.tools,
206
+ resources: entry.resources,
207
+ allowPublish: options.allowPublish === true,
208
+ },
209
+ });
210
+ // No session id generator: stateless mode.
211
+ const transport = new WebStandardStreamableHTTPServerTransport({
212
+ enableJsonResponse: true,
213
+ });
214
+ try {
215
+ await server.connect(transport);
216
+ return await transport.handleRequest(new Request(request.url, { method: 'POST', headers: request.headers, body }));
217
+ }
218
+ finally {
219
+ await server.close().catch(() => undefined);
220
+ }
221
+ }
222
+ /** No server-initiated stream and no connection to end: the server is stateless. */
223
+ async function notAllowed() {
224
+ const payload = await getPayload();
225
+ if (!enabled(payload))
226
+ return reply(404, 'Not found.');
227
+ return reply(405, 'This MCP server only accepts POST.', { allow: 'POST' });
228
+ }
229
+ return { POST, GET: notAllowed, DELETE: notAllowed };
230
+ }
@@ -0,0 +1,64 @@
1
+ import type { DataContext } from '@next-buildr/core';
2
+ import type { Payload } from 'payload';
3
+ /** Wraps a read in a tagged cache. The default is `unstable_cache` (Next 15 and 16). */
4
+ export type CacheRead = <T>(read: () => Promise<T>, keyParts: string[], options: {
5
+ readonly tags: string[];
6
+ }) => () => Promise<T>;
7
+ export interface GetBuildrDocumentInput {
8
+ readonly payload: Payload;
9
+ readonly collection: string;
10
+ /** Look the document up by its slug field... */
11
+ readonly slug?: string;
12
+ /** ...or by id. */
13
+ readonly id?: string | number;
14
+ readonly slugField?: string;
15
+ readonly locale?: string;
16
+ /**
17
+ * Read the latest draft with the permissions of `user` (uncached). Without a user the read is a
18
+ * visitor's, so it sees only what is published.
19
+ */
20
+ readonly draft?: boolean;
21
+ readonly user?: unknown;
22
+ /** The name the document is bound under (`page`, `post`); the collection slug by default. */
23
+ readonly contextName?: string;
24
+ /** The public path of the document, for the `route` scope. */
25
+ readonly path?: (doc: Record<string, unknown>) => string;
26
+ /** Relation depth of the read. Default `1`. */
27
+ readonly depth?: number;
28
+ /** The page of a paginated listing: `route.params.page`. */
29
+ readonly page?: number;
30
+ readonly timeZone?: string;
31
+ /** Replaces the tagged cache (tests, hosts with their own). */
32
+ readonly cache?: CacheRead;
33
+ }
34
+ /** What `BuildrPage` (`@next-buildr/next`) renders, plus the document itself and its cache tags. */
35
+ export interface BuildrDocumentEntry {
36
+ /** The resolved layout, as stored: `BuildrPage` migrates and validates it. */
37
+ readonly document: unknown;
38
+ readonly context: DataContext;
39
+ readonly layoutRef: string | null;
40
+ readonly layoutSource: 'document' | 'template' | 'default-template' | 'builtin';
41
+ readonly currentId: string | number;
42
+ /** The Payload document (populated to `depth`), for metadata. */
43
+ readonly doc: Record<string, unknown>;
44
+ /** The tags this entry was cached under. */
45
+ readonly tags: string[];
46
+ }
47
+ /**
48
+ * The production read of a page (docs/nextjs.md): the document (by slug or id), its layout
49
+ * (`resolveLayout`), and the `DataContext` (`buildContext`) in one call, or `null` when there is no
50
+ * such document (a `404`). Published reads go through a tagged cache that `revalidateHooks()`
51
+ * invalidates; a draft read is never cached.
52
+ */
53
+ export declare function getBuildrDocument(input: GetBuildrDocumentInput): Promise<BuildrDocumentEntry | null>;
54
+ export interface ListPublishedSlugsInput {
55
+ readonly payload: Payload;
56
+ readonly collection: string;
57
+ readonly slugField?: string;
58
+ readonly locale?: string;
59
+ /** Upper bound; a site with more pages relies on `dynamicParams` for the rest. Default `1000`. */
60
+ readonly limit?: number;
61
+ }
62
+ /** The slugs of the published documents, for `generateStaticParams`. Documents without a slug are skipped. */
63
+ export declare function listPublishedSlugs(input: ListPublishedSlugsInput): Promise<string[]>;
64
+ //# sourceMappingURL=get-document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-document.d.ts","sourceRoot":"","sources":["../../src/next/get-document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAa,MAAM,mBAAmB,CAAC;AAChE,OAAO,KAAK,EAAE,OAAO,EAAS,MAAM,SAAS,CAAC;AAQ9C,wFAAwF;AACxF,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,EACxB,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,KACjC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC;AAYtB,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB;IACnB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;IACzD,+CAA+C;IAC/C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;CAC5B;AAED,oGAAoG;AACpG,MAAM,WAAW,mBAAmB;IAClC,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,UAAU,GAAG,UAAU,GAAG,kBAAkB,GAAG,SAAS,CAAC;IAChF,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IACpC,iEAAiE;IACjE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;CACzB;AAKD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,sBAAsB,GAC5B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAyKrC;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,kGAAkG;IAClG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,8GAA8G;AAC9G,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA2B1F"}
@@ -0,0 +1,208 @@
1
+ import { buildContext, resolveLayout } from '../data/index.js';
2
+ import { configuredLocales, localeConfigOf } from '../plugin/locales.js';
3
+ import { collectionTag, docTag, globalTag } from './tags.js';
4
+ const SITE_GLOBAL = 'site-settings';
5
+ const defaultCache = (read, keyParts, options) => {
6
+ let cached;
7
+ return async () => {
8
+ // Loaded on first use so this module can be imported (and tested) outside Next.js.
9
+ const { unstable_cache } = await import('next/cache');
10
+ cached ??= unstable_cache(read, keyParts, { tags: options.tags });
11
+ return (await cached());
12
+ };
13
+ };
14
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ /**
16
+ * The production read of a page (docs/nextjs.md): the document (by slug or id), its layout
17
+ * (`resolveLayout`), and the `DataContext` (`buildContext`) in one call, or `null` when there is no
18
+ * such document (a `404`). Published reads go through a tagged cache that `revalidateHooks()`
19
+ * invalidates; a draft read is never cached.
20
+ */
21
+ export async function getBuildrDocument(input) {
22
+ const { payload, collection } = input;
23
+ if ((input.slug === undefined) === (input.id === undefined)) {
24
+ throw new Error('getBuildrDocument needs exactly one of `slug` and `id`.');
25
+ }
26
+ const configured = configuredLocales(payload.config);
27
+ const locale = input.locale ?? configured?.default;
28
+ if (configured !== undefined && locale !== undefined && !configured.locales.includes(locale)) {
29
+ return null;
30
+ }
31
+ const localeArgs = configured === undefined || locale === undefined
32
+ ? {}
33
+ : { locale, ...(configured.fallback ? {} : { fallbackLocale: false }) };
34
+ const draft = input.draft === true && input.user !== undefined && input.user !== null;
35
+ const hasSite = payload.config.globals?.some((global) => global.slug === SITE_GLOBAL) === true;
36
+ const access = draft
37
+ ? { user: input.user, overrideAccess: false }
38
+ : { overrideAccess: false };
39
+ const read = async () => {
40
+ let doc;
41
+ const depth = input.depth ?? 1;
42
+ if (input.id !== undefined) {
43
+ doc = await payload
44
+ .findByID({
45
+ collection: collection,
46
+ id: input.id,
47
+ depth,
48
+ draft,
49
+ ...localeArgs,
50
+ ...access,
51
+ })
52
+ .then((found) => found)
53
+ .catch(() => undefined);
54
+ // A visitor sees published documents only, whatever the collection's read access says.
55
+ if (!draft &&
56
+ doc !== undefined &&
57
+ doc['_status'] !== undefined &&
58
+ doc['_status'] !== 'published') {
59
+ doc = undefined;
60
+ }
61
+ }
62
+ else {
63
+ const where = {
64
+ and: [
65
+ { [input.slugField ?? 'slug']: { equals: input.slug } },
66
+ ...(draft ? [] : [{ _status: { equals: 'published' } }]),
67
+ ],
68
+ };
69
+ const found = await payload.find({
70
+ collection: collection,
71
+ where,
72
+ limit: 1,
73
+ pagination: false,
74
+ depth,
75
+ draft,
76
+ ...localeArgs,
77
+ ...access,
78
+ });
79
+ doc = found.docs[0];
80
+ }
81
+ if (doc === undefined || (typeof doc['id'] !== 'string' && typeof doc['id'] !== 'number')) {
82
+ return null;
83
+ }
84
+ const id = doc['id'];
85
+ const contextName = input.contextName ?? collection;
86
+ const resolved = await resolveLayout({
87
+ payload,
88
+ collection,
89
+ doc,
90
+ contextName,
91
+ draft,
92
+ ...(draft ? { overrideAccess: false } : {}),
93
+ });
94
+ let site;
95
+ if (hasSite) {
96
+ try {
97
+ site = (await payload.findGlobal({
98
+ slug: SITE_GLOBAL,
99
+ depth: 1,
100
+ draft,
101
+ ...localeArgs,
102
+ ...access,
103
+ }));
104
+ }
105
+ catch {
106
+ site = undefined;
107
+ }
108
+ }
109
+ const source = {
110
+ collections: payload.config.collections,
111
+ globals: (payload.config.globals ?? []),
112
+ };
113
+ const options = {
114
+ contextNames: { [collection]: contextName },
115
+ siteGlobal: SITE_GLOBAL,
116
+ };
117
+ const localeConfig = localeConfigOf(payload.config);
118
+ const path = input.path?.(doc) ?? '';
119
+ const scopes = buildContext({
120
+ source,
121
+ options,
122
+ collection,
123
+ doc,
124
+ site,
125
+ route: {
126
+ path,
127
+ locale: locale ?? localeConfig.default,
128
+ ...(input.page === undefined ? {} : { page: input.page }),
129
+ },
130
+ });
131
+ const context = {
132
+ scopes: scopes,
133
+ locale: locale ?? localeConfig.default,
134
+ locales: {
135
+ default: localeConfig.default,
136
+ fallback: localeConfig.fallback,
137
+ intl: localeConfig.intl,
138
+ },
139
+ timeZone: input.timeZone ?? 'UTC',
140
+ mode: draft ? 'preview' : 'production',
141
+ };
142
+ return {
143
+ document: resolved.layout,
144
+ context,
145
+ layoutRef: resolved.layoutRef,
146
+ layoutSource: resolved.source,
147
+ currentId: id,
148
+ doc,
149
+ };
150
+ };
151
+ const tags = [
152
+ collectionTag(collection),
153
+ ...(input.id === undefined ? [] : [docTag(collection, input.id)]),
154
+ ...(hasSite ? [globalTag(SITE_GLOBAL)] : []),
155
+ 'buildr:theme',
156
+ ].sort();
157
+ const entry = draft
158
+ ? await read()
159
+ : await (input.cache ?? defaultCache)(read, [
160
+ 'buildr:document',
161
+ collection,
162
+ input.slug !== undefined ? `slug:${input.slug}` : `id:${String(input.id)}`,
163
+ locale ?? '',
164
+ String(input.depth ?? 1),
165
+ input.page === undefined ? '' : `page:${input.page}`,
166
+ ], { tags })();
167
+ if (entry === null)
168
+ return null;
169
+ // The tags of the document that was found, which a slug lookup could not know beforehand.
170
+ const found = new Set(tags);
171
+ found.add(docTag(collection, entry.currentId));
172
+ if (entry.layoutRef !== null) {
173
+ const at = entry.layoutRef.indexOf(':');
174
+ const owner = entry.layoutRef.slice(0, at);
175
+ const ref = entry.layoutRef.slice(at + 1);
176
+ found.add(owner === 'buildr-templates' ? `buildr:template:${ref}` : docTag(owner, ref));
177
+ }
178
+ return { ...entry, tags: [...found].sort() };
179
+ }
180
+ /** The slugs of the published documents, for `generateStaticParams`. Documents without a slug are skipped. */
181
+ export async function listPublishedSlugs(input) {
182
+ const field = input.slugField ?? 'slug';
183
+ const configured = configuredLocales(input.payload.config);
184
+ const localeArgs = configured === undefined
185
+ ? {}
186
+ : {
187
+ locale: input.locale ?? configured.default,
188
+ ...(configured.fallback ? {} : { fallbackLocale: false }),
189
+ };
190
+ const result = await input.payload.find({
191
+ collection: input.collection,
192
+ where: { _status: { equals: 'published' } },
193
+ limit: input.limit ?? 1000,
194
+ pagination: false,
195
+ depth: 0,
196
+ draft: false,
197
+ overrideAccess: false,
198
+ select: { [field]: true },
199
+ ...localeArgs,
200
+ });
201
+ const slugs = [];
202
+ for (const doc of result.docs) {
203
+ const slug = doc[field];
204
+ if (typeof slug === 'string' && slug !== '' && isRecord(doc))
205
+ slugs.push(slug);
206
+ }
207
+ return slugs;
208
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=get-document.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-document.test.d.ts","sourceRoot":"","sources":["../../src/next/get-document.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,155 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { boot, title, withChild } from '../plugin/endpoints/endpoints.test-kit.js';
3
+ import { getBuildrDocument, listPublishedSlugs } from './index.js';
4
+ let h;
5
+ beforeAll(async () => {
6
+ h = await boot('get-document', {
7
+ collections: {
8
+ pages: { context: 'page', templates: true, path: (d) => `/${String(d['slug'])}` },
9
+ },
10
+ }, {
11
+ pageAccess: { read: ({ req }) => (req.user ? true : { _status: { equals: 'published' } }) },
12
+ globals: [
13
+ {
14
+ slug: 'site-settings',
15
+ access: { read: () => true },
16
+ fields: [{ name: 'siteName', type: 'text' }],
17
+ },
18
+ ],
19
+ });
20
+ await h.payload.updateGlobal({
21
+ slug: 'site-settings',
22
+ data: { siteName: 'Acme' },
23
+ });
24
+ });
25
+ afterAll(() => h.close());
26
+ const create = (data, status) => h.payload.create({
27
+ collection: 'pages',
28
+ data: { ...data, _status: status },
29
+ draft: status === 'draft',
30
+ });
31
+ /** A cache that only records what it was asked to cache, and runs the read. */
32
+ const spyCache = () => {
33
+ const calls = [];
34
+ const cache = (read, keys, options) => {
35
+ calls.push({ keys, tags: options.tags });
36
+ return read;
37
+ };
38
+ return { cache, calls };
39
+ };
40
+ describe('getBuildrDocument', () => {
41
+ it('reads a published page by slug: layout, context and tags', async () => {
42
+ const page = await create({ title: 'About', slug: 'about', layout: withChild(title('Hi')) }, 'published');
43
+ const { cache, calls } = spyCache();
44
+ const entry = await getBuildrDocument({
45
+ payload: h.payload,
46
+ collection: 'pages',
47
+ slug: 'about',
48
+ contextName: 'page',
49
+ path: (doc) => `/${String(doc['slug'])}`,
50
+ cache,
51
+ });
52
+ expect(entry).not.toBeNull();
53
+ expect(entry?.layoutSource).toBe('document');
54
+ expect(entry?.layoutRef).toBe(`pages:${page.id}`);
55
+ expect(entry?.currentId).toBe(page.id);
56
+ expect(entry?.context).toMatchObject({
57
+ mode: 'production',
58
+ scopes: { page: { title: 'About' }, site: { siteName: 'Acme' }, route: { path: '/about' } },
59
+ });
60
+ expect(entry?.tags).toEqual([
61
+ 'buildr:col:pages',
62
+ `buildr:doc:pages:${page.id}`,
63
+ 'buildr:global:site-settings',
64
+ 'buildr:theme',
65
+ ].sort());
66
+ // Published reads go through the cache, keyed by document and language.
67
+ expect(calls).toHaveLength(1);
68
+ expect(calls[0]?.keys).toContain('slug:about');
69
+ });
70
+ it('exposes the page of a listing as route.params.page, keyed in the cache', async () => {
71
+ await create({ title: 'Blog', slug: 'blog', layout: withChild(title('Hi')) }, 'published');
72
+ const { cache, calls } = spyCache();
73
+ const entry = await getBuildrDocument({
74
+ payload: h.payload,
75
+ collection: 'pages',
76
+ slug: 'blog',
77
+ contextName: 'page',
78
+ page: 2,
79
+ cache,
80
+ });
81
+ expect(entry?.context).toMatchObject({ scopes: { route: { params: { page: 2 } } } });
82
+ expect(calls[0]?.keys).toContain('page:2');
83
+ });
84
+ it('reads by id and falls back to the built-in layout', async () => {
85
+ const page = await create({ title: 'Bare', slug: 'bare' }, 'published');
86
+ const entry = await getBuildrDocument({
87
+ payload: h.payload,
88
+ collection: 'pages',
89
+ id: page.id,
90
+ contextName: 'page',
91
+ cache: spyCache().cache,
92
+ });
93
+ expect(entry?.layoutSource).toBe('builtin');
94
+ expect(entry?.layoutRef).toBeNull();
95
+ });
96
+ it('does not show a draft to a visitor, and does not cache a draft read', async () => {
97
+ await create({ title: 'Secret', slug: 'secret' }, 'draft');
98
+ const { cache, calls } = spyCache();
99
+ const visitor = await getBuildrDocument({
100
+ payload: h.payload,
101
+ collection: 'pages',
102
+ slug: 'secret',
103
+ cache,
104
+ });
105
+ expect(visitor).toBeNull();
106
+ const user = { id: 1, collection: 'users' };
107
+ const asEditor = spyCache();
108
+ const draftRead = await getBuildrDocument({
109
+ payload: h.payload,
110
+ collection: 'pages',
111
+ slug: 'secret',
112
+ draft: true,
113
+ user,
114
+ cache: asEditor.cache,
115
+ });
116
+ // The test config has no auth collection, so the local API treats the user as a visitor here; what
117
+ // matters is that a draft read never touches the cache.
118
+ expect(asEditor.calls).toHaveLength(0);
119
+ expect(draftRead === null || draftRead.context.mode === 'preview').toBe(true);
120
+ expect(calls).toHaveLength(1);
121
+ });
122
+ it('answers null for a missing document and needs exactly one of slug and id', async () => {
123
+ expect(await getBuildrDocument({
124
+ payload: h.payload,
125
+ collection: 'pages',
126
+ slug: 'nope',
127
+ cache: spyCache().cache,
128
+ })).toBeNull();
129
+ await expect(getBuildrDocument({ payload: h.payload, collection: 'pages' })).rejects.toThrow(/exactly one/);
130
+ await expect(getBuildrDocument({ payload: h.payload, collection: 'pages', slug: 'a', id: 1 })).rejects.toThrow(/exactly one/);
131
+ });
132
+ it('runs the read once per cache miss (the wrapper decides)', async () => {
133
+ const read = vi.fn();
134
+ const cache = (fn, _keys, _options) => {
135
+ let memo;
136
+ read();
137
+ return () => {
138
+ memo ??= fn();
139
+ return memo;
140
+ };
141
+ };
142
+ const args = { payload: h.payload, collection: 'pages', slug: 'about', cache };
143
+ await getBuildrDocument(args);
144
+ await getBuildrDocument(args);
145
+ expect(read).toHaveBeenCalledTimes(2); // one wrapper per call; memoization is the cache's business
146
+ });
147
+ });
148
+ describe('listPublishedSlugs', () => {
149
+ it('lists published slugs only', async () => {
150
+ const slugs = await listPublishedSlugs({ payload: h.payload, collection: 'pages' });
151
+ expect(slugs).toContain('about');
152
+ expect(slugs).toContain('bare');
153
+ expect(slugs).not.toContain('secret');
154
+ });
155
+ });
@@ -0,0 +1,9 @@
1
+ export type { BuildrDocumentEntry, CacheRead, GetBuildrDocumentInput, ListPublishedSlugsInput, } from './get-document.ts';
2
+ export { getBuildrDocument, listPublishedSlugs } from './get-document.ts';
3
+ export type { GlobalHookArgs, HookArgs, Revalidate, RevalidateHooks } from './revalidate.ts';
4
+ export { revalidateHooks } from './revalidate.ts';
5
+ export type { SeoEntry, SeoOptions } from './seo.ts';
6
+ export { alternatesOf, seoFromDocument } from './seo.ts';
7
+ export type { TagInput } from './tags.ts';
8
+ export { collectionTag, docTag, globalTag, THEME_TAG, tagsFor, templateTag, } from './tags.ts';
9
+ //# sourceMappingURL=index.d.ts.map