emdash 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/{adapters-BLMa4JGD.d.mts → adapters-N6BF7RCD.d.mts} +1 -1
  2. package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-N6BF7RCD.d.mts.map} +1 -1
  3. package/dist/{apply-kC39ev1Z.mjs → apply-wmVEOSbR.mjs} +56 -9
  4. package/dist/apply-wmVEOSbR.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +80 -27
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.d.mts.map +1 -1
  10. package/dist/astro/middleware/auth.mjs +127 -56
  11. package/dist/astro/middleware/auth.mjs.map +1 -1
  12. package/dist/astro/middleware/request-context.mjs +1 -1
  13. package/dist/astro/middleware/setup.mjs +1 -1
  14. package/dist/astro/middleware.d.mts.map +1 -1
  15. package/dist/astro/middleware.mjs +74 -39
  16. package/dist/astro/middleware.mjs.map +1 -1
  17. package/dist/astro/types.d.mts +30 -9
  18. package/dist/astro/types.d.mts.map +1 -1
  19. package/dist/{byline-CL847F26.mjs → byline-1WQPlISL.mjs} +51 -29
  20. package/dist/byline-1WQPlISL.mjs.map +1 -0
  21. package/dist/{bylines-C2a-2TGt.mjs → bylines-BYdTYmia.mjs} +10 -8
  22. package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BYdTYmia.mjs.map} +1 -1
  23. package/dist/cli/index.mjs +15 -12
  24. package/dist/cli/index.mjs.map +1 -1
  25. package/dist/client/cf-access.d.mts +1 -1
  26. package/dist/client/index.d.mts +1 -1
  27. package/dist/client/index.mjs +1 -1
  28. package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
  29. package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
  30. package/dist/{content-D6C2WsZC.mjs → content-BmXndhdi.mjs} +16 -3
  31. package/dist/content-BmXndhdi.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/index.mjs +1 -1
  34. package/dist/db/libsql.d.mts +1 -1
  35. package/dist/db/postgres.d.mts +1 -1
  36. package/dist/db/sqlite.d.mts +1 -1
  37. package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
  38. package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
  39. package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
  40. package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
  41. package/dist/{index-CLBc4gw-.d.mts → index-UHEVQMus.d.mts} +55 -17
  42. package/dist/index-UHEVQMus.d.mts.map +1 -0
  43. package/dist/index.d.mts +11 -11
  44. package/dist/index.mjs +17 -17
  45. package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
  46. package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
  47. package/dist/{loader-fz8Q_3EO.mjs → loader-CHb2v0jm.mjs} +1 -1
  48. package/dist/{loader-fz8Q_3EO.mjs.map → loader-CHb2v0jm.mjs.map} +1 -1
  49. package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
  50. package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
  51. package/dist/media/index.d.mts +1 -1
  52. package/dist/media/local-runtime.d.mts +7 -7
  53. package/dist/{mode-C2EzN1uE.mjs → mode-CYeM2rPt.mjs} +1 -1
  54. package/dist/{mode-C2EzN1uE.mjs.map → mode-CYeM2rPt.mjs.map} +1 -1
  55. package/dist/page/index.d.mts +10 -1
  56. package/dist/page/index.d.mts.map +1 -1
  57. package/dist/page/index.mjs +8 -4
  58. package/dist/page/index.mjs.map +1 -1
  59. package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-bOx1xCTY.d.mts} +1 -1
  60. package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-bOx1xCTY.d.mts.map} +1 -1
  61. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  62. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  63. package/dist/{query-BVYN0PJ6.mjs → query-5Hcv_5ER.mjs} +20 -8
  64. package/dist/{query-BVYN0PJ6.mjs.map → query-5Hcv_5ER.mjs.map} +1 -1
  65. package/dist/{registry-BNYQKX_d.mjs → registry-1EvbAfsC.mjs} +6 -2
  66. package/dist/{registry-BNYQKX_d.mjs.map → registry-1EvbAfsC.mjs.map} +1 -1
  67. package/dist/{runner-BraqvGYk.mjs → runner-BoN0-FPi.mjs} +155 -130
  68. package/dist/runner-BoN0-FPi.mjs.map +1 -0
  69. package/dist/{runner-EAtf0ZIe.d.mts → runner-DTqkzOzc.d.mts} +2 -2
  70. package/dist/{runner-EAtf0ZIe.d.mts.map → runner-DTqkzOzc.d.mts.map} +1 -1
  71. package/dist/runtime.d.mts +6 -6
  72. package/dist/runtime.mjs +1 -1
  73. package/dist/{search-C1gg67nN.mjs → search-BsYMed12.mjs} +235 -105
  74. package/dist/search-BsYMed12.mjs.map +1 -0
  75. package/dist/seed/index.d.mts +2 -2
  76. package/dist/seed/index.mjs +8 -8
  77. package/dist/seo/index.d.mts +1 -1
  78. package/dist/storage/local.d.mts +1 -1
  79. package/dist/storage/local.mjs +1 -1
  80. package/dist/storage/s3.d.mts +1 -1
  81. package/dist/storage/s3.mjs +1 -1
  82. package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
  83. package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
  84. package/dist/{transport-yxiQsi8I.mjs → transport-Bl8cTdYt.mjs} +1 -1
  85. package/dist/{transport-yxiQsi8I.mjs.map → transport-Bl8cTdYt.mjs.map} +1 -1
  86. package/dist/{transport-BFGblqwG.d.mts → transport-COOs9GSE.d.mts} +1 -1
  87. package/dist/{transport-BFGblqwG.d.mts.map → transport-COOs9GSE.d.mts.map} +1 -1
  88. package/dist/{types-BQo5JS0J.d.mts → types-6dqxBqsH.d.mts} +80 -106
  89. package/dist/types-6dqxBqsH.d.mts.map +1 -0
  90. package/dist/{types-DRjfYOEv.d.mts → types-7-UjSEyB.d.mts} +1 -1
  91. package/dist/{types-DRjfYOEv.d.mts.map → types-7-UjSEyB.d.mts.map} +1 -1
  92. package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
  93. package/dist/{types-CUBbjgmP.mjs.map → types-Bec-r_3_.mjs.map} +1 -1
  94. package/dist/{types-DaNLHo_T.d.mts → types-BljtYPSd.d.mts} +1 -1
  95. package/dist/{types-DaNLHo_T.d.mts.map → types-BljtYPSd.d.mts.map} +1 -1
  96. package/dist/{types-BRuPJGdV.d.mts → types-CIsTnQvJ.d.mts} +3 -1
  97. package/dist/types-CIsTnQvJ.d.mts.map +1 -0
  98. package/dist/types-CMMN0pNg.mjs.map +1 -1
  99. package/dist/{types-DPfzHnjW.d.mts → types-CcreFIIH.d.mts} +1 -1
  100. package/dist/{types-DPfzHnjW.d.mts.map → types-CcreFIIH.d.mts.map} +1 -1
  101. package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
  102. package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
  103. package/dist/{validate-HtxZeaBi.d.mts → validate-B7KP7VLM.d.mts} +4 -4
  104. package/dist/{validate-HtxZeaBi.d.mts.map → validate-B7KP7VLM.d.mts.map} +1 -1
  105. package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
  106. package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
  107. package/package.json +6 -6
  108. package/src/api/csrf.ts +13 -2
  109. package/src/api/handlers/content.ts +7 -0
  110. package/src/api/handlers/dashboard.ts +4 -8
  111. package/src/api/handlers/device-flow.ts +55 -37
  112. package/src/api/handlers/index.ts +6 -1
  113. package/src/api/handlers/seo.ts +48 -21
  114. package/src/api/public-url.ts +84 -0
  115. package/src/api/schemas/content.ts +2 -2
  116. package/src/api/schemas/menus.ts +12 -2
  117. package/src/astro/integration/index.ts +30 -7
  118. package/src/astro/integration/routes.ts +13 -2
  119. package/src/astro/integration/runtime.ts +7 -5
  120. package/src/astro/integration/vite-config.ts +52 -9
  121. package/src/astro/middleware/auth.ts +60 -56
  122. package/src/astro/middleware/csp.ts +25 -0
  123. package/src/astro/middleware.ts +31 -3
  124. package/src/astro/routes/PluginRegistry.tsx +8 -2
  125. package/src/astro/routes/admin.astro +7 -2
  126. package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
  127. package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
  128. package/src/astro/routes/api/auth/invite/complete.ts +3 -2
  129. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
  130. package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
  131. package/src/astro/routes/api/auth/passkey/options.ts +3 -2
  132. package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
  133. package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
  134. package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
  135. package/src/astro/routes/api/auth/signup/complete.ts +3 -2
  136. package/src/astro/routes/api/content/[collection]/index.ts +31 -3
  137. package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
  138. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
  139. package/src/astro/routes/api/manifest.ts +1 -0
  140. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
  141. package/src/astro/routes/api/oauth/authorize.ts +12 -7
  142. package/src/astro/routes/api/oauth/device/code.ts +5 -1
  143. package/src/astro/routes/api/setup/admin-verify.ts +3 -2
  144. package/src/astro/routes/api/setup/admin.ts +3 -2
  145. package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
  146. package/src/astro/routes/api/setup/index.ts +3 -2
  147. package/src/astro/routes/api/snapshot.ts +2 -1
  148. package/src/astro/routes/api/themes/preview.ts +2 -1
  149. package/src/astro/routes/api/well-known/auth.ts +1 -0
  150. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
  151. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
  152. package/src/astro/routes/robots.txt.ts +5 -1
  153. package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
  154. package/src/astro/routes/sitemap.xml.ts +18 -23
  155. package/src/astro/types.ts +27 -1
  156. package/src/auth/passkey-config.ts +6 -10
  157. package/src/bylines/index.ts +11 -8
  158. package/src/cli/commands/login.ts +5 -2
  159. package/src/components/InlinePortableTextEditor.tsx +5 -3
  160. package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
  161. package/src/database/migrations/034_published_at_index.ts +29 -0
  162. package/src/database/migrations/runner.ts +2 -0
  163. package/src/database/repositories/byline.ts +48 -42
  164. package/src/database/repositories/content.ts +23 -1
  165. package/src/database/repositories/options.ts +9 -3
  166. package/src/database/repositories/seo.ts +34 -17
  167. package/src/database/repositories/types.ts +2 -0
  168. package/src/emdash-runtime.ts +61 -18
  169. package/src/import/index.ts +1 -1
  170. package/src/import/sources/wxr.ts +45 -2
  171. package/src/index.ts +9 -1
  172. package/src/mcp/server.ts +85 -5
  173. package/src/menus/index.ts +2 -1
  174. package/src/page/context.ts +13 -1
  175. package/src/page/jsonld.ts +10 -6
  176. package/src/page/seo-contributions.ts +1 -1
  177. package/src/plugins/context.ts +145 -35
  178. package/src/plugins/manager.ts +12 -0
  179. package/src/plugins/types.ts +80 -4
  180. package/src/query.ts +18 -0
  181. package/src/schema/registry.ts +5 -0
  182. package/src/settings/index.ts +64 -0
  183. package/src/utils/chunks.ts +17 -0
  184. package/dist/apply-kC39ev1Z.mjs.map +0 -1
  185. package/dist/byline-CL847F26.mjs.map +0 -1
  186. package/dist/content-D6C2WsZC.mjs.map +0 -1
  187. package/dist/index-CLBc4gw-.d.mts.map +0 -1
  188. package/dist/runner-BraqvGYk.mjs.map +0 -1
  189. package/dist/search-C1gg67nN.mjs.map +0 -1
  190. package/dist/types-BQo5JS0J.d.mts.map +0 -1
  191. package/dist/types-BRuPJGdV.d.mts.map +0 -1
  192. /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
