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
@@ -15,6 +15,7 @@ import { getSiteBaseUrl } from "#api/site-url.js";
15
15
  import { sendCommentNotification } from "#comments/notifications.js";
16
16
  import { createComment, type CommentHookRunner } from "#comments/service.js";
17
17
  import { CommentRepository } from "#db/repositories/comment.js";
18
+ import { validateIdentifier } from "#db/validate.js";
18
19
  import { extractRequestMeta } from "#plugins/request-meta.js";
19
20
  import type { CollectionCommentSettings, ModerationDecision } from "#plugins/types.js";
20
21
 
@@ -106,6 +107,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
106
107
  }
107
108
 
108
109
  // Verify the content item exists, is published, and not soft-deleted
110
+ validateIdentifier(collection, "collection");
109
111
  const contentRow = await emdash.db
110
112
  .selectFrom(`ec_${collection}` as never)
111
113
  .select(["id" as never, "slug" as never, "author_id" as never, "published_at" as never])
@@ -17,6 +17,7 @@ import { requirePerm } from "#api/authorize.js";
17
17
  import { apiError, apiSuccess, handleError } from "#api/error.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { wpRewriteUrlsBody } from "#api/schemas.js";
20
+ import { validateIdentifier } from "#db/validate.js";
20
21
  import { normalizeMediaValue } from "#media/normalize.js";
21
22
  import type { MediaProvider } from "#media/types.js";
22
23
  import type { EmDashHandlers } from "#types";
