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
@@ -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 {
@@ -400,11 +401,14 @@ export class EmDashRuntime {
400
401
  this.pluginStates.set(pluginId, status);
401
402
  if (status === "active") {
402
403
  this.enabledPlugins.add(pluginId);
404
+ await this.rebuildHookPipeline();
405
+ await this._hooks.runPluginActivate(pluginId);
403
406
  } else {
407
+ // Fire deactivate on the current pipeline while the plugin is still in it
408
+ await this._hooks.runPluginDeactivate(pluginId);
404
409
  this.enabledPlugins.delete(pluginId);
410
+ await this.rebuildHookPipeline();
405
411
  }
406
-
407
- await this.rebuildHookPipeline();
408
412
  }
409
413
 
410
414
  /**
@@ -1153,7 +1157,10 @@ export class EmDashRuntime {
1153
1157
  label?: string;
1154
1158
  required?: boolean;
1155
1159
  widget?: string;
1156
- options?: Array<{ value: string; label: string }>;
1160
+ // Two shapes: legacy enum-style `[{ value, label }]` for select widgets,
1161
+ // or arbitrary `Record<string, unknown>` for plugin field widgets that
1162
+ // need per-field config (e.g. a checkbox grid receiving its column defs).
1163
+ options?: Array<{ value: string; label: string }> | Record<string, unknown>;
1157
1164
  }
1158
1165
  > = {};
1159
1166
 
@@ -1165,7 +1172,14 @@ export class EmDashRuntime {
1165
1172
  required: field.required,
1166
1173
  };
1167
1174
  if (field.widget) entry.widget = field.widget;
1168
- // Include select/multiSelect options from validation
1175
+ // Plugin field widgets read their per-field config from `field.options`,
1176
+ // which the seed schema types as `Record<string, unknown>`. Pass it
1177
+ // through to the manifest so plugin widgets in the admin SPA receive it.
1178
+ if (field.options) {
1179
+ entry.options = field.options;
1180
+ }
1181
+ // Legacy: select/multiSelect enum options live on `field.validation.options`.
1182
+ // Wins over `field.options` to preserve existing behavior for enum widgets.
1169
1183
  if (field.validation?.options) {
1170
1184
  entry.options = field.validation.options.map((v) => ({
1171
1185
  value: v,
@@ -1243,8 +1257,8 @@ export class EmDashRuntime {
1243
1257
  version: plugin.version,
1244
1258
  enabled,
1245
1259
  adminMode,
1246
- adminPages: plugin.admin?.pages,
1247
- dashboardWidgets: plugin.admin?.widgets,
1260
+ adminPages: plugin.admin?.pages ?? [],
1261
+ dashboardWidgets: plugin.admin?.widgets ?? [],
1248
1262
  portableTextBlocks: plugin.admin?.portableTextBlocks,
1249
1263
  fieldWidgets: plugin.admin?.fieldWidgets,
1250
1264
  };
@@ -1266,8 +1280,8 @@ export class EmDashRuntime {
1266
1280
  enabled,
1267
1281
  sandboxed: true,
1268
1282
  adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1269
- adminPages: entry.adminPages,
1270
- dashboardWidgets: entry.adminWidgets,
1283
+ adminPages: entry.adminPages ?? [],
1284
+ dashboardWidgets: entry.adminWidgets ?? [],
1271
1285
  };
1272
1286
  }
1273
1287
 
@@ -1289,26 +1303,51 @@ export class EmDashRuntime {
1289
1303
  enabled,
1290
1304
  sandboxed: true,
1291
1305
  adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1292
- adminPages: pages,
1293
- dashboardWidgets: widgets,
1306
+ adminPages: pages ?? [],
1307
+ dashboardWidgets: widgets ?? [],
1294
1308
  };
1295
1309
  }
1296
1310
 
1297
- // Generate hash from both collections and plugins so cache invalidates
1298
- // when plugins are enabled/disabled or their config changes
1311
+ // Build taxonomies from database
1312
+ let manifestTaxonomies: Array<{
1313
+ name: string;
1314
+ label: string;
1315
+ labelSingular?: string;
1316
+ hierarchical: boolean;
1317
+ collections: string[];
1318
+ }> = [];
1319
+ try {
1320
+ const rows = await this.db
1321
+ .selectFrom("_emdash_taxonomy_defs")
1322
+ .selectAll()
1323
+ .orderBy("name")
1324
+ .execute();
1325
+ manifestTaxonomies = rows.map((row) => ({
1326
+ name: row.name,
1327
+ label: row.label,
1328
+ labelSingular: row.label_singular ?? undefined,
1329
+ hierarchical: row.hierarchical === 1,
1330
+ collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
1331
+ }));
1332
+ } catch (error) {
1333
+ console.debug("EmDash: Could not load taxonomy definitions:", error);
1334
+ }
1335
+
1336
+ // Build manifest hash
1299
1337
  const manifestHash = await hashString(
1300
- JSON.stringify(manifestCollections) + JSON.stringify(manifestPlugins),
1338
+ JSON.stringify(manifestCollections) +
1339
+ JSON.stringify(manifestPlugins) +
1340
+ JSON.stringify(manifestTaxonomies),
1301
1341
  );
1302
1342
 
1303
1343
  // Determine auth mode
1304
1344
  const authMode = getAuthMode(this.config);
1305
1345
  const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey";
1306
1346
 
1307
- // Include i18n config if enabled
1308
- const { getI18nConfig, isI18nEnabled } = await import("./i18n/config.js");
1309
- const i18nConfig = getI18nConfig();
1347
+ // Include i18n config if enabled (read from virtual module to avoid SSR module singleton mismatch)
1348
+ const i18nConfig = virtualConfig?.i18n;
1310
1349
  const i18n =
1311
- isI18nEnabled() && i18nConfig
1350
+ i18nConfig && i18nConfig.locales && i18nConfig.locales.length > 1
1312
1351
  ? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales }
1313
1352
  : undefined;
1314
1353
 
@@ -1317,6 +1356,7 @@ export class EmDashRuntime {
1317
1356
  hash: manifestHash,
1318
1357
  collections: manifestCollections,
1319
1358
  plugins: manifestPlugins,
1359
+ taxonomies: manifestTaxonomies,
1320
1360
  authMode: authModeValue,
1321
1361
  i18n,
1322
1362
  marketplace: !!this.config.marketplace,
@@ -1805,7 +1845,10 @@ export class EmDashRuntime {
1805
1845
  // resolution order in getPluginRouteMeta to avoid auth/execution mismatches.
1806
1846
  const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
1807
1847
  if (trustedPlugin && this.enabledPlugins.has(trustedPlugin.id)) {
1808
- const routeRegistry = new PluginRouteRegistry({ db: this.db });
1848
+ const routeRegistry = new PluginRouteRegistry({
1849
+ db: this.db,
1850
+ emailPipeline: this.email ?? undefined,
1851
+ });
1809
1852
  routeRegistry.register(trustedPlugin);
1810
1853
 
1811
1854
  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
@@ -290,6 +290,7 @@ export {
290
290
  probeUrl,
291
291
  clearSources,
292
292
  wxrSource,
293
+ parseWxrDate,
293
294
  wordpressRestSource,
294
295
  importReusableBlocksAsSections,
295
296
  } from "./import/index.js";
@@ -336,7 +337,13 @@ export type {
336
337
  GetPreviewUrlOptions,
337
338
  } from "./preview/index.js";
338
339
  // Site Settings
339
- export { getSiteSetting, getSiteSettings, setSiteSettings } from "./settings/index.js";
340
+ export {
341
+ getPluginSetting,
342
+ getPluginSettings,
343
+ getSiteSetting,
344
+ getSiteSettings,
345
+ setSiteSettings,
346
+ } from "./settings/index.js";
340
347
  export type {
341
348
  SiteSettings,
342
349
  SiteSettingKey,
@@ -352,6 +359,7 @@ export type { SeoMeta, SeoMetaOptions } from "./seo/index.js";
352
359
  export type {
353
360
  PagePlacement,
354
361
  PublicPageContext,
362
+ BreadcrumbItem,
355
363
  PageMetadataEvent,
356
364
  PageMetadataContribution,
357
365
  PageMetadataHandler,
package/src/mcp/server.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Permission, RoleLevel } from "@emdash-cms/auth";
13
- import { canActOnOwn, Role } from "@emdash-cms/auth";
13
+ import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth";
14
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
15
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
16
16
  import { z } from "zod";
@@ -299,7 +299,7 @@ export function createMcpServer(): McpServer {
299
299
  status: z
300
300
  .enum(["draft", "published"])
301
301
  .optional()
302
- .describe("Initial status (default 'draft')"),
302
+ .describe("Initial status (default 'draft'). Requires publish permission."),
303
303
  locale: z
304
304
  .string()
305
305
  .optional()
@@ -317,11 +317,47 @@ export function createMcpServer(): McpServer {
317
317
  requireScope(extra, "content:write");
318
318
  requireRole(extra, Role.CONTRIBUTOR);
319
319
  const { emdash, userId } = getExtra(extra);
320
+
321
+ // Creating a translation requires edit permission on the source item
322
+ if (args.translationOf) {
323
+ const source = await emdash.handleContentGet(args.collection, args.translationOf);
324
+ if (!source.success) return unwrap(source);
325
+ requireOwnership(
326
+ extra,
327
+ extractContentAuthorId(source.data),
328
+ "content:edit_own",
329
+ "content:edit_any",
330
+ );
331
+ }
332
+
333
+ // Publishing requires publish permission — create as draft then publish
334
+ if (args.status === "published") {
335
+ const user = { id: userId, role: getExtra(extra).userRole };
336
+ if (!hasPermission(user, "content:publish_own" as Permission)) {
337
+ throw new McpError(
338
+ ErrorCode.InvalidRequest,
339
+ "Insufficient permissions: publishing requires content:publish_own",
340
+ );
341
+ }
342
+ const result = await emdash.handleContentCreate(args.collection, {
343
+ data: args.data,
344
+ slug: args.slug,
345
+ authorId: userId,
346
+ locale: args.locale,
347
+ translationOf: args.translationOf,
348
+ });
349
+ if (!result.success) return unwrap(result);
350
+ const itemId = extractContentId(result.data);
351
+ if (itemId) {
352
+ return unwrap(await emdash.handleContentPublish(args.collection, itemId));
353
+ }
354
+ return unwrap(result);
355
+ }
356
+
320
357
  return unwrap(
321
358
  await emdash.handleContentCreate(args.collection, {
322
359
  data: args.data,
323
360
  slug: args.slug,
324
- status: args.status,
325
361
  authorId: userId,
326
362
  locale: args.locale,
327
363
  translationOf: args.translationOf,
@@ -347,7 +383,12 @@ export function createMcpServer(): McpServer {
347
383
  .optional()
348
384
  .describe("Field values to update (only include changed fields)"),
349
385
  slug: z.string().optional().describe("New URL slug"),
350
- status: z.enum(["draft", "published"]).optional().describe("New status"),
386
+ status: z
387
+ .enum(["draft", "published"])
388
+ .optional()
389
+ .describe(
390
+ "New status. Setting to 'published' requires publish permission. Setting to 'draft' unpublishes the item and also requires publish permission.",
391
+ ),
351
392
  _rev: z
352
393
  .string()
353
394
  .optional()
@@ -372,11 +413,50 @@ export function createMcpServer(): McpServer {
372
413
  );
373
414
 
374
415
  const resolvedId = extractContentId(existing.data) ?? args.id;
416
+
417
+ // Status transitions route through dedicated handlers for proper revision management
418
+ if (args.status === "published") {
419
+ requireOwnership(
420
+ extra,
421
+ extractContentAuthorId(existing.data),
422
+ "content:publish_own",
423
+ "content:publish_any",
424
+ );
425
+ if (args.data || args.slug) {
426
+ const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
427
+ data: args.data,
428
+ slug: args.slug,
429
+ authorId: userId,
430
+ _rev: args._rev,
431
+ });
432
+ if (!updateResult.success) return unwrap(updateResult);
433
+ }
434
+ return unwrap(await emdash.handleContentPublish(args.collection, resolvedId));
435
+ }
436
+
437
+ if (args.status === "draft") {
438
+ requireOwnership(
439
+ extra,
440
+ extractContentAuthorId(existing.data),
441
+ "content:publish_own",
442
+ "content:publish_any",
443
+ );
444
+ if (args.data || args.slug) {
445
+ const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
446
+ data: args.data,
447
+ slug: args.slug,
448
+ authorId: userId,
449
+ _rev: args._rev,
450
+ });
451
+ if (!updateResult.success) return unwrap(updateResult);
452
+ }
453
+ return unwrap(await emdash.handleContentUnpublish(args.collection, resolvedId));
454
+ }
455
+
375
456
  return unwrap(
376
457
  await emdash.handleContentUpdate(args.collection, resolvedId, {
377
458
  data: args.data,
378
459
  slug: args.slug,
379
- status: args.status,
380
460
  authorId: userId,
381
461
  _rev: args._rev,
382
462
  }),
@@ -9,6 +9,7 @@ import { sql } from "kysely";
9
9
 
10
10
  import type { Database } from "../database/types.js";
11
11
  import { getDb } from "../loader.js";
12
+ import { sanitizeHref } from "../utils/url.js";
12
13
  import type { Menu, MenuItem, MenuItemRow } from "./types.js";
13
14
 
14
15
  /**
@@ -235,7 +236,7 @@ async function resolveMenuItem(
235
236
  return {
236
237
  id: item.id,
237
238
  label: item.label,
238
- url,
239
+ url: sanitizeHref(url),
239
240
  target: item.target || undefined,
240
241
  titleAttr: item.title_attr || undefined,
241
242
  cssClasses: item.css_classes || undefined,
@@ -5,13 +5,14 @@
5
5
  * The resulting context is passed to EmDashHead / EmDashBodyStart / EmDashBodyEnd.
6
6
  */
7
7
 
8
- import type { PublicPageContext } from "../plugins/types.js";
8
+ import type { BreadcrumbItem, PublicPageContext } from "../plugins/types.js";
9
9
 
10
10
  /** Fields shared by both input forms */
11
11
  interface PageContextFields {
12
12
  kind: "content" | "custom";
13
13
  pageType?: string;
14
14
  title?: string | null;
15
+ pageTitle?: string | null;
15
16
  description?: string | null;
16
17
  canonical?: string | null;
17
18
  image?: string | null;
@@ -31,6 +32,14 @@ interface PageContextFields {
31
32
  };
32
33
  /** Site name for structured data and og:site_name */
33
34
  siteName?: string;
35
+ /**
36
+ * Breadcrumb trail for this page, root first. Pass an empty array
37
+ * to explicitly opt out of breadcrumbs (e.g. homepage), or omit the
38
+ * field to let consumers fall back to their own derivation.
39
+ */
40
+ breadcrumbs?: BreadcrumbItem[];
41
+ /** Public-facing site URL (origin) for structured data */
42
+ siteUrl?: string;
34
43
  }
35
44
 
36
45
  /** Input with Astro global -- used in .astro files */
@@ -76,6 +85,7 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu
76
85
  kind: input.kind,
77
86
  pageType: input.pageType ?? (input.kind === "content" ? "article" : "website"),
78
87
  title: input.title ?? null,
88
+ pageTitle: input.pageTitle ?? null,
79
89
  description: input.description ?? null,
80
90
  canonical: input.canonical ?? null,
81
91
  image: input.image ?? null,
@@ -89,5 +99,7 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu
89
99
  seo: input.seo,
90
100
  articleMeta: input.articleMeta,
91
101
  siteName: input.siteName,
102
+ breadcrumbs: input.breadcrumbs,
103
+ siteUrl: input.siteUrl,
92
104
  };