@@ -12,7 +12,9 @@ import { ContentRepository } from "../database/repositories/content.js";
12
12
  import { MediaRepository } from "../database/repositories/media.js";
13
13
  import { OptionsRepository } from "../database/repositories/options.js";
14
14
  import { PluginStorageRepository } from "../database/repositories/plugin-storage.js";
15
+ import { SeoRepository } from "../database/repositories/seo.js";
15
16
  import { UserRepository } from "../database/repositories/user.js";
17
+ import { withTransaction } from "../database/transaction.js";
16
18
  import type { Database } from "../database/types.js";
17
19
  import { validateExternalUrl, SsrfError, stripCredentialHeaders } from "../import/ssrf.js";
18
20
  import type { Storage } from "../storage/types.js";
@@ -36,6 +38,8 @@ import type {
36
38
  UserAccess,
37
39
  UserInfo,
38
40
  ContentItem,
41
+ ContentItemSeoInput,
42
+ ContentWriteInput,
39
43
  MediaItem,
40
44
  PaginatedResult,
41
45
  QueryOptions,
@@ -148,24 +152,66 @@ export function createStorageAccess<T extends PluginStorageConfig>(
148
152
  // Content Access
149
153
  // =============================================================================
150
154
 
155
+ /**
156
+ * Extract `seo` from a plugin-supplied content write input and return both
157
+ * parts. Mutates nothing — returns a new field map without the `seo` key.
158
+ */
159
+ function splitSeoFromInput(input: ContentWriteInput): {
160
+ fields: Record<string, unknown>;
161
+ seo: ContentItemSeoInput | undefined;
162
+ } {
163
+ const { seo, ...fields } = input;
164
+ // Reject non-object seo values rather than silently dropping them.
165
+ if (seo !== undefined && (seo === null || typeof seo !== "object" || Array.isArray(seo))) {
166
+ throw new Error("content.seo must be an object");
167
+ }
168
+ return { fields, seo };
169
+ }
170
+
171
+ /**
172
+ * Reject writing SEO to a collection that does not have it enabled.
173
+ * Matches the REST API behavior (VALIDATION_ERROR).
174
+ */
175
+ async function assertSeoEnabled(
176
+ seoRepo: SeoRepository,
177
+ collection: string,
178
+ seo: ContentItemSeoInput | undefined,
179
+ ): Promise<boolean> {
180
+ const hasSeo = await seoRepo.isEnabled(collection);
181
+ if (seo !== undefined && !hasSeo) {
182
+ throw new Error(
183
+ `Collection "${collection}" does not have SEO enabled. ` +
184
+ `Remove the seo field or enable SEO on this collection.`,
185
+ );
186
+ }
187
+ return hasSeo;
188
+ }
189
+
151
190
  /**
152
191
  * Create read-only content access
153
192
  */
154
193
  export function createContentAccess(db: Kysely<Database>): ContentAccess {
155
194
  const contentRepo = new ContentRepository(db);
195
+ const seoRepo = new SeoRepository(db);
156
196
 
157
197
  return {
158
198
  async get(collection: string, id: string): Promise<ContentItem | null> {
159
199
  const item = await contentRepo.findById(collection, id);
160
200
  if (!item) return null;
161
201
 
162
- return {
202
+ const result: ContentItem = {
163
203
  id: item.id,
164
204
  type: item.type,
165
205
  data: item.data,
166
206
  createdAt: item.createdAt,
167
207
  updatedAt: item.updatedAt,
168
208
  };
209
+
210
+ if (await seoRepo.isEnabled(collection)) {
211
+ result.seo = await seoRepo.get(collection, item.id);
212
+ }
213
+
214
+ return result;
169
215
  },
170
216
 
171
217
  async list(
@@ -188,14 +234,27 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
188
234
  orderBy,
189
235
  });
190
236
 
237
+ const items: ContentItem[] = result.items.map((item) => ({
238
+ id: item.id,
239
+ type: item.type,
240
+ data: item.data,
241
+ createdAt: item.createdAt,
242
+ updatedAt: item.updatedAt,
243
+ }));
244
+
245
+ if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
246
+ const seoMap = await seoRepo.getMany(
247
+ collection,
248
+ items.map((i) => i.id),
249
+ );
250
+ for (const item of items) {
251
+ const seo = seoMap.get(item.id);
252
+ if (seo) item.seo = seo;
253
+ }
254
+ }
255
+
191
256
  return {
192
- items: result.items.map((item) => ({
193
- id: item.id,
194
- type: item.type,
195
- data: item.data,
196
- createdAt: item.createdAt,
197
- updatedAt: item.updatedAt,
198
- })),
257
+ items,
199
258
  cursor: result.nextCursor,
200
259
  hasMore: !!result.nextCursor,
201
260
  };
@@ -204,47 +263,97 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
204
263
  }
205
264
 
206
265
  /**
207
- * Create full content access with write operations
266
+ * Create full content access with write operations.
267
+ *
268
+ * `create` and `update` accept a reserved `seo` key in their `data`
269
+ * argument. When present, it is routed to the core SEO panel
270
+ * (`_emdash_seo`) via `SeoRepository.upsert`, in the same transaction as
271
+ * the content write. The returned `ContentItem.seo` reflects the resulting
272
+ * SEO state for SEO-enabled collections.
208
273
  */
209
274
  export function createContentAccessWithWrite(db: Kysely<Database>): ContentAccessWithWrite {
210
- const contentRepo = new ContentRepository(db);
211
275
  const readAccess = createContentAccess(db);
212
276
 
213
277
  return {
214
278
  ...readAccess,
215
279
 
216
- async create(collection: string, data: Record<string, unknown>): Promise<ContentItem> {
217
- const item = await contentRepo.create({
218
- type: collection,
219
- data,
220
- });
280
+ async create(collection: string, data: ContentWriteInput): Promise<ContentItem> {
281
+ const { fields, seo } = splitSeoFromInput(data);
221
282
 
222
- return {
223
- id: item.id,
224
- type: item.type,
225
- data: item.data,
226
- createdAt: item.createdAt,
227
- updatedAt: item.updatedAt,
228
- };
283
+ return withTransaction(db, async (trx) => {
284
+ const trxContentRepo = new ContentRepository(trx);
285
+ const trxSeoRepo = new SeoRepository(trx);
286
+
287
+ const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
288
+
289
+ const item = await trxContentRepo.create({
290
+ type: collection,
291
+ data: fields,
292
+ });
293
+
294
+ const result: ContentItem = {
295
+ id: item.id,
296
+ type: item.type,
297
+ data: item.data,
298
+ createdAt: item.createdAt,
299
+ updatedAt: item.updatedAt,
300
+ };
301
+
302
+ if (hasSeo) {
303
+ result.seo =
304
+ seo !== undefined
305
+ ? await trxSeoRepo.upsert(collection, item.id, seo)
306
+ : await trxSeoRepo.get(collection, item.id);
307
+ }
308
+
309
+ return result;
310
+ });
229
311
  },
230
312
 
231
- async update(
232
- collection: string,
233
- id: string,
234
- data: Record<string, unknown>,
235
- ): Promise<ContentItem> {
236
- const item = await contentRepo.update(collection, id, { data });
313
+ async update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem> {
314
+ const { fields, seo } = splitSeoFromInput(data);
315
+
316
+ return withTransaction(db, async (trx) => {
317
+ const trxContentRepo = new ContentRepository(trx);
318
+ const trxSeoRepo = new SeoRepository(trx);
319
+
320
+ const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
321
+
322
+ // Pass the `data` payload to ContentRepository.update only when
323
+ // there are field updates — passing an empty object would still
324
+ // bump updated_at/version, but we want a seo-only call to touch
325
+ // only the SEO table. ContentRepository.update handles the no-op
326
+ // path by returning the current row.
327
+ const hasFieldUpdates = Object.keys(fields).length > 0;
328
+ const item = hasFieldUpdates
329
+ ? await trxContentRepo.update(collection, id, { data: fields })
330
+ : await (async () => {
331
+ const existing = await trxContentRepo.findById(collection, id);
332
+ if (!existing) throw new Error("Content not found");
333
+ return existing;
334
+ })();
335
+
336
+ const result: ContentItem = {
337
+ id: item.id,
338
+ type: item.type,
339
+ data: item.data,
340
+ createdAt: item.createdAt,
341
+ updatedAt: item.updatedAt,
342
+ };
237
343
 
238
- return {
239
- id: item.id,
240
- type: item.type,
241
- data: item.data,
242
- createdAt: item.createdAt,
243
- updatedAt: item.updatedAt,
244
- };
344
+ if (hasSeo) {
345
+ result.seo =
346
+ seo !== undefined
347
+ ? await trxSeoRepo.upsert(collection, item.id, seo)
348
+ : await trxSeoRepo.get(collection, item.id);
349
+ }
350
+
351
+ return result;
352
+ });
245
353
  },
246
354
 
247
355
  async delete(collection: string, id: string): Promise<boolean> {
356
+ const contentRepo = new ContentRepository(db);
248
357
  return contentRepo.delete(collection, id);
249
358
  },
250
359
  };
@@ -384,6 +493,7 @@ const MAX_PLUGIN_REDIRECTS = 5;
384
493
 
385
494
  function isHostAllowed(host: string, allowedHosts: string[]): boolean {
386
495
  return allowedHosts.some((pattern) => {
496
+ if (pattern === "*") return true;
387
497
  if (pattern.startsWith("*.")) {
388
498
  const suffix = pattern.slice(1); // ".example.com"
389
499
  return host.endsWith(suffix) || host === pattern.slice(2);
@@ -18,6 +18,7 @@ import type { Storage } from "../storage/types.js";
18
18
  import type { PluginContextFactoryOptions } from "./context.js";
19
19
  import { setCronTasksEnabled } from "./cron.js";
20
20
  import { definePlugin } from "./define-plugin.js";
21
+ import type { EmailPipeline } from "./email.js";
21
22
  import {
22
23
  HookPipeline,
23
24
  type HookResult,
@@ -83,6 +84,17 @@ export class PluginManager {
83
84
  };
84
85
  }
85
86
 
87
+ /**
88
+ * Set the email pipeline used when creating plugin contexts.
89
+ * Reinitializes routes/hooks if already initialized so ctx.email is available immediately.
90
+ */
91
+ setEmailPipeline(pipeline: EmailPipeline): void {
92
+ this.factoryOptions.emailPipeline = pipeline;
93
+ if (this.initialized) {
94
+ this.reinitialize();
95
+ }
96
+ }
97
+
86
98
  // =========================================================================
87
99
  // Plugin Registration
88
100
  // =========================================================================
@@ -161,6 +161,34 @@ export interface KVAccess {
161
161
  list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
162
162
  }
163
163
 
164
+ /**
165
+ * SEO metadata for a content item, as stored in the core SEO panel.
166
+ *
167
+ * Only present on items in collections with `has_seo = 1`. For collections
168
+ * without SEO enabled, `ContentItem.seo` is `undefined`.
169
+ */
170
+ export interface ContentItemSeo {
171
+ title: string | null;
172
+ description: string | null;
173
+ image: string | null;
174
+ canonical: string | null;
175
+ noIndex: boolean;
176
+ }
177
+
178
+ /**
179
+ * SEO input accepted by content write operations.
180
+ *
181
+ * All fields are optional — only fields that are present overwrite existing
182
+ * values. An empty object is treated as a no-op.
183
+ */
184
+ export interface ContentItemSeoInput {
185
+ title?: string | null;
186
+ description?: string | null;
187
+ image?: string | null;
188
+ canonical?: string | null;
189
+ noIndex?: boolean;
190
+ }
191
+
164
192
  /**
165
193
  * Content item returned from content API
166
194
  */
@@ -168,6 +196,11 @@ export interface ContentItem {
168
196
  id: string;
169
197
  type: string;
170
198
  data: Record<string, unknown>;
199
+ /**
200
+ * SEO metadata, populated when the collection has SEO enabled
201
+ * (`has_seo = 1`). `undefined` for non-SEO collections.
202
+ */
203
+ seo?: ContentItemSeo;
171
204
  createdAt: string;
172
205
  updatedAt: string;
173
206
  }
@@ -181,6 +214,18 @@ export interface ContentListOptions {
181
214
  orderBy?: Record<string, "asc" | "desc">;
182
215
  }
183
216
 
217
+ /**
218
+ * Input accepted by `content.create` / `content.update`.
219
+ *
220
+ * Most entries are field slugs mapped to their values. The reserved `seo`
221
+ * key is extracted and routed to the core SEO panel (the `_emdash_seo`
222
+ * table), matching the shape accepted by the REST API. Passing `seo` for a
223
+ * collection that does not have SEO enabled throws a validation error.
224
+ */
225
+ export type ContentWriteInput = Record<string, unknown> & {
226
+ seo?: ContentItemSeoInput;
227
+ };
228
+
184
229
  /**
185
230
  * Content access interface - capability-gated
186
231
  */
@@ -190,8 +235,8 @@ export interface ContentAccess {
190
235
  list(collection: string, options?: ContentListOptions): Promise<PaginatedResult<ContentItem>>;
191
236
 
192
237
  // Write operations (requires write:content) - optional on interface
193
- create?(collection: string, data: Record<string, unknown>): Promise<ContentItem>;
194
- update?(collection: string, id: string, data: Record<string, unknown>): Promise<ContentItem>;
238
+ create?(collection: string, data: ContentWriteInput): Promise<ContentItem>;
239
+ update?(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
195
240
  delete?(collection: string, id: string): Promise<boolean>;
196
241
  }
197
242
 
@@ -199,8 +244,8 @@ export interface ContentAccess {
199
244
  * Full content access with write operations
200
245
  */
201
246
  export interface ContentAccessWithWrite extends ContentAccess {
202
- create(collection: string, data: Record<string, unknown>): Promise<ContentItem>;
203
- update(collection: string, id: string, data: Record<string, unknown>): Promise<ContentItem>;
247
+ create(collection: string, data: ContentWriteInput): Promise<ContentItem>;
248
+ update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
204
249
  delete(collection: string, id: string): Promise<boolean>;
205
250
  }
206
251
 
@@ -747,6 +792,17 @@ export type UninstallHandler = (event: UninstallEvent, ctx: PluginContext) => Pr
747
792
  /** Placement targets for page fragment contributions */
748
793
  export type PagePlacement = "head" | "body:start" | "body:end";
749
794
 
795
+ /**
796
+ * A single breadcrumb trail item. Used by `PublicPageContext.breadcrumbs`
797
+ * so themes can publish breadcrumb trails that SEO plugins consume.
798
+ */
799
+ export interface BreadcrumbItem {
800
+ /** Display name for this crumb (e.g. "Home", "Blog", "My Post"). */
801
+ name: string;
802
+ /** Absolute or root-relative URL for this crumb. */
803
+ url: string;
804
+ }
805
+
750
806
  /**
751
807
  * Describes the page being rendered. Passed to page hooks so plugins
752
808
  * can decide what to contribute without fetching content themselves.
@@ -757,7 +813,10 @@ export interface PublicPageContext {
757
813
  locale: string | null;
758
814
  kind: "content" | "custom";
759
815
  pageType: string;
816
+ /** Full document title for the rendered page */
760
817
  title: string | null;
818
+ /** Page-only title for OG/Twitter/JSON-LD headline output */
819
+ pageTitle?: string | null;
761
820
  description: string | null;
762
821
  canonical: string | null;
763
822
  image: string | null;
@@ -781,6 +840,23 @@ export interface PublicPageContext {
781
840
  };
782
841
  /** Site name for structured data and og:site_name */
783
842
  siteName?: string;
843
+ /**
844
+ * Optional breadcrumb trail for this page, root first. When set,
845
+ * SEO plugins should use this verbatim rather than deriving a trail
846
+ * from `path`. Themes typically populate this at the point they
847
+ * build the context (e.g. from a content hierarchy walk, taxonomy
848
+ * lookup, or per-`pageType` routing logic).
849
+ *
850
+ * Semantics for consumers:
851
+ * - `undefined` — theme has no opinion; consumer falls back to
852
+ * its own derivation.
853
+ * - `[]` — this page has no breadcrumbs (e.g. homepage); consumer
854
+ * should skip `BreadcrumbList` emission entirely.
855
+ * - Non-empty array — used verbatim for `BreadcrumbList` output.
856
+ */
857
+ breadcrumbs?: BreadcrumbItem[];
858
+ /** Public-facing site URL (origin) for structured data */
859
+ siteUrl?: string;
784
860
  }
785
861
 
786
862
  // ── page:metadata ───────────────────────────────────────────────
package/src/query.ts CHANGED
@@ -422,6 +422,24 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
422
422
 
423
423
  if (!baseEntry) continue; // Try next locale in chain
424
424
 
425
+ // Preview tokens are item-scoped: verify the resolved entry matches.
426
+ // Edit mode (authenticated editors) has collection-wide draft access.
427
+ if (isPreviewMode && !isEditMode) {
428
+ const dbId = entryDatabaseId(baseEntry);
429
+ if (preview!.id !== dbId && preview!.id !== id) {
430
+ // Token doesn't match — serve only if publicly visible, without draft access
431
+ if (isVisible(baseEntry)) {
432
+ return successResult(wrapEntry(baseEntry), {
433
+ isPreview: false,
434
+ fallbackLocale,
435
+ cacheHint: cacheHint ?? {},
436
+ });
437
+ }
438
+ // Not visible — try next locale in fallback chain
439
+ continue;
440
+ }
441
+ }
442
+
425
443
  // Check if entry has a draft revision — if so, re-fetch with revision data
426
444
  const baseData = entryData(baseEntry);
427
445
  const draftRevisionId = dataStr(baseData, "draftRevisionId") || undefined;
@@ -595,6 +595,11 @@ export class SchemaRegistry {
595
595
  CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_created_id`)}
596
596
  ON ${sql.ref(tableName)} (deleted_at, created_at DESC, id DESC)
597
597
  `.execute(conn);
598
+
599
+ await sql`
600
+ CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_published_id`)}
601
+ ON ${sql.ref(tableName)} (deleted_at, published_at DESC, id DESC)
602
+ `.execute(conn);
598
603
  }
599
604
 
600
605
  /**
@@ -201,3 +201,67 @@ export async function setSiteSettings(
201
201
 
202
202
  await options.setMany(updates);
203
203
  }
204
+
205
+ /**
206
+ * Get a single plugin setting by key.
207
+ *
208
+ * Plugin settings are stored in the options table under
209
+ * `plugin:<pluginId>:settings:<key>`.
210
+ */
211
+ export async function getPluginSetting<T = unknown>(
212
+ pluginId: string,
213
+ key: string,
214
+ ): Promise<T | undefined> {
215
+ const db = await getDb();
216
+ return getPluginSettingWithDb<T>(pluginId, key, db);
217
+ }
218
+
219
+ /**
220
+ * Get a single plugin setting by key (with explicit db).
221
+ *
222
+ * @internal Use `getPluginSetting()` in templates and plugin rendering code.
223
+ */
224
+ export async function getPluginSettingWithDb<T = unknown>(
225
+ pluginId: string,
226
+ key: string,
227
+ db: Kysely<Database>,
228
+ ): Promise<T | undefined> {
229
+ const options = new OptionsRepository(db);
230
+ const value = await options.get<T>(`plugin:${pluginId}:settings:${key}`);
231
+ return value ?? undefined;
232
+ }
233
+
234
+ /**
235
+ * Get all persisted plugin settings for a plugin.
236
+ *
237
+ * Defaults declared in `admin.settingsSchema` are not materialized
238
+ * automatically; callers should apply their own fallback defaults.
239
+ */
240
+ export async function getPluginSettings(pluginId: string): Promise<Record<string, unknown>> {
241
+ const db = await getDb();
242
+ return getPluginSettingsWithDb(pluginId, db);
243
+ }
244
+
245
+ /**
246
+ * Get all persisted plugin settings for a plugin (with explicit db).
247
+ *
248
+ * @internal Use `getPluginSettings()` in templates and plugin rendering code.
249
+ */
250
+ export async function getPluginSettingsWithDb(
251
+ pluginId: string,
252
+ db: Kysely<Database>,
253
+ ): Promise<Record<string, unknown>> {
254
+ const prefix = `plugin:${pluginId}:settings:`;
255
+ const options = new OptionsRepository(db);
256
+ const allOptions = await options.getByPrefix(prefix);
257
+
258
+ const settings: Record<string, unknown> = {};
259
+ for (const [key, value] of allOptions) {
260
+ if (!key.startsWith(prefix)) {
261
+ continue;
262
+ }
263
+ settings[key.slice(prefix.length)] = value;
264
+ }
265
+
266
+ return settings;
267
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Split an array into chunks of at most `size` elements.
3
+ *
4
+ * Used to keep SQL `IN (?, ?, …)` clauses within Cloudflare D1's
5
+ * bound-parameter limit (~100 per statement).
6
+ */
7
+ export function chunks<T>(arr: T[], size: number): T[][] {
8
+ if (arr.length === 0) return [];
9
+ const result: T[][] = [];
10
+ for (let i = 0; i < arr.length; i += size) {
11
+ result.push(arr.slice(i, i + size));
12
+ }
13
+ return result;
14
+ }
15
+
16
+ /** Conservative default chunk size for SQL IN clauses (well within D1's limit). */
17
+ export const SQL_BATCH_SIZE = 50;