@@ -280,6 +281,7 @@ async function rewriteUrls(
280
281
  continue;
281
282
 
282
283
  // Get table name
284
+ validateIdentifier(collection.slug, "collection slug");
283
285
  const tableName = `ec_${collection.slug}`;
284
286
 
285
287
  try {
@@ -11,6 +11,7 @@ import type { APIRoute } from "astro";
11
11
 
12
12
  import { getAuthMode } from "#auth/mode.js";
13
13
 
14
+ import { COMMIT, VERSION } from "../../../version.js";
14
15
  import type { EmDashManifest } from "../../types.js";
15
16
 
16
17
  export const prerender = false;
@@ -43,7 +44,8 @@ export const GET: APIRoute = async ({ locals }) => {
43
44
  signupEnabled,
44
45
  }
45
46
  : {
46
- version: "0.1.0",
47
+ version: VERSION,
48
+ commit: COMMIT,
47
49
  hash: "default",
48
50
  collections: {},
49
51
  plugins: {},
@@ -17,6 +17,7 @@ import {
17
17
  } from "#api/handlers/redirects.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { updateRedirectBody } from "#api/schemas.js";
20
+ import { invalidateRedirectCache } from "#redirects/cache.js";
20
21
 
21
22
  export const prerender = false;
22
23
 
@@ -57,6 +58,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
57
58
  if (isParseError(body)) return body;
58
59
 
59
60
  const result = await handleRedirectUpdate(db, id, body);
61
+ invalidateRedirectCache();
60
62
  return unwrapResult(result);
61
63
  } catch (error) {
62
64
  return handleError(error, "Failed to update redirect", "REDIRECT_UPDATE_ERROR");
@@ -77,6 +79,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
77
79
 
78
80
  try {
79
81
  const result = await handleRedirectDelete(db, id);
82
+ invalidateRedirectCache();
80
83
  return unwrapResult(result);
81
84
  } catch (error) {
82
85
  return handleError(error, "Failed to delete redirect", "REDIRECT_DELETE_ERROR");
@@ -12,6 +12,7 @@ import { handleError, unwrapResult } from "#api/error.js";
12
12
  import { handleRedirectCreate, handleRedirectList } from "#api/handlers/redirects.js";
13
13
  import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
14
  import { createRedirectBody, redirectsListQuery } from "#api/schemas.js";
15
+ import { invalidateRedirectCache } from "#redirects/cache.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -45,6 +46,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
45
46
  if (isParseError(body)) return body;
46
47
 
47
48
  const result = await handleRedirectCreate(db, body);
49
+ invalidateRedirectCache();
48
50
  return unwrapResult(result, 201);
49
51
  } catch (error) {
50
52
  return handleError(error, "Failed to create redirect", "REDIRECT_CREATE_ERROR");
@@ -59,6 +59,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
59
59
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- parseBody validates via Zod
60
60
  body as UpdateCollectionInput,
61
61
  );
62
+ emdash!.invalidateManifest();
62
63
  return unwrapResult(result);
63
64
  };
64
65
 
@@ -76,5 +77,6 @@ export const DELETE: APIRoute = async ({ params, url, locals }) => {
76
77
  const result = await handleSchemaCollectionDelete(emdash!.db, slug, {
77
78
  force,
78
79
  });
80
+ emdash!.invalidateManifest();
79
81
  return unwrapResult(result);
80
82
  };
@@ -43,5 +43,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
43
43
 
44
44
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Zod schema output narrowed to CreateCollectionInput
45
45
  const result = await handleSchemaCollectionCreate(emdash!.db, body as CreateCollectionInput);
46
+ emdash!.invalidateManifest();
46
47
  return unwrapResult(result, 201);
47
48
  };
@@ -32,20 +32,34 @@ import type { StorageDescriptor, S3StorageConfig, LocalStorageConfig } from "./t
32
32
  /**
33
33
  * S3-compatible storage adapter
34
34
  *
35
- * Works with AWS S3, Cloudflare R2 (via S3 API), Minio, etc.
35
+ * Works with AWS S3, Cloudflare R2 (via S3 API), MinIO, etc.
36
+ *
37
+ * Any field omitted here is resolved from the matching `S3_*` environment
38
+ * variable when the container starts (`S3_ENDPOINT`, `S3_BUCKET`,
39
+ * `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `S3_PUBLIC_URL`).
40
+ * Explicit values always take precedence over env vars.
41
+ *
42
+ * Note: env var resolution reads `process.env` on Node at runtime.
43
+ * Workers users should continue passing explicit values to `s3({...})`.
36
44
  *
37
45
  * @example
38
46
  * ```ts
47
+ * // All fields from env (container deployments)
48
+ * storage: s3()
49
+ *
50
+ * // Mix: CDN from config, credentials from env
51
+ * storage: s3({ publicUrl: "https://cdn.example.com" })
52
+ *
53
+ * // All explicit (unchanged from before)
39
54
  * storage: s3({
40
55
  * endpoint: "https://xxx.r2.cloudflarestorage.com",
41
56
  * bucket: "media",
42
- * accessKeyId: process.env.R2_ACCESS_KEY_ID!,
43
- * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
44
- * publicUrl: "https://cdn.example.com", // optional CDN
57
+ * accessKeyId: process.env.R2_ACCESS_KEY_ID,
58
+ * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
45
59
  * })
46
60
  * ```
47
61
  */
48
- export function s3(config: S3StorageConfig): StorageDescriptor {
62
+ export function s3(config: Partial<S3StorageConfig> = {}): StorageDescriptor {
49
63
  return {
50
64
  entrypoint: "emdash/storage/s3",
51
65
  config,
@@ -39,10 +39,18 @@ export interface S3StorageConfig {
39
39
  endpoint: string;
40
40
  /** Bucket name */
41
41
  bucket: string;
42
- /** Access key ID */
43
- accessKeyId: string;
44
- /** Secret access key */
45
- secretAccessKey: string;
42
+ /**
43
+ * Access key ID.
44
+ * May be resolved from the `S3_ACCESS_KEY_ID` env var at runtime on Node.
45
+ * Must be provided together with `secretAccessKey`, or both omitted.
46
+ */
47
+ accessKeyId?: string;
48
+ /**
49
+ * Secret access key.
50
+ * May be resolved from the `S3_SECRET_ACCESS_KEY` env var at runtime on Node.
51
+ * Must be provided together with `accessKeyId`, or both omitted.
52
+ */
53
+ secretAccessKey?: string;
46
54
  /** Optional region (defaults to "auto") */
47
55
  region?: string;
48
56
  /** Optional public URL prefix for CDN */
@@ -102,6 +102,7 @@ export type ManifestAuthMode = string;
102
102
  */
103
103
  export interface EmDashManifest {
104
104
  version: string;
105
+ commit?: string;
105
106
  hash: string;
106
107
  collections: Record<string, ManifestCollection>;
107
108
  plugins: Record<string, ManifestPlugin>;
@@ -14,6 +14,48 @@ import { validateIdentifier } from "../database/validate.js";
14
14
  import { getDb } from "../loader.js";
15
15
  import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
16
16
 
17
+ /**
18
+ * Cached result of "does any byline exist in the database?"
19
+ * null = not yet checked, true/false = cached result.
20
+ * Invalidated when bylines are created or deleted.
21
+ */
22
+ let hasBylines: boolean | null = null;
23
+
24
+ /**
25
+ * Invalidate the cached "has any bylines" check.
26
+ * Call this when bylines are created, updated, or deleted.
27
+ */
28
+ export function invalidateBylineCache(): void {
29
+ hasBylines = null;
30
+ }
31
+
32
+ /**
33
+ * Check if any bylines exist in the database. Result is cached
34
+ * for the lifetime of the worker/process and invalidated on writes.
35
+ */
36
+ async function hasAnyBylines(): Promise<boolean> {
37
+ if (hasBylines !== null) return hasBylines;
38
+
39
+ try {
40
+ const db = await getDb();
41
+ const result = await sql<{ id: string }>`
42
+ SELECT id FROM _emdash_bylines LIMIT 1
43
+ `.execute(db);
44
+ hasBylines = result.rows.length > 0;
45
+ } catch (error: unknown) {
46
+ // Only treat "no such table" as a safe false -- anything else should
47
+ // not be cached so the next request retries.
48
+ const message = error instanceof Error ? error.message : "";
49
+ if (message.includes("no such table")) {
50
+ hasBylines = false;
51
+ } else {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ return hasBylines;
57
+ }
58
+
17
59
  /**
18
60
  * Get a byline by ID.
19
61
  *
@@ -134,6 +176,12 @@ export async function getBylinesForEntries(
134
176
  return result;
135
177
  }
136
178
 
179
+ // Skip DB queries entirely when no bylines have been created.
180
+ // The cache is invalidated when bylines are created/deleted.
181
+ if (!(await hasAnyBylines())) {
182
+ return result;
183
+ }
184
+
137
185
  const db = await getDb();
138
186
  const repo = new BylineRepository(db);
139
187
 
@@ -199,8 +247,8 @@ async function getAuthorId(
199
247
  collection: string,
200
248
  entryId: string,
201
249
  ): Promise<string | null> {
250
+ validateIdentifier(collection, "collection");
202
251
  const tableName = `ec_${collection}`;
203
- validateIdentifier(tableName, "content table");
204
252
 
205
253
  const result = await sql<{ author_id: string | null }>`
206
254
  SELECT author_id FROM ${sql.ref(tableName)}
@@ -220,8 +268,8 @@ async function getAuthorIds(
220
268
  collection: string,
221
269
  entryIds: string[],
222
270
  ): Promise<Map<string, string>> {
271
+ validateIdentifier(collection, "collection");
223
272
  const tableName = `ec_${collection}`;
224
- validateIdentifier(tableName, "content table");
225
273
 
226
274
  const map = new Map<string, string>();
227
275
  for (const chunk of chunks(entryIds, SQL_BATCH_SIZE)) {
package/src/cleanup.ts CHANGED
@@ -121,11 +121,11 @@ export async function runSystemCleanup(
121
121
  * them down to REVISION_KEEP_COUNT.
122
122
  */
123
123
  async function pruneExcessiveRevisions(db: Kysely<Database>): Promise<number> {
124
- const entries = await sql<{ collection: string; entry_id: string; cnt: number }>`
125
- SELECT collection, entry_id, COUNT(*) as cnt
124
+ const entries = await sql<{ collection: string; entry_id: string }>`
125
+ SELECT collection, entry_id
126
126
  FROM revisions
127
127
  GROUP BY collection, entry_id
128
- HAVING cnt > ${REVISION_PRUNE_THRESHOLD}
128
+ HAVING COUNT(*) > ${REVISION_PRUNE_THRESHOLD}
129
129
  `.execute(db);
130
130
 
131
131
  if (entries.rows.length === 0) return 0;
@@ -196,11 +196,7 @@ export async function resolveSourceEntry(
196
196
  ): Promise<string | undefined> {
197
197
  const cleaned = distPath.replace(LEADING_DOT_SLASH_RE, "");
198
198
 
199
- // Try the path directly (might be source already)
200
- const direct = resolve(pluginDir, cleaned);
201
- if (await fileExists(direct)) return direct;
202
-
203
- // Convert dist path to src: dist/foo.mjs → src/foo.ts
199
+ // Prefer source over dist dist/foo.mjs src/foo.ts
204
200
  const srcPath = cleaned.replace(DIST_PREFIX_RE, "src/").replace(MJS_EXT_RE, ".ts");
205
201
  const srcFull = resolve(pluginDir, srcPath);
206
202
  if (await fileExists(srcFull)) return srcFull;
@@ -210,6 +206,10 @@ export async function resolveSourceEntry(
210
206
  const tsxFull = resolve(pluginDir, tsxPath);
211
207
  if (await fileExists(tsxFull)) return tsxFull;
212
208
 
209
+ // Fall back to direct path (might be source already, or pre-compiled plugin)
210
+ const direct = resolve(pluginDir, cleaned);
211
+ if (await fileExists(direct)) return direct;
212
+
213
213
  return undefined;
214
214
  }
215
215
 
@@ -14,6 +14,7 @@ import type { ColumnDataType, Kysely, RawBuilder } from "kysely";
14
14
  import { sql } from "kysely";
15
15
 
16
16
  import type { DatabaseDialectType } from "../db/adapters.js";
17
+ import { validateIdentifier, validateJsonFieldName } from "./validate.js";
17
18
 
18
19
  export type { DatabaseDialectType };
19
20
 
@@ -131,6 +132,8 @@ export function binaryType(db: Kysely<any>): ColumnDataType {
131
132
  */
132
133
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
133
134
  export function jsonExtractExpr(db: Kysely<any>, column: string, path: string): string {
135
+ validateIdentifier(column, "JSON column name");
136
+ validateJsonFieldName(path, "JSON path");
134
137
  if (isPostgres(db)) {
135
138
  return `${column}->>'${path}'`;
136
139
  }
@@ -58,8 +58,8 @@ export async function up(db: Kysely<unknown>): Promise<void> {
58
58
  }
59
59
 
60
60
  export async function down(db: Kysely<unknown>): Promise<void> {
61
- await db.schema.dropIndex("idx_content_taxonomies_term").execute();
62
- await db.schema.dropIndex("idx_media_mime_type").execute();
61
+ await db.schema.dropIndex("idx_sections_source").execute();
62
+ await db.schema.dropIndex("idx_sections_category").execute();
63
63
  await db.schema.dropTable("_emdash_sections").execute();
64
64
  await db.schema.dropTable("_emdash_section_categories").execute();
65
65
  }
@@ -1,4 +1,4 @@
1
- import { type Kysely, type Migration, type MigrationProvider, Migrator } from "kysely";
1
+ import { type Kysely, type Migration, type MigrationProvider, Migrator, sql } from "kysely";
2
2
 
3
3
  import type { Database } from "../types.js";
4
4
  // Import migrations statically for bundling
@@ -122,9 +122,30 @@ export async function getMigrationStatus(db: Kysely<Database>): Promise<Migratio
122
122
  }
123
123
 
124
124
  /**
125
- * Run all pending migrations
125
+ * Run all pending migrations.
126
+ *
127
+ * Includes a fast-path: if the migration table already exists and contains
128
+ * exactly MIGRATION_COUNT rows, all migrations have been applied and we can
129
+ * skip the Kysely Migrator entirely. This avoids the expensive
130
+ * `pragma_table_info` introspection that Kysely runs for every table in the
131
+ * database (twice!) just to check if the migration tables exist.
132
+ * On D1 with ~57 tables, that's ~116 queries saved per init.
126
133
  */
127
134
  export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
135
+ // Fast path: check if all migrations are already applied.
136
+ // A single cheap query vs the Migrator's full schema introspection.
137
+ try {
138
+ const result = await sql<{ count: number }>`
139
+ SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
140
+ `.execute(db);
141
+ if (result.rows[0]?.count === MIGRATION_COUNT) {
142
+ return { applied: [] };
143
+ }
144
+ } catch {
145
+ // Table doesn't exist yet (first run). Fall through to the Migrator
146
+ // which will create it.
147
+ }
148
+
128
149
  const migrator = new Migrator({
129
150
  db,
130
151
  provider: new StaticMigrationProvider(),
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
3
3
 
4
4
  import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
5
5
  import { listTablesLike } from "../dialect-helpers.js";
6
+ import { withTransaction } from "../transaction.js";
6
7
  import type { BylineTable, Database } from "../types.js";
7
8
  import { validateIdentifier } from "../validate.js";
8
9
  import {
@@ -197,7 +198,7 @@ export class BylineRepository {
197
198
  const existing = await this.findById(id);
198
199
  if (!existing) return false;
199
200
 
200
- await this.db.transaction().execute(async (trx) => {
201
+ await withTransaction(this.db, async (trx) => {
201
202
  await trx.deleteFrom("_emdash_content_bylines").where("byline_id", "=", id).execute();
202
203
 
203
204
  await trx.deleteFrom("_emdash_bylines").where("id", "=", id).execute();
@@ -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
 
@@ -168,6 +170,7 @@ export class ContentRepository {
168
170
  if (data && typeof data === "object") {
169
171
  for (const [key, value] of Object.entries(data)) {
170
172
  if (!SYSTEM_COLUMNS.has(key)) {
173
+ validateIdentifier(key, "content field name");
171
174
  columns.push(key);
172
175
  values.push(serializeValue(value));
173
176
  }
@@ -578,6 +581,7 @@ export class ContentRepository {
578
581
  if (input.data !== undefined && typeof input.data === "object") {
579
582
  for (const [key, value] of Object.entries(input.data)) {
580
583
  if (!SYSTEM_COLUMNS.has(key)) {
584
+ validateIdentifier(key, "content field name");
581
585
  updates[key] = serializeValue(value);
582
586
  }
583
587
  }
@@ -1079,6 +1083,7 @@ export class ContentRepository {
1079
1083
  for (const [key, value] of Object.entries(data)) {
1080
1084
  if (SYSTEM_COLUMNS.has(key)) continue;
1081
1085
  if (key.startsWith("_")) continue; // revision metadata
1086
+ validateIdentifier(key, "content field name");
1082
1087
  updates[key] = serializeValue(value);
1083
1088
  }
1084
1089
 
@@ -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> {
@@ -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));
@@ -23,6 +23,7 @@ import { isSqlite } from "./database/dialect-helpers.js";
23
23
  import { runMigrations } from "./database/migrations/runner.js";
24
24
  import { RevisionRepository } from "./database/repositories/revision.js";
25
25
  import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
26
+ import { validateIdentifier } from "./database/validate.js";
26
27
  import { normalizeMediaValue } from "./media/normalize.js";
27
28
  import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js";
28
29
  import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js";
@@ -36,8 +37,10 @@ import type {
36
37
  PageMetadataContribution,
37
38
  PageFragmentContribution,
38
39
  } from "./plugins/types.js";
40
+ import { invalidateUrlPatternCache } from "./query.js";
39
41
  import type { FieldType } from "./schema/types.js";
40
42
  import { hashString } from "./utils/hash.js";
43
+ import { COMMIT, VERSION } from "./version.js";
41
44
 
42
45
  const LEADING_SLASH_PATTERN = /^\//;
43
46
 
@@ -55,6 +58,7 @@ const VALID_LINK_REL = new Set([
55
58
  "alternate",
56
59
  "author",
57
60
  "license",
61
+ "nlweb",
58
62
  "site.standard.document",
59
63
  ]);
60
64
 
@@ -1352,7 +1356,8 @@ export class EmDashRuntime {
1352
1356
  : undefined;
1353
1357
 
1354
1358
  return {
1355
- version: "0.1.0",
1359
+ version: VERSION,
1360
+ commit: COMMIT,
1356
1361
  hash: manifestHash,
1357
1362
  collections: manifestCollections,
1358
1363
  plugins: manifestPlugins,
@@ -1364,11 +1369,12 @@ export class EmDashRuntime {
1364
1369
  }
1365
1370
 
1366
1371
  /**
1367
- * Invalidate the cached manifest (no-op now that we don't cache).
1368
- * Kept for API compatibility.
1372
+ * Invalidate cached data derived from the manifest/schema.
1373
+ * Called when collections are created, updated, or deleted.
1369
1374
  */
1370
1375
  invalidateManifest(): void {
1371
- // No-op - manifest is rebuilt on each request
1376
+ // Invalidate the URL pattern cache used by resolveEmDashPath
1377
+ invalidateUrlPatternCache();
1372
1378
  }
1373
1379
 
1374
1380
  // =========================================================================
@@ -1540,6 +1546,7 @@ export class EmDashRuntime {
1540
1546
  });
1541
1547
 
1542
1548
  // Update entry to point to new draft (metadata only, not data columns)
1549
+ validateIdentifier(collection, "collection");
1543
1550
  const tableName = `ec_${collection}`;
1544
1551
  await sql`
1545
1552
  UPDATE ${sql.ref(tableName)}
@@ -1609,7 +1616,7 @@ export class EmDashRuntime {
1609
1616
 
1610
1617
  // Run afterDelete hooks (fire-and-forget)
1611
1618
  if (result.success) {
1612
- this.runAfterDeleteHooks(id, collection);
1619
+ this.runAfterDeleteHooks(id, collection, false);
1613
1620
  }
1614
1621
 
1615
1622
  return result;
@@ -1631,7 +1638,14 @@ export class EmDashRuntime {
1631
1638
  }
1632
1639
 
1633
1640
  async handleContentPermanentDelete(collection: string, id: string) {
1634
- return handleContentPermanentDelete(this.db, collection, id);
1641
+ const result = await handleContentPermanentDelete(this.db, collection, id);
1642
+
1643
+ // Run afterDelete hooks so plugins (e.g. AI Search) can clean up
1644
+ if (result.success) {
1645
+ this.runAfterDeleteHooks(id, collection, true);
1646
+ }
1647
+
1648
+ return result;
1635
1649
  }
1636
1650
 
1637
1651
  async handleContentCountTrashed(collection: string) {
@@ -2006,11 +2020,11 @@ export class EmDashRuntime {
2006
2020
  }
2007
2021
  }
2008
2022
 
2009
- private runAfterDeleteHooks(id: string, collection: string): void {
2023
+ private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
2010
2024
  // Trusted plugins
2011
2025
  if (this.hooks.hasHooks("content:afterDelete")) {
2012
2026
  this.hooks
2013
- .runContentAfterDelete(id, collection)
2027
+ .runContentAfterDelete(id, collection, permanent)
2014
2028
  .catch((err) => console.error("EmDash afterDelete hook error:", err));
2015
2029
  }
2016
2030
 
@@ -2020,7 +2034,7 @@ export class EmDashRuntime {
2020
2034
  if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2021
2035
 
2022
2036
  plugin
2023
- .invokeHook("content:afterDelete", { id, collection })
2037
+ .invokeHook("content:afterDelete", { id, collection, permanent })
2024
2038
  .catch((err) =>
2025
2039
  console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err),
2026
2040
  );
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 {
@@ -212,6 +213,8 @@ export type {
212
213
  ResolvedHook,
213
214
  ResolvedPluginHooks,
214
215
  ContentHookEvent,
216
+ ContentDeleteEvent,
217
+ ContentPublishStateChangeEvent,
215
218
  MediaUploadEvent,
216
219
  HookResult,
217
220
  PluginRoute,
package/src/loader.ts CHANGED
@@ -16,6 +16,7 @@ import { Kysely, sql, type Dialect } from "kysely";
16
16
 
17
17
  import { currentTimestampValue, isPostgres } from "./database/dialect-helpers.js";
18
18
  import { decodeCursor, encodeCursor } from "./database/repositories/types.js";
19
+ import { validateIdentifier } from "./database/validate.js";
19
20
  import type { Database } from "./index.js";
20
21
  import { getRequestContext } from "./request-context.js";
21
22
 
@@ -50,6 +51,7 @@ const SYSTEM_COLUMNS = new Set([
50
51
  * Get the table name for a collection type
51
52
  */
52
53
  function getTableName(type: string): string {
54
+ validateIdentifier(type, "collection type");
53
55
  return `ec_${type}`;
54
56
  }
55
57