emdash 0.1.1 → 0.3.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 (202) hide show
  1. package/dist/{apply-kC39ev1Z.mjs → apply-Bqoekfbe.mjs} +57 -10
  2. package/dist/apply-Bqoekfbe.mjs.map +1 -0
  3. package/dist/astro/index.d.mts +23 -9
  4. package/dist/astro/index.d.mts.map +1 -1
  5. package/dist/astro/index.mjs +90 -25
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/middleware/auth.d.mts +3 -3
  8. package/dist/astro/middleware/auth.d.mts.map +1 -1
  9. package/dist/astro/middleware/auth.mjs +126 -55
  10. package/dist/astro/middleware/auth.mjs.map +1 -1
  11. package/dist/astro/middleware/redirect.mjs +2 -2
  12. package/dist/astro/middleware/request-context.mjs +1 -1
  13. package/dist/astro/middleware.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +80 -41
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/types.d.mts +27 -6
  17. package/dist/astro/types.d.mts.map +1 -1
  18. package/dist/{byline-CL847F26.mjs → byline-BGj9p9Ht.mjs} +53 -31
  19. package/dist/byline-BGj9p9Ht.mjs.map +1 -0
  20. package/dist/{bylines-C2a-2TGt.mjs → bylines-BihaoIDY.mjs} +12 -10
  21. package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BihaoIDY.mjs.map} +1 -1
  22. package/dist/cli/index.mjs +17 -14
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
  25. package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
  26. package/dist/{content-D6C2WsZC.mjs → content-BsBoyj8G.mjs} +35 -5
  27. package/dist/content-BsBoyj8G.mjs.map +1 -0
  28. package/dist/db/index.mjs +2 -2
  29. package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
  30. package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
  31. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  32. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  33. package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
  34. package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
  35. package/dist/{index-CLBc4gw-.d.mts → index-Cff7AimE.d.mts} +77 -15
  36. package/dist/index-Cff7AimE.d.mts.map +1 -0
  37. package/dist/index.d.mts +6 -6
  38. package/dist/index.mjs +19 -19
  39. package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
  40. package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
  41. package/dist/{loader-fz8Q_3EO.mjs → loader-BmYdf3Dr.mjs} +4 -2
  42. package/dist/loader-BmYdf3Dr.mjs.map +1 -0
  43. package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
  44. package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
  45. package/dist/media/local-runtime.d.mts +4 -4
  46. package/dist/page/index.d.mts +10 -1
  47. package/dist/page/index.d.mts.map +1 -1
  48. package/dist/page/index.mjs +8 -4
  49. package/dist/page/index.mjs.map +1 -1
  50. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  51. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  52. package/dist/{query-BVYN0PJ6.mjs → query-sesiOndV.mjs} +20 -8
  53. package/dist/{query-BVYN0PJ6.mjs.map → query-sesiOndV.mjs.map} +1 -1
  54. package/dist/{redirect-DIfIni3r.mjs → redirect-DUAk-Yl_.mjs} +9 -2
  55. package/dist/redirect-DUAk-Yl_.mjs.map +1 -0
  56. package/dist/{registry-BNYQKX_d.mjs → registry-DU18yVo0.mjs} +14 -4
  57. package/dist/registry-DU18yVo0.mjs.map +1 -0
  58. package/dist/{runner-BraqvGYk.mjs → runner-Biufrii2.mjs} +157 -132
  59. package/dist/runner-Biufrii2.mjs.map +1 -0
  60. package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
  61. package/dist/runtime.d.mts +3 -3
  62. package/dist/runtime.mjs +2 -2
  63. package/dist/{search-C1gg67nN.mjs → search-BXB-jfu2.mjs} +241 -109
  64. package/dist/search-BXB-jfu2.mjs.map +1 -0
  65. package/dist/seed/index.d.mts +1 -1
  66. package/dist/seed/index.mjs +10 -10
  67. package/dist/seo/index.d.mts +1 -1
  68. package/dist/storage/local.d.mts +1 -1
  69. package/dist/storage/local.mjs +1 -1
  70. package/dist/storage/s3.d.mts +11 -3
  71. package/dist/storage/s3.d.mts.map +1 -1
  72. package/dist/storage/s3.mjs +76 -15
  73. package/dist/storage/s3.mjs.map +1 -1
  74. package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
  75. package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
  76. package/dist/{types-BRuPJGdV.d.mts → types-BbsYgi_R.d.mts} +3 -1
  77. package/dist/types-BbsYgi_R.d.mts.map +1 -0
  78. package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
  79. package/dist/types-Bec-r_3_.mjs.map +1 -0
  80. package/dist/{types-DaNLHo_T.d.mts → types-C1-PVaS_.d.mts} +14 -6
  81. package/dist/types-C1-PVaS_.d.mts.map +1 -0
  82. package/dist/types-CMMN0pNg.mjs.map +1 -1
  83. package/dist/{types-BQo5JS0J.d.mts → types-CaKte3hR.d.mts} +78 -6
  84. package/dist/types-CaKte3hR.d.mts.map +1 -0
  85. package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
  86. package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
  87. package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
  88. package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
  89. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  90. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  91. package/dist/{validate-HtxZeaBi.d.mts → validate-bfg9OR6N.d.mts} +2 -2
  92. package/dist/{validate-HtxZeaBi.d.mts.map → validate-bfg9OR6N.d.mts.map} +1 -1
  93. package/dist/version-REAapfsU.mjs +7 -0
  94. package/dist/version-REAapfsU.mjs.map +1 -0
  95. package/package.json +6 -6
  96. package/src/api/csrf.ts +13 -2
  97. package/src/api/handlers/content.ts +7 -0
  98. package/src/api/handlers/dashboard.ts +4 -8
  99. package/src/api/handlers/device-flow.ts +55 -37
  100. package/src/api/handlers/index.ts +6 -1
  101. package/src/api/handlers/redirects.ts +95 -3
  102. package/src/api/handlers/seo.ts +48 -21
  103. package/src/api/public-url.ts +84 -0
  104. package/src/api/schemas/content.ts +2 -2
  105. package/src/api/schemas/menus.ts +12 -2
  106. package/src/api/schemas/redirects.ts +1 -0
  107. package/src/astro/integration/index.ts +30 -7
  108. package/src/astro/integration/routes.ts +13 -2
  109. package/src/astro/integration/runtime.ts +7 -5
  110. package/src/astro/integration/vite-config.ts +55 -9
  111. package/src/astro/middleware/auth.ts +60 -56
  112. package/src/astro/middleware/csp.ts +25 -0
  113. package/src/astro/middleware.ts +31 -3
  114. package/src/astro/routes/PluginRegistry.tsx +8 -2
  115. package/src/astro/routes/admin.astro +7 -2
  116. package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
  117. package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
  118. package/src/astro/routes/api/auth/invite/complete.ts +3 -2
  119. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
  120. package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
  121. package/src/astro/routes/api/auth/passkey/options.ts +3 -2
  122. package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
  123. package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
  124. package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
  125. package/src/astro/routes/api/auth/signup/complete.ts +3 -2
  126. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  127. package/src/astro/routes/api/content/[collection]/index.ts +31 -3
  128. package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
  129. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  130. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
  131. package/src/astro/routes/api/manifest.ts +4 -1
  132. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
  133. package/src/astro/routes/api/oauth/authorize.ts +12 -7
  134. package/src/astro/routes/api/oauth/device/code.ts +5 -1
  135. package/src/astro/routes/api/setup/admin-verify.ts +3 -2
  136. package/src/astro/routes/api/setup/admin.ts +3 -2
  137. package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
  138. package/src/astro/routes/api/setup/index.ts +3 -2
  139. package/src/astro/routes/api/snapshot.ts +2 -1
  140. package/src/astro/routes/api/themes/preview.ts +2 -1
  141. package/src/astro/routes/api/well-known/auth.ts +1 -0
  142. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
  143. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
  144. package/src/astro/routes/robots.txt.ts +5 -1
  145. package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
  146. package/src/astro/routes/sitemap.xml.ts +18 -23
  147. package/src/astro/storage/adapters.ts +19 -5
  148. package/src/astro/storage/types.ts +12 -4
  149. package/src/astro/types.ts +28 -1
  150. package/src/auth/passkey-config.ts +6 -10
  151. package/src/bylines/index.ts +13 -10
  152. package/src/cli/commands/login.ts +5 -2
  153. package/src/components/InlinePortableTextEditor.tsx +5 -3
  154. package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
  155. package/src/database/dialect-helpers.ts +3 -0
  156. package/src/database/migrations/034_published_at_index.ts +29 -0
  157. package/src/database/migrations/runner.ts +2 -0
  158. package/src/database/repositories/byline.ts +48 -42
  159. package/src/database/repositories/content.ts +28 -1
  160. package/src/database/repositories/options.ts +9 -3
  161. package/src/database/repositories/redirect.ts +13 -0
  162. package/src/database/repositories/seo.ts +34 -17
  163. package/src/database/repositories/types.ts +2 -0
  164. package/src/database/validate.ts +10 -10
  165. package/src/emdash-runtime.ts +66 -19
  166. package/src/import/index.ts +1 -1
  167. package/src/import/sources/wxr.ts +45 -2
  168. package/src/index.ts +10 -1
  169. package/src/loader.ts +2 -0
  170. package/src/mcp/server.ts +85 -5
  171. package/src/menus/index.ts +6 -1
  172. package/src/page/context.ts +13 -1
  173. package/src/page/jsonld.ts +10 -6
  174. package/src/page/seo-contributions.ts +1 -1
  175. package/src/plugins/context.ts +145 -35
  176. package/src/plugins/manager.ts +12 -0
  177. package/src/plugins/types.ts +80 -4
  178. package/src/query.ts +18 -0
  179. package/src/redirects/loops.ts +318 -0
  180. package/src/schema/registry.ts +8 -0
  181. package/src/search/fts-manager.ts +4 -0
  182. package/src/settings/index.ts +64 -0
  183. package/src/storage/s3.ts +94 -25
  184. package/src/storage/types.ts +13 -5
  185. package/src/utils/chunks.ts +17 -0
  186. package/src/utils/slugify.ts +11 -0
  187. package/src/version.ts +12 -0
  188. package/dist/apply-kC39ev1Z.mjs.map +0 -1
  189. package/dist/byline-CL847F26.mjs.map +0 -1
  190. package/dist/content-D6C2WsZC.mjs.map +0 -1
  191. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  192. package/dist/index-CLBc4gw-.d.mts.map +0 -1
  193. package/dist/loader-fz8Q_3EO.mjs.map +0 -1
  194. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  195. package/dist/registry-BNYQKX_d.mjs.map +0 -1
  196. package/dist/runner-BraqvGYk.mjs.map +0 -1
  197. package/dist/search-C1gg67nN.mjs.map +0 -1
  198. package/dist/types-BQo5JS0J.d.mts.map +0 -1
  199. package/dist/types-BRuPJGdV.d.mts.map +0 -1
  200. package/dist/types-CUBbjgmP.mjs.map +0 -1
  201. package/dist/types-DaNLHo_T.d.mts.map +0 -1
  202. /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { sql, type Kysely, type Selectable } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
