emdash 0.2.0 → 0.4.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 (197) hide show
  1. package/dist/{adapters-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
  2. package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
  3. package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
  4. package/dist/apply-Cma_PiF6.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +25 -11
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +38 -25
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.mjs +2 -2
  11. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  12. package/dist/astro/middleware/redirect.mjs +20 -8
  13. package/dist/astro/middleware/redirect.mjs.map +1 -1
  14. package/dist/astro/middleware/request-context.mjs +12 -2
  15. package/dist/astro/middleware/request-context.mjs.map +1 -1
  16. package/dist/astro/middleware/setup.mjs +1 -1
  17. package/dist/astro/middleware.d.mts.map +1 -1
  18. package/dist/astro/middleware.mjs +52 -45
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +9 -9
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
  23. package/dist/byline-WuOq9MFJ.mjs.map +1 -0
  24. package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
  25. package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
  26. package/dist/cache-E3Dts-yT.mjs +56 -0
  27. package/dist/cache-E3Dts-yT.mjs.map +1 -0
  28. package/dist/cli/index.mjs +13 -13
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
  34. package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
  35. package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
  36. package/dist/content-BsBoyj8G.mjs.map +1 -0
  37. package/dist/db/index.d.mts +3 -3
  38. package/dist/db/index.mjs +2 -2
  39. package/dist/db/libsql.d.mts +1 -1
  40. package/dist/db/postgres.d.mts +1 -1
  41. package/dist/db/sqlite.d.mts +1 -1
  42. package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
  43. package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
  44. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  45. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  46. package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
  47. package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
  48. package/dist/{index-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
  49. package/dist/index-CRg3PWfZ.d.mts.map +1 -0
  50. package/dist/index.d.mts +11 -11
  51. package/dist/index.mjs +20 -20
  52. package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
  53. package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
  54. package/dist/{loader-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
  55. package/dist/loader-BYzwzORf.mjs.map +1 -0
  56. package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
  57. package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
  58. package/dist/media/index.d.mts +1 -1
  59. package/dist/media/index.mjs +1 -1
  60. package/dist/media/local-runtime.d.mts +7 -7
  61. package/dist/{mode-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
  62. package/dist/{mode-CYeM2rPt.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
  63. package/dist/page/index.d.mts +1 -1
  64. package/dist/patterns-CrCYkMBb.mjs +93 -0
  65. package/dist/patterns-CrCYkMBb.mjs.map +1 -0
  66. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
  67. package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
  68. package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
  69. package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
  70. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  71. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  72. package/dist/{query-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
  73. package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
  74. package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
  75. package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
  76. package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
  77. package/dist/registry-BgnP3ysR.mjs.map +1 -0
  78. package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
  79. package/dist/runner-Cd-_WyDo.mjs.map +1 -0
  80. package/dist/{runner-DTqkzOzc.d.mts → runner-DYv3rX8P.d.mts} +10 -3
  81. package/dist/runner-DYv3rX8P.d.mts.map +1 -0
  82. package/dist/runtime.d.mts +6 -6
  83. package/dist/runtime.mjs +2 -2
  84. package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
  85. package/dist/search-B5p9D36n.mjs.map +1 -0
  86. package/dist/seed/index.d.mts +2 -2
  87. package/dist/seed/index.mjs +10 -10
  88. package/dist/seo/index.d.mts +1 -1
  89. package/dist/storage/local.d.mts +1 -1
  90. package/dist/storage/local.mjs +1 -1
  91. package/dist/storage/s3.d.mts +11 -3
  92. package/dist/storage/s3.d.mts.map +1 -1
  93. package/dist/storage/s3.mjs +76 -15
  94. package/dist/storage/s3.mjs.map +1 -1
  95. package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
  96. package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
  97. package/dist/transaction-Cn2rjY78.mjs +28 -0
  98. package/dist/transaction-Cn2rjY78.mjs.map +1 -0
  99. package/dist/{transport-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
  100. package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
  101. package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
  102. package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
  103. package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
  104. package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
  105. package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
  106. package/dist/types-BYWYxLcp.d.mts.map +1 -0
  107. package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
  108. package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
  109. package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
  110. package/dist/types-DNZpaCBk.d.mts.map +1 -0
  111. package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
  112. package/dist/types-Dz9_WMS6.mjs.map +1 -0
  113. package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
  114. package/dist/{types-CcreFIIH.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
  115. package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
  116. package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
  117. package/dist/{validate-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
  118. package/dist/{validate-B7KP7VLM.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
  119. package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
  120. package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
  121. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  122. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  123. package/dist/version-DlTDRdpv.mjs +7 -0
  124. package/dist/version-DlTDRdpv.mjs.map +1 -0
  125. package/package.json +7 -5
  126. package/src/api/handlers/content.ts +36 -25
  127. package/src/api/handlers/menus.ts +19 -16
  128. package/src/api/handlers/redirects.ts +95 -3
  129. package/src/api/schemas/redirects.ts +1 -0
  130. package/src/astro/integration/index.ts +2 -3
  131. package/src/astro/integration/runtime.ts +8 -14
  132. package/src/astro/integration/vite-config.ts +14 -4
  133. package/src/astro/middleware/redirect.ts +30 -15
  134. package/src/astro/middleware.ts +11 -19
  135. package/src/astro/routes/admin.astro +2 -2
  136. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  137. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  138. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  139. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  140. package/src/astro/routes/api/manifest.ts +3 -1
  141. package/src/astro/routes/api/redirects/[id].ts +3 -0
  142. package/src/astro/routes/api/redirects/index.ts +2 -0
  143. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
  144. package/src/astro/routes/api/schema/collections/index.ts +1 -0
  145. package/src/astro/storage/adapters.ts +19 -5
  146. package/src/astro/storage/types.ts +12 -4
  147. package/src/astro/types.ts +1 -0
  148. package/src/bylines/index.ts +50 -2
  149. package/src/cleanup.ts +3 -3
  150. package/src/cli/commands/bundle-utils.ts +5 -5
  151. package/src/database/dialect-helpers.ts +3 -0
  152. package/src/database/migrations/011_sections.ts +2 -2
  153. package/src/database/migrations/runner.ts +23 -2
  154. package/src/database/repositories/byline.ts +2 -1
  155. package/src/database/repositories/content.ts +5 -0
  156. package/src/database/repositories/redirect.ts +13 -0
  157. package/src/database/validate.ts +10 -10
  158. package/src/emdash-runtime.ts +23 -9
  159. package/src/index.ts +3 -0
  160. package/src/loader.ts +2 -0
  161. package/src/mcp/server.ts +40 -67
  162. package/src/menus/index.ts +4 -0
  163. package/src/plugins/context.ts +28 -4
  164. package/src/plugins/cron.ts +29 -4
  165. package/src/plugins/hooks.ts +22 -10
  166. package/src/plugins/index.ts +1 -0
  167. package/src/plugins/manager.ts +6 -2
  168. package/src/plugins/marketplace.ts +33 -3
  169. package/src/plugins/routes.ts +3 -3
  170. package/src/plugins/types.ts +7 -0
  171. package/src/query.ts +37 -14
  172. package/src/redirects/cache.ts +68 -0
  173. package/src/redirects/loops.ts +318 -0
  174. package/src/schema/registry.ts +3 -0
  175. package/src/search/fts-manager.ts +24 -11
  176. package/src/search/query.ts +8 -9
  177. package/src/seed/apply.ts +49 -28
  178. package/src/storage/s3.ts +94 -25
  179. package/src/storage/types.ts +13 -5
  180. package/src/utils/slugify.ts +11 -0
  181. package/src/version.ts +12 -0
  182. package/src/visual-editing/toolbar.ts +11 -1
  183. package/dist/apply-wmVEOSbR.mjs.map +0 -1
  184. package/dist/byline-1WQPlISL.mjs.map +0 -1
  185. package/dist/bylines-BYdTYmia.mjs.map +0 -1
  186. package/dist/content-BmXndhdi.mjs.map +0 -1
  187. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  188. package/dist/index-UHEVQMus.d.mts.map +0 -1
  189. package/dist/loader-CHb2v0jm.mjs.map +0 -1
  190. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  191. package/dist/registry-1EvbAfsC.mjs.map +0 -1
  192. package/dist/runner-BoN0-FPi.mjs.map +0 -1
  193. package/dist/runner-DTqkzOzc.d.mts.map +0 -1
  194. package/dist/search-BsYMed12.mjs.map +0 -1
  195. package/dist/types-6dqxBqsH.d.mts.map +0 -1
  196. package/dist/types-Bec-r_3_.mjs.map +0 -1
  197. package/dist/types-BljtYPSd.d.mts.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -1257,26 +1257,8 @@ export function createMcpServer(): McpServer {
1257
1257
  requireScope(extra, "content:read");
1258
1258
  const ec = getEmDash(extra);
1259
1259
  try {
1260
- const rows = (await ec.db
1261
- .selectFrom("_emdash_taxonomy_defs" as never)
1262
- .selectAll()
1263
- .execute()) as Array<{
1264
- id: string;
1265
- name: string;
1266
- label: string;
1267
- label_singular: string | null;
1268
- hierarchical: number;
1269
- collections: string | null;
1270
- }>;
1271
- const taxonomies = rows.map((row) => ({
1272
- id: row.id,
1273
- name: row.name,
1274
- label: row.label,
1275
- labelSingular: row.label_singular ?? undefined,
1276
- hierarchical: row.hierarchical === 1,
1277
- collections: row.collections ? JSON.parse(row.collections) : [],
1278
- }));
1279
- return jsonResult(taxonomies);
1260
+ const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1261
+ return unwrap(await handleTaxonomyList(ec.db));
1280
1262
  } catch (error) {
1281
1263
  return errorResult(error);
1282
1264
  }
@@ -1302,32 +1284,44 @@ export function createMcpServer(): McpServer {
1302
1284
  requireScope(extra, "content:read");
1303
1285
  const ec = getEmDash(extra);
1304
1286
  try {
1305
- const taxonomy = (await ec.db
1306
- .selectFrom("_emdash_taxonomy_defs" as never)
1307
- .select("id" as never)
1308
- .where("name" as never, "=", args.taxonomy as never)
1309
- .executeTakeFirst()) as { id: string } | undefined;
1310
-
1287
+ // Verify taxonomy exists via handler layer
1288
+ const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1289
+ const listResult = await handleTaxonomyList(ec.db);
1290
+ if (!listResult.success) return unwrap(listResult);
1291
+
1292
+ const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
1293
+ .taxonomies;
1294
+ const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1311
1295
  if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1312
1296
 
1297
+ // Paginated term query via repository (avoids N+1 of handleTermList)
1298
+ const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1299
+ const repo = new TaxonomyRepository(ec.db);
1313
1300
  const limit = Math.min(args.limit ?? 50, 100);
1314
- let query = ec.db
1315
- .selectFrom("_emdash_taxonomy_terms" as never)
1316
- .selectAll()
1317
- .where("taxonomy_id" as never, "=", taxonomy.id as never)
1318
- .orderBy("label" as never, "asc")
1319
- .limit(limit + 1);
1301
+ const terms = await repo.findByName(args.taxonomy);
1320
1302
 
1303
+ // Manual cursor pagination over the sorted results
1304
+ let startIdx = 0;
1321
1305
  if (args.cursor) {
1322
- query = query.where("id" as never, ">" as never, args.cursor as never);
1306
+ const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
1307
+ if (cursorIdx >= 0) startIdx = cursorIdx + 1;
1323
1308
  }
1324
1309
 
1325
- const rows = (await query.execute()) as Array<{ id: string }>;
1326
- const hasMore = rows.length > limit;
1327
- const items = hasMore ? rows.slice(0, limit) : rows;
1328
- const nextCursor = hasMore ? items.at(-1)?.id : undefined;
1329
-
1330
- return jsonResult({ items, nextCursor });
1310
+ const page = terms.slice(startIdx, startIdx + limit);
1311
+ const hasMore = startIdx + limit < terms.length;
1312
+ const nextCursor = hasMore ? page.at(-1)?.id : undefined;
1313
+
1314
+ return jsonResult({
1315
+ items: page.map((t) => ({
1316
+ id: t.id,
1317
+ name: t.name,
1318
+ slug: t.slug,
1319
+ label: t.label,
1320
+ parentId: t.parentId,
1321
+ description: typeof t.data?.description === "string" ? t.data.description : undefined,
1322
+ })),
1323
+ nextCursor,
1324
+ });
1331
1325
  } catch (error) {
1332
1326
  return errorResult(error);
1333
1327
  }
@@ -1354,36 +1348,15 @@ export function createMcpServer(): McpServer {
1354
1348
  requireRole(extra, Role.EDITOR);
1355
1349
  const ec = getEmDash(extra);
1356
1350
  try {
1357
- const { ulid } = await import("ulidx");
1358
-
1359
- const taxonomy = (await ec.db
1360
- .selectFrom("_emdash_taxonomy_defs" as never)
1361
- .select("id" as never)
1362
- .where("name" as never, "=", args.taxonomy as never)
1363
- .executeTakeFirst()) as { id: string } | undefined;
1364
-
1365
- if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1366
-
1367
- const id = ulid();
1368
- await ec.db
1369
- .insertInto("_emdash_taxonomy_terms" as never)
1370
- .values({
1371
- id,
1372
- taxonomy_id: taxonomy.id,
1351
+ const { handleTermCreate } = await import("../api/handlers/taxonomies.js");
1352
+ return unwrap(
1353
+ await handleTermCreate(ec.db, args.taxonomy, {
1373
1354
  slug: args.slug,
1374
1355
  label: args.label,
1375
- parent_id: args.parentId ?? null,
1376
- description: args.description ?? null,
1377
- } as never)
1378
- .execute();
1379
-
1380
- const term = await ec.db
1381
- .selectFrom("_emdash_taxonomy_terms" as never)
1382
- .selectAll()
1383
- .where("id" as never, "=", id as never)
1384
- .executeTakeFirstOrThrow();
1385
-
1386
- return jsonResult(term);
1356
+ parentId: args.parentId,
1357
+ description: args.description,
1358
+ }),
1359
+ );
1387
1360
  } catch (error) {
1388
1361
  return errorResult(error);
1389
1362
  }
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
8
8
  import { sql } from "kysely";
9
9
 
10
10
  import type { Database } from "../database/types.js";
11
+ import { validateIdentifier } from "../database/validate.js";
11
12
  import { getDb } from "../loader.js";
12
13
  import { sanitizeHref } from "../utils/url.js";
13
14
  import type { Menu, MenuItem, MenuItemRow } from "./types.js";
@@ -273,6 +274,9 @@ async function resolveContentUrl(
273
274
  }
274
275
 
275
276
  try {
277
+ // Validate collection name before interpolating into table reference
278
+ validateIdentifier(collection, "menu item collection");
279
+
276
280
  // Dynamic content tables (ec_*) aren't in the Database type, so use sql
277
281
  const result = await sql<{ slug: string }>`
278
282
  SELECT slug FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${entryId} LIMIT 1
@@ -202,9 +202,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
202
202
  const result: ContentItem = {
203
203
  id: item.id,
204
204
  type: item.type,
205
+ slug: item.slug,
206
+ status: item.status,
205
207
  data: item.data,
206
208
  createdAt: item.createdAt,
207
209
  updatedAt: item.updatedAt,
210
+ locale: item.locale,
211
+ publishedAt: item.publishedAt,
208
212
  };
209
213
 
210
214
  if (await seoRepo.isEnabled(collection)) {
@@ -237,9 +241,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
237
241
  const items: ContentItem[] = result.items.map((item) => ({
238
242
  id: item.id,
239
243
  type: item.type,
244
+ slug: item.slug,
245
+ status: item.status,
240
246
  data: item.data,
241
247
  createdAt: item.createdAt,
242
248
  updatedAt: item.updatedAt,
249
+ locale: item.locale,
250
+ publishedAt: item.publishedAt,
243
251
  }));
244
252
 
245
253
  if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
@@ -294,9 +302,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
294
302
  const result: ContentItem = {
295
303
  id: item.id,
296
304
  type: item.type,
305
+ slug: item.slug,
306
+ status: item.status,
297
307
  data: item.data,
298
308
  createdAt: item.createdAt,
299
309
  updatedAt: item.updatedAt,
310
+ locale: item.locale,
311
+ publishedAt: item.publishedAt,
300
312
  };
301
313
 
302
314
  if (hasSeo) {
@@ -336,9 +348,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
336
348
  const result: ContentItem = {
337
349
  id: item.id,
338
350
  type: item.type,
351
+ slug: item.slug,
352
+ status: item.status,
339
353
  data: item.data,
340
354
  createdAt: item.createdAt,
341
355
  updatedAt: item.updatedAt,
356
+ locale: item.locale,
357
+ publishedAt: item.publishedAt,
342
358
  };
343
359
 
344
360
  if (hasSeo) {
@@ -439,12 +455,13 @@ export function createMediaAccessWithWrite(
439
455
  );
440
456
  }
441
457
 
442
- const mediaId = ulid();
458
+ // Generate a storage key with a unique prefix
459
+ const keyPrefix = ulid();
443
460
  // Extract extension from basename (ignore path separators)
444
461
  const basename = filename.split("/").pop() ?? filename;
445
462
  const dotIdx = basename.lastIndexOf(".");
446
463
  const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
447
- const storageKey = `${mediaId}${ext}`;
464
+ const storageKey = `${keyPrefix}${ext}`;
448
465
 
449
466
  // Upload to storage first
450
467
  await storage.upload({
@@ -454,8 +471,9 @@ export function createMediaAccessWithWrite(
454
471
  });
455
472
 
456
473
  // Create DB record — clean up storage on failure
474
+ let media;
457
475
  try {
458
- await mediaRepo.create({
476
+ media = await mediaRepo.create({
459
477
  filename: basename,
460
478
  mimeType: contentType,
461
479
  size: bytes.byteLength,
@@ -472,7 +490,7 @@ export function createMediaAccessWithWrite(
472
490
  }
473
491
 
474
492
  return {
475
- mediaId,
493
+ mediaId: media.id,
476
494
  storageKey,
477
495
  url: `/_emdash/api/media/file/${storageKey}`,
478
496
  };
@@ -491,11 +509,17 @@ export function createMediaAccessWithWrite(
491
509
  /** Maximum number of redirects to follow in plugin HTTP access */
492
510
  const MAX_PLUGIN_REDIRECTS = 5;
493
511
 
512
+ /**
513
+ * Check if a hostname matches any pattern in the allowed list.
514
+ * Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
515
+ * "api.example.com" matches exactly.
516
+ */
494
517
  function isHostAllowed(host: string, allowedHosts: string[]): boolean {
495
518
  return allowedHosts.some((pattern) => {
496
519
  if (pattern === "*") return true;
497
520
  if (pattern.startsWith("*.")) {
498
521
  const suffix = pattern.slice(1); // ".example.com"
522
+ // Match subdomains (foo.example.com) and bare domain (example.com)
499
523
  return host.endsWith(suffix) || host === pattern.slice(2);
500
524
  }
501
525
  return host === pattern;
@@ -113,13 +113,38 @@ export class CronExecutor {
113
113
 
114
114
  if (task.is_oneshot) {
115
115
  if (hookFailed) {
116
- // Keep the task for retry reset to idle with a 1-minute backoff
117
- const retryAt = new Date(Date.now() + 60_000).toISOString();
118
- await sql`
116
+ // Retry metadata is namespaced under __emdash to avoid collisions
117
+ // with plugin-controlled data fields.
118
+ const meta =
119
+ parsedData?.__emdash != null && typeof parsedData.__emdash === "object"
120
+ ? (parsedData.__emdash as Record<string, unknown>)
121
+ : undefined;
122
+ const raw = meta?.retryCount;
123
+ const retryCount =
124
+ typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
125
+ const MAX_ONESHOT_RETRIES = 5;
126
+
127
+ if (retryCount >= MAX_ONESHOT_RETRIES) {
128
+ console.error(
129
+ `[cron] One-shot task ${task.plugin_id}:${task.task_name} exceeded ${MAX_ONESHOT_RETRIES} retries, removing`,
130
+ );
131
+ await sql`
132
+ DELETE FROM _emdash_cron_tasks WHERE id = ${task.id}
133
+ `.execute(this.db);
134
+ } else {
135
+ // Retry with exponential backoff: 1m, 2m, 4m, 8m, 16m
136
+ const backoffMs = 60_000 * Math.pow(2, retryCount);
137
+ const retryAt = new Date(Date.now() + backoffMs).toISOString();
138
+ const updatedData = JSON.stringify({
139
+ ...parsedData,
140
+ __emdash: { ...meta, retryCount: retryCount + 1 },
141
+ });
142
+ await sql`
119
143
  UPDATE _emdash_cron_tasks
120
- SET status = 'idle', locked_at = NULL, next_run_at = ${retryAt}
144
+ SET status = 'idle', locked_at = NULL, next_run_at = ${retryAt}, data = ${updatedData}
121
145
  WHERE id = ${task.id}
122
146
  `.execute(this.db);
147
+ }
123
148
  } else {
124
149
  // Success: delete the one-shot task
125
150
  await sql`
@@ -324,7 +324,11 @@ export class HookPipeline {
324
324
  );
325
325
 
326
326
  if (ready.length === 0) {
327
- // Circular dependency or missing dependency - just add by priority
327
+ // Circular dependency or missing dependency - log warning and fall back to priority
328
+ const pluginIds = remaining.map((h) => h.pluginId).join(", ");
329
+ console.warn(
330
+ `[hooks] Hook dependency cycle or missing dependency detected among plugins: ${pluginIds}. Falling back to priority order.`,
331
+ );
328
332
  remaining.sort((a, b) => a.priority - b.priority);
329
333
  sorted.push(...remaining);
330
334
  break;
@@ -344,12 +348,16 @@ export class HookPipeline {
344
348
  * Execute a hook with timeout
345
349
  */
346
350
  private async executeWithTimeout<T>(fn: () => Promise<T>, timeout: number): Promise<T> {
347
- return Promise.race([
348
- fn(),
349
- new Promise<T>((_, reject) =>
350
- setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout),
351
- ),
352
- ]);
351
+ let timer: ReturnType<typeof setTimeout>;
352
+ const timeoutPromise = new Promise<T>(
353
+ (_, reject) =>
354
+ (timer = setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout)),
355
+ );
356
+ try {
357
+ return await Promise.race([fn(), timeoutPromise]);
358
+ } finally {
359
+ clearTimeout(timer!);
360
+ }
353
361
  }
354
362
 
355
363
  // =========================================================================
@@ -561,7 +569,7 @@ export class HookPipeline {
561
569
 
562
570
  for (const hook of hooks) {
563
571
  const { handler } = hook;
564
- const event: ContentDeleteEvent = { id, collection };
572
+ const event: ContentDeleteEvent = { id, collection, permanent: false };
565
573
  const ctx = this.getContext(hook.pluginId);
566
574
  const start = Date.now();
567
575
 
@@ -597,13 +605,17 @@ export class HookPipeline {
597
605
  /**
598
606
  * Run content:afterDelete hooks
599
607
  */
600
- async runContentAfterDelete(id: string, collection: string): Promise<HookResult<void>[]> {
608
+ async runContentAfterDelete(
609
+ id: string,
610
+ collection: string,
611
+ permanent: boolean,
612
+ ): Promise<HookResult<void>[]> {
601
613
  const hooks = this.getTypedHooks("content:afterDelete");
602
614
  const results: HookResult<void>[] = [];
603
615
 
604
616
  for (const hook of hooks) {
605
617
  const { handler } = hook;
606
- const event: ContentDeleteEvent = { id, collection };
618
+ const event: ContentDeleteEvent = { id, collection, permanent };
607
619
  const ctx = this.getContext(hook.pluginId);
608
620
  const start = Date.now();
609
621
 
@@ -121,6 +121,7 @@ export type {
121
121
  ResolvedPluginHooks,
122
122
  ContentHookEvent,
123
123
  ContentDeleteEvent,
124
+ ContentPublishStateChangeEvent,
124
125
  MediaUploadEvent,
125
126
  MediaAfterUploadEvent,
126
127
  LifecycleEvent,
@@ -333,9 +333,13 @@ export class PluginManager {
333
333
  /**
334
334
  * Run content:afterDelete hooks across all active plugins
335
335
  */
336
- async runContentAfterDelete(id: string, collection: string): Promise<HookResult<void>[]> {
336
+ async runContentAfterDelete(
337
+ id: string,
338
+ collection: string,
339
+ permanent: boolean,
340
+ ): Promise<HookResult<void>[]> {
337
341
  this.ensureInitialized();
338
- return this.hookPipeline!.runContentAfterDelete(id, collection);
342
+ return this.hookPipeline!.runContentAfterDelete(id, collection, permanent);
339
343
  }
340
344
 
341
345
  /**
@@ -241,12 +241,42 @@ class MarketplaceClientImpl implements MarketplaceClient {
241
241
  async downloadBundle(id: string, version: string): Promise<PluginBundle> {
242
242
  const bundleUrl = `${this.baseUrl}/api/v1/plugins/${encodeURIComponent(id)}/versions/${encodeURIComponent(version)}/bundle`;
243
243
 
244
+ const marketplaceOrigin = new URL(this.baseUrl).origin;
245
+ const MAX_REDIRECTS = 5;
244
246
  let response: Response;
245
247
  try {
246
- response = await fetch(bundleUrl, {
247
- redirect: "follow",
248
- });
248
+ let currentUrl = bundleUrl;
249
+ response = await fetch(currentUrl, { redirect: "manual" });
250
+
251
+ // Follow redirects manually, validating each target stays on the marketplace host
252
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
253
+ if (response.status < 300 || response.status >= 400) break;
254
+
255
+ const location = response.headers.get("location");
256
+ if (!location) break;
257
+
258
+ const target = new URL(location, currentUrl);
259
+ if (target.origin !== marketplaceOrigin) {
260
+ throw new MarketplaceError(
261
+ `Bundle download redirected to untrusted host: ${target.origin}`,
262
+ response.status,
263
+ "BUNDLE_REDIRECT_UNTRUSTED",
264
+ );
265
+ }
266
+ currentUrl = target.href;
267
+ response = await fetch(currentUrl, { redirect: "manual" });
268
+ }
269
+
270
+ // If still a redirect after MAX_REDIRECTS, fail explicitly
271
+ if (response.status >= 300 && response.status < 400) {
272
+ throw new MarketplaceError(
273
+ `Bundle download exceeded maximum redirects (${MAX_REDIRECTS})`,
274
+ response.status,
275
+ "BUNDLE_TOO_MANY_REDIRECTS",
276
+ );
277
+ }
249
278
  } catch (err) {
279
+ if (err instanceof MarketplaceError) throw err;
250
280
  throw new MarketplaceUnavailableError(err);
251
281
  }
252
282
 
@@ -124,13 +124,13 @@ export class PluginRouteHandler {
124
124
  };
125
125
  }
126
126
 
127
- // Unknown error
128
- const message = error instanceof Error ? error.message : String(error);
127
+ // Unknown error -- log internally, return generic message
128
+ console.error(`[plugin:${this.plugin.id}] Route handler failed:`, error);
129
129
  return {
130
130
  success: false,
131
131
  error: {
132
132
  code: "INTERNAL_ERROR",
133
- message: `Route handler failed: ${message}`,
133
+ message: "An internal error occurred",
134
134
  },
135
135
  status: 500,
136
136
  };
@@ -195,6 +195,9 @@ export interface ContentItemSeoInput {
195
195
  export interface ContentItem {
196
196
  id: string;
197
197
  type: string;
198
+ slug: string | null;
199
+ status: string;
200
+ locale: string | null;
198
201
  data: Record<string, unknown>;
199
202
  /**
200
203
  * SEO metadata, populated when the collection has SEO enabled
@@ -203,6 +206,7 @@ export interface ContentItem {
203
206
  seo?: ContentItemSeo;
204
207
  createdAt: string;
205
208
  updatedAt: string;
209
+ publishedAt: string | null;
206
210
  }
207
211
 
208
212
  /**
@@ -702,6 +706,8 @@ export interface ContentHookEvent {
702
706
  export interface ContentDeleteEvent {
703
707
  id: string;
704
708
  collection: string;
709
+ /** `true` when the content is permanently deleted (not just trashed). */
710
+ permanent: boolean;
705
711
  }
706
712
 
707
713
  /**
@@ -876,6 +882,7 @@ export type PageMetadataLinkRel =
876
882
  | "alternate"
877
883
  | "author"
878
884
  | "license"
885
+ | "nlweb"
879
886
  | "site.standard.document";
880
887
 
881
888
  export type PageMetadataContribution =
package/src/query.ts CHANGED
@@ -637,6 +637,22 @@ function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[]
637
637
  return { regex: new RegExp(`^${regexStr}$`), paramNames };
638
638
  }
639
639
 
640
+ /** Cached compiled URL patterns for resolveEmDashPath */
641
+ interface CachedPattern {
642
+ slug: string;
643
+ regex: RegExp;
644
+ paramNames: string[];
645
+ }
646
+ let cachedUrlPatterns: CachedPattern[] | null = null;
647
+
648
+ /**
649
+ * Invalidate the cached URL patterns used by resolveEmDashPath.
650
+ * Call when collection URL patterns change (schema updates).
651
+ */
652
+ export function invalidateUrlPatternCache(): void {
653
+ cachedUrlPatterns = null;
654
+ }
655
+
640
656
  /**
641
657
  * Resolve a URL path to a content entry by matching against collection URL patterns.
642
658
  *
@@ -659,32 +675,39 @@ function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[]
659
675
  export async function resolveEmDashPath<T = Record<string, unknown>>(
660
676
  path: string,
661
677
  ): Promise<ResolvePathResult<T> | null> {
662
- const { getDb } = await import("./loader.js");
663
- const { SchemaRegistry } = await import("./schema/registry.js");
664
- const db = await getDb();
665
- const registry = new SchemaRegistry(db);
666
- const collections = await registry.listCollections();
667
-
668
- for (const collection of collections) {
669
- if (!collection.urlPattern) continue;
678
+ // Build and cache compiled patterns on first call
679
+ if (!cachedUrlPatterns) {
680
+ const { getDb } = await import("./loader.js");
681
+ const { SchemaRegistry } = await import("./schema/registry.js");
682
+ const db = await getDb();
683
+ const registry = new SchemaRegistry(db);
684
+ const collections = await registry.listCollections();
685
+
686
+ cachedUrlPatterns = [];
687
+ for (const collection of collections) {
688
+ if (!collection.urlPattern) continue;
689
+ const { regex, paramNames } = patternToRegex(collection.urlPattern);
690
+ cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames });
691
+ }
692
+ }
670
693
 
671
- const { regex, paramNames } = patternToRegex(collection.urlPattern);
672
- const match = path.match(regex);
694
+ for (const pattern of cachedUrlPatterns) {
695
+ const match = path.match(pattern.regex);
673
696
  if (!match) continue;
674
697
 
675
698
  // Extract params
676
699
  const params: Record<string, string> = {};
677
- for (let i = 0; i < paramNames.length; i++) {
678
- params[paramNames[i]] = match[i + 1];
700
+ for (let i = 0; i < pattern.paramNames.length; i++) {
701
+ params[pattern.paramNames[i]] = match[i + 1];
679
702
  }
680
703
 
681
704
  // Look up entry by slug (most common pattern)
682
705
  const slug = params.slug;
683
706
  if (!slug) continue;
684
707
 
685
- const { entry } = await getEmDashEntry<string, T>(collection.slug, slug);
708
+ const { entry } = await getEmDashEntry<string, T>(pattern.slug, slug);
686
709
  if (entry) {
687
- return { entry, collection: collection.slug, params };
710
+ return { entry, collection: pattern.slug, params };
688
711
  }
689
712
  }
690
713
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Redirect pattern cache.
3
+ *
4
+ * Module-level cache for compiled redirect pattern rules. The middleware
5
+ * populates this on first request; route handlers invalidate it on writes.
6
+ *
7
+ * This module deliberately has NO Astro imports so it can be safely imported
8
+ * from handlers, seed, CLI, and tests without dragging in `astro:middleware`.
9
+ */
10
+
11
+ import type { Redirect } from "../database/repositories/redirect.js";
12
+ import type { CompiledPattern } from "./patterns.js";
13
+ import { compilePattern, interpolateDestination, matchPattern } from "./patterns.js";
14
+
15
+ export interface CachedRedirectRule {
16
+ redirect: Redirect;
17
+ compiled: CompiledPattern;
18
+ }
19
+
20
+ /**
21
+ * Cached pattern rules with compiled regexes.
22
+ * null = not yet populated, array = cached.
23
+ */
24
+ let cachedPatternRules: CachedRedirectRule[] | null = null;
25
+
26
+ /**
27
+ * Invalidate the cached redirect pattern rules.
28
+ * Call when redirects are created, updated, or deleted.
29
+ */
30
+ export function invalidateRedirectCache(): void {
31
+ cachedPatternRules = null;
32
+ }
33
+
34
+ /**
35
+ * Get the cached compiled pattern rules, or null if the cache is cold.
36
+ */
37
+ export function getCachedPatternRules(): CachedRedirectRule[] | null {
38
+ return cachedPatternRules;
39
+ }
40
+
41
+ /**
42
+ * Populate the pattern rules cache from a list of enabled pattern redirects.
43
+ */
44
+ export function setCachedPatternRules(redirects: Redirect[]): CachedRedirectRule[] {
45
+ cachedPatternRules = redirects.map((r) => ({
46
+ redirect: r,
47
+ compiled: compilePattern(r.source),
48
+ }));
49
+ return cachedPatternRules;
50
+ }
51
+
52
+ /**
53
+ * Match a path against the cached pattern rules.
54
+ * Returns the resolved destination and matching redirect, or null.
55
+ */
56
+ export function matchCachedPatterns(
57
+ rules: CachedRedirectRule[],
58
+ pathname: string,
59
+ ): { redirect: Redirect; destination: string } | null {
60
+ for (const { redirect, compiled } of rules) {
61
+ const params = matchPattern(compiled, pathname);
62
+ if (params) {
63
+ const dest = interpolateDestination(redirect.destination, params);
64
+ return { redirect, destination: dest };
65
+ }
66
+ }
67
+ return null;
68
+ }