93
105
  }
@@ -33,7 +33,7 @@ export function cleanJsonLd(obj: Record<string, unknown>): Record<string, unknow
33
33
  export function buildBlogPostingJsonLd(page: PublicPageContext): Record<string, unknown> | null {
34
34
  if (page.pageType !== "article" || !page.canonical) return null;
35
35
 
36
- const ogTitle = page.seo?.ogTitle || page.title;
36
+ const ogTitle = page.seo?.ogTitle ?? page.pageTitle ?? page.title;
37
37
  const description = page.seo?.ogDescription || page.description;
38
38
  const ogImage = page.seo?.ogImage || page.image;
39
39
  const publishedTime = page.articleMeta?.publishedTime;
@@ -77,12 +77,16 @@ export function buildWebSiteJsonLd(page: PublicPageContext): Record<string, unkn
77
77
  const siteName = page.siteName;
78
78
  if (!siteName) return null;
79
79
 
80
- // Use origin from the page URL for the site URL
80
+ // Use configured public origin, falling back to page URL origin
81
81
  let siteUrl: string;
82
- try {
83
- siteUrl = new URL(page.url).origin;
84
- } catch {
85
- siteUrl = page.canonical || page.url;
82
+ if (page.siteUrl) {
83
+ siteUrl = page.siteUrl;
84
+ } else {
85
+ try {
86
+ siteUrl = new URL(page.url).origin;
87
+ } catch {
88
+ siteUrl = page.canonical || page.url;
89
+ }
86
90
  }
87
91
 
88
92
  return cleanJsonLd({
@@ -20,7 +20,7 @@ export function generateBaseSeoContributions(page: PublicPageContext): PageMetad
20
20
  const contributions: PageMetadataContribution[] = [];
21
21
 
22
22
  const description = page.description;
23
- const ogTitle = page.seo?.ogTitle || page.title;
23
+ const ogTitle = page.seo?.ogTitle ?? page.pageTitle ?? page.title;
24
24
  const ogDescription = page.seo?.ogDescription || description;
25
25
  const ogImage = page.seo?.ogImage || page.image;
26
26
  const robots = page.seo?.robots;