4
5
  import { listTablesLike } from "../dialect-helpers.js";
5
6
  import type { BylineTable, Database } from "../types.js";
6
7
  import { validateIdentifier } from "../validate.js";
@@ -259,41 +260,44 @@ export class BylineRepository {
259
260
  const result = new Map<string, ContentBylineCredit[]>();
260
261
  if (contentIds.length === 0) return result;
261
262
 
262
- const rows = await this.db
263
- .selectFrom("_emdash_content_bylines as cb")
264
- .innerJoin("_emdash_bylines as b", "b.id", "cb.byline_id")
265
- .select([
266
- "cb.content_id as content_id",
267
- "cb.sort_order as sort_order",
268
- "cb.role_label as role_label",
269
- "b.id as id",
270
- "b.slug as slug",
271
- "b.display_name as display_name",
272
- "b.bio as bio",
273
- "b.avatar_media_id as avatar_media_id",
274
- "b.website_url as website_url",
275
- "b.user_id as user_id",
276
- "b.is_guest as is_guest",
277
- "b.created_at as created_at",
278
- "b.updated_at as updated_at",
279
- ])
280
- .where("cb.collection_slug", "=", collectionSlug)
281
- .where("cb.content_id", "in", contentIds)
282
- .orderBy("cb.sort_order", "asc")
283
- .execute();
263
+ const uniqueContentIds = [...new Set(contentIds)];
264
+ for (const chunk of chunks(uniqueContentIds, SQL_BATCH_SIZE)) {
265
+ const rows = await this.db
266
+ .selectFrom("_emdash_content_bylines as cb")
267
+ .innerJoin("_emdash_bylines as b", "b.id", "cb.byline_id")
268
+ .select([
269
+ "cb.content_id as content_id",
270
+ "cb.sort_order as sort_order",
271
+ "cb.role_label as role_label",
272
+ "b.id as id",
273
+ "b.slug as slug",
274
+ "b.display_name as display_name",
275
+ "b.bio as bio",
276
+ "b.avatar_media_id as avatar_media_id",
277
+ "b.website_url as website_url",
278
+ "b.user_id as user_id",
279
+ "b.is_guest as is_guest",
280
+ "b.created_at as created_at",
281
+ "b.updated_at as updated_at",
282
+ ])
283
+ .where("cb.collection_slug", "=", collectionSlug)
284
+ .where("cb.content_id", "in", chunk)
285
+ .orderBy("cb.sort_order", "asc")
286
+ .execute();
284
287
 
285
- for (const row of rows) {
286
- const contentId = row.content_id;
287
- const credit: ContentBylineCredit = {
288
- byline: rowToByline(row),
289
- sortOrder: row.sort_order,
290
- roleLabel: row.role_label,
291
- };
292
- const existing = result.get(contentId);
293
- if (existing) {
294
- existing.push(credit);
295
- } else {
296
- result.set(contentId, [credit]);
288
+ for (const row of rows) {
289
+ const contentId = row.content_id;
290
+ const credit: ContentBylineCredit = {
291
+ byline: rowToByline(row),
292
+ sortOrder: row.sort_order,
293
+ roleLabel: row.role_label,
294
+ };
295
+ const existing = result.get(contentId);
296
+ if (existing) {
297
+ existing.push(credit);
298
+ } else {
299
+ result.set(contentId, [credit]);
300
+ }
297
301
  }
298
302
  }
299
303
 
@@ -308,15 +312,17 @@ export class BylineRepository {
308
312
  const result = new Map<string, BylineSummary>();
309
313
  if (userIds.length === 0) return result;
310
314
 
311
- const rows = await this.db
312
- .selectFrom("_emdash_bylines")
313
- .selectAll()
314
- .where("user_id", "in", userIds)
315
- .execute();
315
+ for (const chunk of chunks(userIds, SQL_BATCH_SIZE)) {
316
+ const rows = await this.db
317
+ .selectFrom("_emdash_bylines")
318
+ .selectAll()
319
+ .where("user_id", "in", chunk)
320
+ .execute();
316
321
 
317
- for (const row of rows) {
318
- if (row.user_id) {
319
- result.set(row.user_id, rowToByline(row));
322
+ for (const row of rows) {
323
+ if (row.user_id) {
324
+ result.set(row.user_id, rowToByline(row));
325
+ }
320
326
  }
321
327
  }
322
328
  return result;
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
3
3
 
4
4
  import { slugify } from "../../utils/slugify.js";
5
5
  import type { Database } from "../types.js";
6
+ import { validateIdentifier } from "../validate.js";
6
7
  import { RevisionRepository } from "./revision.js";
7
8
  import type {
8
9
  CreateContentInput,
@@ -41,6 +42,7 @@ const SYSTEM_COLUMNS = new Set([
41
42
  * Get the table name for a collection type
42
43
  */
43
44
  function getTableName(type: string): string {
45
+ validateIdentifier(type, "collection type");
44
46
  return `ec_${type}`;
45
47
  }
46
48
 
@@ -116,6 +118,7 @@ export class ContentRepository {
116
118
  locale,
117
119
  translationOf,
118
120
  publishedAt,
121
+ createdAt,
119
122
  } = input;
120
123
 
121
124
  // Validate required fields
@@ -155,7 +158,7 @@ export class ContentRepository {
155
158
  status,
156
159
  authorId || null,
157
160
  primaryBylineId ?? null,
158
- now,
161
+ createdAt || now,
159
162
  now,
160
163
  publishedAt || null,
161
164
  1,
@@ -167,6 +170,7 @@ export class ContentRepository {
167
170
  if (data && typeof data === "object") {
168
171
  for (const [key, value] of Object.entries(data)) {
169
172
  if (!SYSTEM_COLUMNS.has(key)) {
173
+ validateIdentifier(key, "content field name");
170
174
  columns.push(key);
171
175
  values.push(serializeValue(value));
172
176
  }
@@ -577,6 +581,7 @@ export class ContentRepository {
577
581
  if (input.data !== undefined && typeof input.data === "object") {
578
582
  for (const [key, value] of Object.entries(input.data)) {
579
583
  if (!SYSTEM_COLUMNS.has(key)) {
584
+ validateIdentifier(key, "content field name");
580
585
  updates[key] = serializeValue(value);
581
586
  }
582
587
  }
@@ -767,6 +772,27 @@ export class ContentRepository {
767
772
  return Number(result?.count || 0);
768
773
  }
769
774
 
775
+ // get overall statistics (total, published, draft) for a content type in a single query
776
+ async getStats(type: string): Promise<{ total: number; published: number; draft: number }> {
777
+ const tableName = getTableName(type);
778
+
779
+ const result = await this.db
780
+ .selectFrom(tableName as keyof Database)
781
+ .select((eb) => [
782
+ eb.fn.count("id").as("total"),
783
+ eb.fn.sum(eb.case().when("status", "=", "published").then(1).else(0).end()).as("published"),
784
+ eb.fn.sum(eb.case().when("status", "=", "draft").then(1).else(0).end()).as("draft"),
785
+ ])
786
+ .where("deleted_at" as never, "is", null)
787
+ .executeTakeFirst();
788
+
789
+ return {
790
+ total: Number(result?.total || 0),
791
+ published: Number(result?.published || 0),
792
+ draft: Number(result?.draft || 0),
793
+ };
794
+ }
795
+
770
796
  /**
771
797
  * Schedule content for future publishing
772
798
  *
@@ -1057,6 +1083,7 @@ export class ContentRepository {
1057
1083
  for (const [key, value] of Object.entries(data)) {
1058
1084
  if (SYSTEM_COLUMNS.has(key)) continue;
1059
1085
  if (key.startsWith("_")) continue; // revision metadata
1086
+ validateIdentifier(key, "content field name");
1060
1087
  updates[key] = serializeValue(value);
1061
1088
  }
1062
1089
 
@@ -1,7 +1,11 @@
1
- import type { Kysely } from "kysely";
1
+ import { sql, type Kysely, type SqlBool } from "kysely";
2
2
 
3
3
  import type { Database, OptionTable } from "../types.js";
4
4
 
5
+ function escapeLike(value: string): string {
6
+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
7
+ }
8
+
5
9
  /**
6
10
  * Options repository for key-value settings storage
7
11
  *
@@ -122,10 +126,11 @@ export class OptionsRepository {
122
126
  * Get all options matching a prefix
123
127
  */
124
128
  async getByPrefix<T = unknown>(prefix: string): Promise<Map<string, T>> {
129
+ const pattern = `${escapeLike(prefix)}%`;
125
130
  const rows = await this.db
126
131
  .selectFrom("options")
127
132
  .select(["name", "value"])
128
- .where("name", "like", `${prefix}%`)
133
+ .where(sql<SqlBool>`name LIKE ${pattern} ESCAPE '\\'`)
129
134
  .execute();
130
135
 
131
136
  const result = new Map<string, T>();
@@ -140,9 +145,10 @@ export class OptionsRepository {
140
145
  * Delete all options matching a prefix
141
146
  */
142
147
  async deleteByPrefix(prefix: string): Promise<number> {
148
+ const pattern = `${escapeLike(prefix)}%`;
143
149
  const result = await this.db
144
150
  .deleteFrom("options")
145
- .where("name", "like", `${prefix}%`)
151
+ .where(sql<SqlBool>`name LIKE ${pattern} ESCAPE '\\'`)
146
152
  .executeTakeFirst();
147
153
 
148
154
  return Number(result.numDeletedRows ?? 0);
@@ -237,6 +237,19 @@ export class RedirectRepository {
237
237
  return BigInt(result.numDeletedRows) > 0n;
238
238
  }
239
239
 
240
+ /**
241
+ * Fetch all enabled redirects (for loop detection graph building).
242
+ * Not paginated — returns the full set.
243
+ */
244
+ async findAllEnabled(): Promise<Redirect[]> {
245
+ const rows = await this.db
246
+ .selectFrom("_emdash_redirects")
247
+ .selectAll()
248
+ .where("enabled", "=", 1)
249
+ .execute();
250
+ return rows.map(rowToRedirect);
251
+ }
252
+
240
253
  // --- Matching -----------------------------------------------------------
241
254
 
242
255
  async findExactMatch(path: string): Promise<Redirect | null> {
@@ -1,5 +1,6 @@
1
1
  import { sql, type Kysely } from "kysely";
2
2
 
3
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
3
4
  import type { Database } from "../types.js";
4
5
  import type { ContentSeo, ContentSeoInput } from "./types.js";
5
6
 
@@ -36,6 +37,19 @@ function hasAnyField(input: ContentSeoInput): boolean {
36
37
  export class SeoRepository {
37
38
  constructor(private db: Kysely<Database>) {}
38
39
 
40
+ /**
41
+ * Check whether a collection has SEO enabled (`has_seo = 1`).
42
+ * Returns `false` if the collection does not exist.
43
+ */
44
+ async isEnabled(collection: string): Promise<boolean> {
45
+ const row = await this.db
46
+ .selectFrom("_emdash_collections")
47
+ .select("has_seo")
48
+ .where("slug", "=", collection)
49
+ .executeTakeFirst();
50
+ return row?.has_seo === 1;
51
+ }
52
+
39
53
  /**
40
54
  * Get SEO data for a content item. Returns null defaults if no row exists.
41
55
  */
@@ -61,37 +75,40 @@ export class SeoRepository {
61
75
  }
62
76
 
63
77
  /**
64
- * Get SEO data for multiple content items in a single query.
78
+ * Get SEO data for multiple content items.
65
79
  * Returns a Map keyed by content_id. Items without SEO rows get defaults.
80
+ *
81
+ * Chunks the `content_id IN (…)` clause so the total bound-parameter count
82
+ * per statement (ids + the `collection = ?` filter) stays within Cloudflare
83
+ * D1's 100-variable limit regardless of how many content items are passed.
66
84
  */
67
85
  async getMany(collection: string, contentIds: string[]): Promise<Map<string, ContentSeo>> {
68
86
  const result = new Map<string, ContentSeo>();
69
87
 
70
88
  if (contentIds.length === 0) return result;
71
89
 
72
- // Batch query single SELECT with IN clause
73
- const rows = await this.db
74
- .selectFrom("_emdash_seo")
75
- .selectAll()
76
- .where("collection", "=", collection)
77
- .where("content_id", "in", contentIds)
78
- .execute();
79
-
80
- // Index fetched rows by content_id
81
- const rowMap = new Map(rows.map((r) => [r.content_id, r]));
82
-
90
+ // Pre-fill with defaults so every input id has an entry even if no row exists.
83
91
  for (const id of contentIds) {
84
- const row = rowMap.get(id);
85
- if (row) {
86
- result.set(id, {
92
+ result.set(id, { ...SEO_DEFAULTS });
93
+ }
94
+
95
+ const uniqueContentIds = [...new Set(contentIds)];
96
+ for (const chunk of chunks(uniqueContentIds, SQL_BATCH_SIZE)) {
97
+ const rows = await this.db
98
+ .selectFrom("_emdash_seo")
99
+ .selectAll()
100
+ .where("collection", "=", collection)
101
+ .where("content_id", "in", chunk)
102
+ .execute();
103
+
104
+ for (const row of rows) {
105
+ result.set(row.content_id, {
87
106
  title: row.seo_title ?? null,
88
107
  description: row.seo_description ?? null,
89
108
  image: row.seo_image ?? null,
90
109
  canonical: row.seo_canonical ?? null,
91
110
  noIndex: row.seo_no_index === 1,
92
111
  });
93
- } else {
94
- result.set(id, { ...SEO_DEFAULTS });
95
112
  }
96
113
  }
97
114
 
@@ -10,6 +10,8 @@ export interface CreateContentInput {
10
10
  locale?: string;
11
11
  translationOf?: string;
12
12
  publishedAt?: string | null;
13
+ /** Override created_at (ISO 8601). Used by importers to preserve original dates. */
14
+ createdAt?: string | null;
13
15
  }
14
16
 
15
17
  export interface UpdateContentInput {
@@ -79,16 +79,6 @@ export function validateIdentifier(value: string, label = "identifier"): void {
79
79
  }
80
80
  }
81
81
 
82
- /**
83
- * Validate that a string is a safe SQL identifier, allowing hyphens.
84
- *
85
- * Like `validateIdentifier` but also permits hyphens, which appear in
86
- * plugin IDs (e.g., "my-plugin"). Matches `/^[a-z][a-z0-9_-]*$/`.
87
- *
88
- * @param value - The string to validate
89
- * @param label - Human-readable label for error messages
90
- * @throws {IdentifierError} If the value is not valid
91
- */
92
82
  /**
93
83
  * Validate that a string is a safe JSON field name for use in json_extract paths.
94
84
  *
@@ -120,6 +110,16 @@ export function validateJsonFieldName(value: string, label = "JSON field name"):
120
110
  }
121
111
  }
122
112
 
113
+ /**
114
+ * Validate that a string is a safe SQL identifier, allowing hyphens.
115
+ *
116
+ * Like `validateIdentifier` but also permits hyphens, which appear in
117
+ * plugin IDs (e.g., "my-plugin"). Matches `/^[a-z][a-z0-9_-]*$/`.
118
+ *
119
+ * @param value - The string to validate
120
+ * @param label - Human-readable label for error messages
121
+ * @throws {IdentifierError} If the value is not valid
122
+ */
123
123
  export function validatePluginIdentifier(value: string, label = "plugin identifier"): void {
124
124
  if (!value || typeof value !== "string") {
125
125
  throw new IdentifierError(`${label} must be a non-empty string`, String(value));
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Element } from "@emdash-cms/blocks";
11
11
  import { Kysely, sql, type Dialect } from "kysely";
12
+ import virtualConfig from "virtual:emdash/config";
12
13
 
13
14
  import { validateRev } from "./api/rev.js";
14
15
  import type {
@@ -22,6 +23,7 @@ import { isSqlite } from "./database/dialect-helpers.js";
22
23
  import { runMigrations } from "./database/migrations/runner.js";
23
24
  import { RevisionRepository } from "./database/repositories/revision.js";
24
25
  import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
26
+ import { validateIdentifier } from "./database/validate.js";
25
27
  import { normalizeMediaValue } from "./media/normalize.js";
26
28
  import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js";
27
29
  import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js";
@@ -37,6 +39,7 @@ import type {
37
39
  } from "./plugins/types.js";
38
40
  import type { FieldType } from "./schema/types.js";
39
41
  import { hashString } from "./utils/hash.js";
42
+ import { COMMIT, VERSION } from "./version.js";
40
43
 
41
44
  const LEADING_SLASH_PATTERN = /^\//;
42
45
 
@@ -400,11 +403,14 @@ export class EmDashRuntime {
400
403
  this.pluginStates.set(pluginId, status);
401
404
  if (status === "active") {
402
405
  this.enabledPlugins.add(pluginId);
406
+ await this.rebuildHookPipeline();
407
+ await this._hooks.runPluginActivate(pluginId);
403
408
  } else {
409
+ // Fire deactivate on the current pipeline while the plugin is still in it
410
+ await this._hooks.runPluginDeactivate(pluginId);
404
411
  this.enabledPlugins.delete(pluginId);
412
+ await this.rebuildHookPipeline();
405
413
  }
406
-
407
- await this.rebuildHookPipeline();
408
414
  }
409
415
 
410
416
  /**
@@ -1153,7 +1159,10 @@ export class EmDashRuntime {
1153
1159
  label?: string;
1154
1160
  required?: boolean;
1155
1161
  widget?: string;
1156
- options?: Array<{ value: string; label: string }>;
1162
+ // Two shapes: legacy enum-style `[{ value, label }]` for select widgets,
1163
+ // or arbitrary `Record<string, unknown>` for plugin field widgets that
1164
+ // need per-field config (e.g. a checkbox grid receiving its column defs).
1165
+ options?: Array<{ value: string; label: string }> | Record<string, unknown>;
1157
1166
  }
1158
1167
  > = {};
1159
1168
 
@@ -1165,7 +1174,14 @@ export class EmDashRuntime {
1165
1174
  required: field.required,
1166
1175
  };
1167
1176
  if (field.widget) entry.widget = field.widget;
1168
- // Include select/multiSelect options from validation
1177
+ // Plugin field widgets read their per-field config from `field.options`,
1178
+ // which the seed schema types as `Record<string, unknown>`. Pass it
1179
+ // through to the manifest so plugin widgets in the admin SPA receive it.
1180
+ if (field.options) {
1181
+ entry.options = field.options;
1182
+ }
1183
+ // Legacy: select/multiSelect enum options live on `field.validation.options`.
1184
+ // Wins over `field.options` to preserve existing behavior for enum widgets.
1169
1185
  if (field.validation?.options) {
1170
1186
  entry.options = field.validation.options.map((v) => ({
1171
1187
  value: v,
@@ -1243,8 +1259,8 @@ export class EmDashRuntime {
1243
1259
  version: plugin.version,
1244
1260
  enabled,
1245
1261
  adminMode,
1246
- adminPages: plugin.admin?.pages,
1247
- dashboardWidgets: plugin.admin?.widgets,
1262
+ adminPages: plugin.admin?.pages ?? [],
1263
+ dashboardWidgets: plugin.admin?.widgets ?? [],
1248
1264
  portableTextBlocks: plugin.admin?.portableTextBlocks,
1249
1265
  fieldWidgets: plugin.admin?.fieldWidgets,
1250
1266
  };
@@ -1266,8 +1282,8 @@ export class EmDashRuntime {
1266
1282
  enabled,
1267
1283
  sandboxed: true,
1268
1284
  adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1269
- adminPages: entry.adminPages,
1270
- dashboardWidgets: entry.adminWidgets,
1285
+ adminPages: entry.adminPages ?? [],
1286
+ dashboardWidgets: entry.adminWidgets ?? [],
1271
1287
  };
1272
1288
  }
1273
1289
 
@@ -1289,34 +1305,61 @@ export class EmDashRuntime {
1289
1305
  enabled,
1290
1306
  sandboxed: true,
1291
1307
  adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1292
- adminPages: pages,
1293
- dashboardWidgets: widgets,
1308
+ adminPages: pages ?? [],
1309
+ dashboardWidgets: widgets ?? [],
1294
1310
  };
1295
1311
  }
1296
1312
 
1297
- // Generate hash from both collections and plugins so cache invalidates
1298
- // when plugins are enabled/disabled or their config changes
1313
+ // Build taxonomies from database
1314
+ let manifestTaxonomies: Array<{
1315
+ name: string;
1316
+ label: string;
1317
+ labelSingular?: string;
1318
+ hierarchical: boolean;
1319
+ collections: string[];
1320
+ }> = [];
1321
+ try {
1322
+ const rows = await this.db
1323
+ .selectFrom("_emdash_taxonomy_defs")
1324
+ .selectAll()
1325
+ .orderBy("name")
1326
+ .execute();
1327
+ manifestTaxonomies = rows.map((row) => ({
1328
+ name: row.name,
1329
+ label: row.label,
1330
+ labelSingular: row.label_singular ?? undefined,
1331
+ hierarchical: row.hierarchical === 1,
1332
+ collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
1333
+ }));
1334
+ } catch (error) {
1335
+ console.debug("EmDash: Could not load taxonomy definitions:", error);
1336
+ }
1337
+
1338
+ // Build manifest hash
1299
1339
  const manifestHash = await hashString(
1300
- JSON.stringify(manifestCollections) + JSON.stringify(manifestPlugins),
1340
+ JSON.stringify(manifestCollections) +
1341
+ JSON.stringify(manifestPlugins) +
1342
+ JSON.stringify(manifestTaxonomies),
1301
1343
  );
1302
1344
 
1303
1345
  // Determine auth mode
1304
1346
  const authMode = getAuthMode(this.config);
1305
1347
  const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey";
1306
1348
 
1307
- // Include i18n config if enabled
1308
- const { getI18nConfig, isI18nEnabled } = await import("./i18n/config.js");
1309
- const i18nConfig = getI18nConfig();
1349
+ // Include i18n config if enabled (read from virtual module to avoid SSR module singleton mismatch)
1350
+ const i18nConfig = virtualConfig?.i18n;
1310
1351
  const i18n =
1311
- isI18nEnabled() && i18nConfig
1352
+ i18nConfig && i18nConfig.locales && i18nConfig.locales.length > 1
1312
1353
  ? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales }
1313
1354
  : undefined;
1314
1355
 
1315
1356
  return {
1316
- version: "0.1.0",
1357
+ version: VERSION,
1358
+ commit: COMMIT,
1317
1359
  hash: manifestHash,
1318
1360
  collections: manifestCollections,
1319
1361
  plugins: manifestPlugins,
1362
+ taxonomies: manifestTaxonomies,
1320
1363
  authMode: authModeValue,
1321
1364
  i18n,
1322
1365
  marketplace: !!this.config.marketplace,
@@ -1500,6 +1543,7 @@ export class EmDashRuntime {
1500
1543
  });
1501
1544
 
1502
1545
  // Update entry to point to new draft (metadata only, not data columns)
1546
+ validateIdentifier(collection, "collection");
1503
1547
  const tableName = `ec_${collection}`;
1504
1548
  await sql`
1505
1549
  UPDATE ${sql.ref(tableName)}
@@ -1805,7 +1849,10 @@ export class EmDashRuntime {
1805
1849
  // resolution order in getPluginRouteMeta to avoid auth/execution mismatches.
1806
1850
  const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
1807
1851
  if (trustedPlugin && this.enabledPlugins.has(trustedPlugin.id)) {
1808
- const routeRegistry = new PluginRouteRegistry({ db: this.db });
1852
+ const routeRegistry = new PluginRouteRegistry({
1853
+ db: this.db,
1854
+ emailPipeline: this.email ?? undefined,
1855
+ });
1809
1856
  routeRegistry.register(trustedPlugin);
1810
1857
 
1811
1858
  const routeKey = path.replace(LEADING_SLASH_PATTERN, "");
@@ -68,7 +68,7 @@ export {
68
68
  export { validateExternalUrl, ssrfSafeFetch, SsrfError } from "./ssrf.js";
69
69
 
70
70
  // Sources
71
- export { wxrSource } from "./sources/wxr.js";
71
+ export { wxrSource, parseWxrDate } from "./sources/wxr.js";
72
72
  export { wordpressRestSource } from "./sources/wordpress-rest.js";
73
73
  export {
74
74
  wordpressPluginSource,
@@ -302,8 +302,8 @@ function wxrPostToNormalizedItem(
302
302
  title: post.title || "Untitled",
303
303
  content,
304
304
  excerpt: post.excerpt,
305
- date: post.postDate ? new Date(post.postDate) : new Date(),
306
- modified: post.postModified ? new Date(post.postModified) : undefined,
305
+ date: parseWxrDate(post.postDateGmt, post.pubDate, post.postDate) ?? new Date(),
306
+ modified: parseWxrDate(post.postModifiedGmt, undefined, post.postModified),
307
307
  author: post.creator,
308
308
  categories: post.categories,
309
309
  tags: post.tags,
@@ -317,6 +317,49 @@ function wxrPostToNormalizedItem(
317
317
  };
318
318
  }
319
319
 
320
+ /**
321
+ * WordPress uses "0000-00-00 00:00:00" as a sentinel for missing GMT dates
322
+ * (e.g. unpublished drafts). This must be treated as absent.
323
+ */
324
+ export const WXR_ZERO_DATE = "0000-00-00 00:00:00";
325
+
326
+ /**
327
+ * Parse a WXR date with the correct fallback chain:
328
+ * 1. GMT date (always UTC, most reliable)
329
+ * 2. pubDate (RFC 2822, includes timezone offset)
330
+ * 3. Site-local date (MySQL datetime without timezone, imprecise but best available)
331
+ *
332
+ * Returns undefined when none of the inputs yield a valid date.
333
+ * Callers that need a guaranteed Date should use `?? new Date()`.
334
+ */
335
+ export function parseWxrDate(
336
+ gmtDate: string | undefined,
337
+ pubDate: string | undefined,
338
+ localDate: string | undefined,
339
+ ): Date | undefined {
340
+ if (gmtDate && gmtDate !== WXR_ZERO_DATE) {
341
+ // GMT dates from WordPress are "YYYY-MM-DD HH:MM:SS" in UTC.
342
+ // Append "Z" so the JS Date constructor treats them as UTC.
343
+ return new Date(gmtDate.replace(" ", "T") + "Z");
344
+ }
345
+
346
+ if (pubDate) {
347
+ // RFC 2822 format includes timezone offset, JS Date parses it correctly
348
+ const d = new Date(pubDate);
349
+ if (!isNaN(d.getTime())) return d;
350
+ }
351
+
352
+ if (localDate) {
353
+ // Site-local time without timezone. Normalize to ISO-like form so
354
+ // runtimes that reject "YYYY-MM-DD HH:MM:SS" can still parse it as
355
+ // local time. If parsing still fails, return undefined.
356
+ const d = new Date(localDate.replace(" ", "T"));
357
+ if (!isNaN(d.getTime())) return d;
358
+ }
359
+
360
+ return undefined;
361
+ }
362
+
320
363
  // Export for use in other sources
321
364
  export { analyzeWxrData, wxrPostToNormalizedItem };
322
365
 
package/src/index.ts CHANGED
@@ -102,6 +102,7 @@ export type {
102
102
  export { ulid } from "ulidx";
103
103
  export { computeContentHash, hashString } from "./utils/hash.js";
104
104
  export { sanitizeHref, isSafeHref } from "./utils/url.js";
105
+ export { decodeSlug } from "./utils/slugify.js";
105
106
 
106
107
  // Live Collections query functions (loader is in emdash/runtime)
107
108
  export {
@@ -290,6 +291,7 @@ export {
290
291
  probeUrl,
291
292
  clearSources,
292
293
  wxrSource,
294
+ parseWxrDate,
293
295
  wordpressRestSource,
294
296
  importReusableBlocksAsSections,
295
297
  } from "./import/index.js";
@@ -336,7 +338,13 @@ export type {
336
338
  GetPreviewUrlOptions,
337
339
  } from "./preview/index.js";
338
340
  // Site Settings
339
- export { getSiteSetting, getSiteSettings, setSiteSettings } from "./settings/index.js";
341
+ export {
342
+ getPluginSetting,
343
+ getPluginSettings,
344
+ getSiteSetting,
345
+ getSiteSettings,
346
+ setSiteSettings,
347
+ } from "./settings/index.js";
340
348
  export type {
341
349
  SiteSettings,
342
350
  SiteSettingKey,
@@ -352,6 +360,7 @@ export type { SeoMeta, SeoMetaOptions } from "./seo/index.js";
352
360
  export type {
353
361
  PagePlacement,
354
362
  PublicPageContext,
363
+ BreadcrumbItem,
355
364
  PageMetadataEvent,
356
365
  PageMetadataContribution,
357
366
  PageMetadataHandler,