emdash 0.3.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 (162) hide show
  1. package/dist/{adapters-BLMa4JGD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
  2. package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
  3. package/dist/{apply-Bqoekfbe.mjs → apply-Cma_PiF6.mjs} +37 -22
  4. package/dist/apply-Cma_PiF6.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +9 -8
  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.mjs +2 -2
  10. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  11. package/dist/astro/middleware/redirect.mjs +19 -7
  12. package/dist/astro/middleware/redirect.mjs.map +1 -1
  13. package/dist/astro/middleware/request-context.mjs +12 -2
  14. package/dist/astro/middleware/request-context.mjs.map +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +45 -42
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +8 -8
  20. package/dist/{byline-BGj9p9Ht.mjs → byline-WuOq9MFJ.mjs} +3 -2
  21. package/dist/byline-WuOq9MFJ.mjs.map +1 -0
  22. package/dist/{bylines-BihaoIDY.mjs → bylines-C_Wsnz4L.mjs} +36 -4
  23. package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
  24. package/dist/cache-E3Dts-yT.mjs +56 -0
  25. package/dist/cache-E3Dts-yT.mjs.map +1 -0
  26. package/dist/cli/index.mjs +11 -11
  27. package/dist/cli/index.mjs.map +1 -1
  28. package/dist/client/cf-access.d.mts +1 -1
  29. package/dist/client/index.d.mts +1 -1
  30. package/dist/client/index.mjs +1 -1
  31. package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
  32. package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
  33. package/dist/db/index.d.mts +3 -3
  34. package/dist/db/index.mjs +1 -1
  35. package/dist/db/libsql.d.mts +1 -1
  36. package/dist/db/postgres.d.mts +1 -1
  37. package/dist/db/sqlite.d.mts +1 -1
  38. package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
  39. package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
  40. package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
  41. package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
  42. package/dist/{index-Cff7AimE.d.mts → index-CRg3PWfZ.d.mts} +32 -30
  43. package/dist/index-CRg3PWfZ.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +17 -17
  46. package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
  47. package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
  48. package/dist/{loader-BmYdf3Dr.mjs → loader-BYzwzORf.mjs} +1 -1
  49. package/dist/{loader-BmYdf3Dr.mjs.map → loader-BYzwzORf.mjs.map} +1 -1
  50. package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
  51. package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/index.mjs +1 -1
  54. package/dist/media/local-runtime.d.mts +7 -7
  55. package/dist/{mode-C2EzN1uE.mjs → mode-CyPLdO3C.mjs} +1 -1
  56. package/dist/{mode-C2EzN1uE.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
  57. package/dist/page/index.d.mts +1 -1
  58. package/dist/patterns-CrCYkMBb.mjs +93 -0
  59. package/dist/patterns-CrCYkMBb.mjs.map +1 -0
  60. package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
  61. package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
  62. package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
  63. package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
  64. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  65. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  66. package/dist/{query-sesiOndV.mjs → query-B6Vu0d2i.mjs} +34 -15
  67. package/dist/{query-sesiOndV.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
  68. package/dist/{redirect-DUAk-Yl_.mjs → redirect-7lGhLBNZ.mjs} +2 -92
  69. package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
  70. package/dist/{registry-DU18yVo0.mjs → registry-BgnP3ysR.mjs} +19 -35
  71. package/dist/registry-BgnP3ysR.mjs.map +1 -0
  72. package/dist/{runner-Biufrii2.mjs → runner-Cd-_WyDo.mjs} +16 -4
  73. package/dist/runner-Cd-_WyDo.mjs.map +1 -0
  74. package/dist/{runner-EAtf0ZIe.d.mts → runner-DYv3rX8P.d.mts} +10 -3
  75. package/dist/runner-DYv3rX8P.d.mts.map +1 -0
  76. package/dist/runtime.d.mts +6 -6
  77. package/dist/runtime.mjs +1 -1
  78. package/dist/{search-BXB-jfu2.mjs → search-B5p9D36n.mjs} +102 -53
  79. package/dist/search-B5p9D36n.mjs.map +1 -0
  80. package/dist/seed/index.d.mts +2 -2
  81. package/dist/seed/index.mjs +8 -8
  82. package/dist/seo/index.d.mts +1 -1
  83. package/dist/storage/local.d.mts +1 -1
  84. package/dist/storage/local.mjs +1 -1
  85. package/dist/storage/s3.d.mts +1 -1
  86. package/dist/storage/s3.mjs +1 -1
  87. package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
  88. package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
  89. package/dist/transaction-Cn2rjY78.mjs +28 -0
  90. package/dist/transaction-Cn2rjY78.mjs.map +1 -0
  91. package/dist/{transport-yxiQsi8I.mjs → transport-BtcQ-Z7T.mjs} +1 -1
  92. package/dist/{transport-yxiQsi8I.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
  93. package/dist/{transport-BFGblqwG.d.mts → transport-CKQA_G44.d.mts} +1 -1
  94. package/dist/{transport-BFGblqwG.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
  95. package/dist/{types-DRjfYOEv.d.mts → types-B6BzlZxx.d.mts} +1 -1
  96. package/dist/{types-DRjfYOEv.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
  97. package/dist/{types-CaKte3hR.d.mts → types-BYWYxLcp.d.mts} +10 -4
  98. package/dist/types-BYWYxLcp.d.mts.map +1 -0
  99. package/dist/{types-BbsYgi_R.d.mts → types-BmkQR1En.d.mts} +1 -1
  100. package/dist/{types-BbsYgi_R.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
  101. package/dist/{types-C1-PVaS_.d.mts → types-DNZpaCBk.d.mts} +1 -1
  102. package/dist/{types-C1-PVaS_.d.mts.map → types-DNZpaCBk.d.mts.map} +1 -1
  103. package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
  104. package/dist/{types-Bec-r_3_.mjs.map → types-Dz9_WMS6.mjs.map} +1 -1
  105. package/dist/{types-DPfzHnjW.d.mts → types-gLYVCXCQ.d.mts} +1 -1
  106. package/dist/{types-DPfzHnjW.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
  107. package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
  108. package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
  109. package/dist/{validate-bfg9OR6N.d.mts → validate-CcNRWH6I.d.mts} +4 -4
  110. package/dist/{validate-bfg9OR6N.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
  111. package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
  112. package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
  113. package/dist/version-DlTDRdpv.mjs +7 -0
  114. package/dist/{version-REAapfsU.mjs.map → version-DlTDRdpv.mjs.map} +1 -1
  115. package/package.json +7 -5
  116. package/src/api/handlers/content.ts +36 -25
  117. package/src/api/handlers/menus.ts +19 -16
  118. package/src/astro/integration/index.ts +2 -3
  119. package/src/astro/integration/runtime.ts +8 -14
  120. package/src/astro/integration/vite-config.ts +7 -0
  121. package/src/astro/middleware/redirect.ts +30 -15
  122. package/src/astro/middleware.ts +11 -19
  123. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  124. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  125. package/src/astro/routes/api/redirects/[id].ts +3 -0
  126. package/src/astro/routes/api/redirects/index.ts +2 -0
  127. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
  128. package/src/astro/routes/api/schema/collections/index.ts +1 -0
  129. package/src/bylines/index.ts +48 -0
  130. package/src/cleanup.ts +3 -3
  131. package/src/cli/commands/bundle-utils.ts +5 -5
  132. package/src/database/migrations/011_sections.ts +2 -2
  133. package/src/database/migrations/runner.ts +23 -2
  134. package/src/database/repositories/byline.ts +2 -1
  135. package/src/emdash-runtime.ts +18 -8
  136. package/src/index.ts +2 -0
  137. package/src/mcp/server.ts +40 -67
  138. package/src/plugins/context.ts +28 -4
  139. package/src/plugins/cron.ts +29 -4
  140. package/src/plugins/hooks.ts +22 -10
  141. package/src/plugins/index.ts +1 -0
  142. package/src/plugins/manager.ts +6 -2
  143. package/src/plugins/marketplace.ts +33 -3
  144. package/src/plugins/routes.ts +3 -3
  145. package/src/plugins/types.ts +7 -0
  146. package/src/query.ts +37 -14
  147. package/src/redirects/cache.ts +68 -0
  148. package/src/search/fts-manager.ts +20 -11
  149. package/src/search/query.ts +8 -9
  150. package/src/seed/apply.ts +49 -28
  151. package/src/visual-editing/toolbar.ts +11 -1
  152. package/dist/apply-Bqoekfbe.mjs.map +0 -1
  153. package/dist/byline-BGj9p9Ht.mjs.map +0 -1
  154. package/dist/bylines-BihaoIDY.mjs.map +0 -1
  155. package/dist/index-Cff7AimE.d.mts.map +0 -1
  156. package/dist/redirect-DUAk-Yl_.mjs.map +0 -1
  157. package/dist/registry-DU18yVo0.mjs.map +0 -1
  158. package/dist/runner-Biufrii2.mjs.map +0 -1
  159. package/dist/runner-EAtf0ZIe.d.mts.map +0 -1
  160. package/dist/search-BXB-jfu2.mjs.map +0 -1
  161. package/dist/types-CaKte3hR.d.mts.map +0 -1
  162. package/dist/version-REAapfsU.mjs +0 -7
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-ClPoSABd.mjs";
2
- import { n as getI18nConfig, r as isI18nEnabled, t as getFallbackChain } from "./config-Cq8H0SfX.mjs";
2
+ import { n as getI18nConfig, r as isI18nEnabled, t as getFallbackChain } from "./config-DkxPrM9l.mjs";
3
3
  import { getRequestContext } from "./request-context.mjs";
4
4
 
5
5
  //#region src/visual-editing/editable.ts
@@ -76,6 +76,7 @@ var query_exports = /* @__PURE__ */ __exportAll({
76
76
  getEmDashCollection: () => getEmDashCollection,
77
77
  getEmDashEntry: () => getEmDashEntry,
78
78
  getTranslations: () => getTranslations,
79
+ invalidateUrlPatternCache: () => invalidateUrlPatternCache,
79
80
  resolveEmDashPath: () => resolveEmDashPath
80
81
  });
81
82
  const COLLECTION_NAME = "_emdash";
@@ -349,7 +350,7 @@ async function getEmDashEntry(type, id, options) {
349
350
  async function hydrateEntryBylines(type, entries) {
350
351
  if (entries.length === 0) return;
351
352
  try {
352
- const { getBylinesForEntries } = await import("./bylines-BihaoIDY.mjs").then((n) => n.t);
353
+ const { getBylinesForEntries } = await import("./bylines-C_Wsnz4L.mjs").then((n) => n.t);
353
354
  const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
354
355
  if (ids.length === 0) return;
355
356
  const bylinesMap = await getBylinesForEntries(type, ids);
@@ -383,7 +384,7 @@ async function hydrateEntryBylines(type, entries) {
383
384
  */
384
385
  async function getTranslations(type, id) {
385
386
  try {
386
- const db = (await import("./loader-BmYdf3Dr.mjs").then((n) => n.r)).getDb;
387
+ const db = (await import("./loader-BYzwzORf.mjs").then((n) => n.r)).getDb;
387
388
  const dbInstance = await db();
388
389
  const { ContentRepository } = await import("./content-BsBoyj8G.mjs").then((n) => n.n);
389
390
  const repo = new ContentRepository(dbInstance);
@@ -425,6 +426,14 @@ function patternToRegex(pattern) {
425
426
  paramNames
426
427
  };
427
428
  }
429
+ let cachedUrlPatterns = null;
430
+ /**
431
+ * Invalidate the cached URL patterns used by resolveEmDashPath.
432
+ * Call when collection URL patterns change (schema updates).
433
+ */
434
+ function invalidateUrlPatternCache() {
435
+ cachedUrlPatterns = null;
436
+ }
428
437
  /**
429
438
  * Resolve a URL path to a content entry by matching against collection URL patterns.
430
439
  *
@@ -445,22 +454,32 @@ function patternToRegex(pattern) {
445
454
  * ```
446
455
  */
447
456
  async function resolveEmDashPath(path) {
448
- const { getDb } = await import("./loader-BmYdf3Dr.mjs").then((n) => n.r);
449
- const { SchemaRegistry } = await import("./registry-DU18yVo0.mjs").then((n) => n.r);
450
- const collections = await new SchemaRegistry(await getDb()).listCollections();
451
- for (const collection of collections) {
452
- if (!collection.urlPattern) continue;
453
- const { regex, paramNames } = patternToRegex(collection.urlPattern);
454
- const match = path.match(regex);
457
+ if (!cachedUrlPatterns) {
458
+ const { getDb } = await import("./loader-BYzwzORf.mjs").then((n) => n.r);
459
+ const { SchemaRegistry } = await import("./registry-BgnP3ysR.mjs").then((n) => n.r);
460
+ const collections = await new SchemaRegistry(await getDb()).listCollections();
461
+ cachedUrlPatterns = [];
462
+ for (const collection of collections) {
463
+ if (!collection.urlPattern) continue;
464
+ const { regex, paramNames } = patternToRegex(collection.urlPattern);
465
+ cachedUrlPatterns.push({
466
+ slug: collection.slug,
467
+ regex,
468
+ paramNames
469
+ });
470
+ }
471
+ }
472
+ for (const pattern of cachedUrlPatterns) {
473
+ const match = path.match(pattern.regex);
455
474
  if (!match) continue;
456
475
  const params = {};
457
- for (let i = 0; i < paramNames.length; i++) params[paramNames[i]] = match[i + 1];
476
+ for (let i = 0; i < pattern.paramNames.length; i++) params[pattern.paramNames[i]] = match[i + 1];
458
477
  const slug = params.slug;
459
478
  if (!slug) continue;
460
- const { entry } = await getEmDashEntry(collection.slug, slug);
479
+ const { entry } = await getEmDashEntry(pattern.slug, slug);
461
480
  if (entry) return {
462
481
  entry,
463
- collection: collection.slug,
482
+ collection: pattern.slug,
464
483
  params
465
484
  };
466
485
  }
@@ -468,5 +487,5 @@ async function resolveEmDashPath(path) {
468
487
  }
469
488
 
470
489
  //#endregion
471
- export { query_exports as a, createNoop as c, getTranslations as i, getEmDashCollection as n, resolveEmDashPath as o, getEmDashEntry as r, createEditable as s, getEditMeta as t };
472
- //# sourceMappingURL=query-sesiOndV.mjs.map
490
+ export { invalidateUrlPatternCache as a, createEditable as c, getTranslations as i, createNoop as l, getEmDashCollection as n, query_exports as o, getEmDashEntry as r, resolveEmDashPath as s, getEditMeta as t };
491
+ //# sourceMappingURL=query-B6Vu0d2i.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"query-sesiOndV.mjs","names":[],"sources":["../src/visual-editing/editable.ts","../src/query.ts"],"sourcesContent":["/**\n * Visual editing annotation system\n *\n * Creates Proxy objects that emit data-emdash-ref attributes when spread onto elements.\n */\n\nexport interface CMSAnnotation {\n\tcollection: string;\n\tid: string;\n\tfield?: string;\n\t/** Entry status — only present on entry-level annotations (not field-level) */\n\tstatus?: string;\n\t/** Whether the entry has unpublished draft changes */\n\thasDraft?: boolean;\n}\n\n/** The shape returned when spreading an edit annotation onto an element */\nexport interface FieldAnnotation {\n\t\"data-emdash-ref\": string;\n}\n\nexport interface EditableOptions {\n\t/** Entry status: \"draft\", \"published\", \"scheduled\" */\n\tstatus?: string;\n\t/** true when draftRevisionId exists and differs from liveRevisionId */\n\thasDraft?: boolean;\n}\n\n/**\n * Create an editable proxy for an entry.\n *\n * Usage:\n * - `{...entry.edit}` - entry-level annotation (includes status/hasDraft)\n * - `{...entry.edit.title}` - field-level annotation\n * - `{...entry.edit['nested.field']}` - nested field (bracket notation)\n */\nexport function createEditable(\n\tcollection: string,\n\tid: string,\n\toptions?: EditableOptions,\n): EditProxy {\n\tconst base: CMSAnnotation = {\n\t\tcollection,\n\t\tid,\n\t\t...(options?.status && { status: options.status }),\n\t\t...(options?.hasDraft && { hasDraft: true }),\n\t};\n\n\treturn new Proxy({} as EditProxy, {\n\t\tget(_, prop) {\n\t\t\tif (prop === \"toJSON\") return () => ({ \"data-emdash-ref\": JSON.stringify(base) });\n\t\t\tif (typeof prop === \"symbol\") return undefined;\n\n\t\t\t// data-emdash-ref access returns the entry-level string\n\t\t\tif (prop === \"data-emdash-ref\") return JSON.stringify(base);\n\n\t\t\t// Field-level: return a FieldAnnotation for the specific field\n\t\t\treturn {\n\t\t\t\t\"data-emdash-ref\": JSON.stringify({ ...base, field: String(prop) }),\n\t\t\t} satisfies FieldAnnotation;\n\t\t},\n\t\townKeys() {\n\t\t\treturn [\"data-emdash-ref\"];\n\t\t},\n\t\tgetOwnPropertyDescriptor(_, prop) {\n\t\t\tif (prop === \"data-emdash-ref\") {\n\t\t\t\treturn {\n\t\t\t\t\tconfigurable: true,\n\t\t\t\t\tenumerable: true,\n\t\t\t\t\tvalue: JSON.stringify(base),\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn undefined;\n\t\t},\n\t});\n}\n\n/**\n * Create a noop proxy for production mode.\n * Spreading this produces no attributes.\n */\nexport function createNoop(): EditProxy {\n\treturn new Proxy({} as EditProxy, {\n\t\tget(_, prop) {\n\t\t\tif (typeof prop === \"symbol\") return undefined;\n\t\t\t// All property access returns undefined in noop mode\n\t\t\treturn undefined;\n\t\t},\n\t\townKeys() {\n\t\t\treturn [];\n\t\t},\n\t\tgetOwnPropertyDescriptor() {\n\t\t\treturn undefined;\n\t\t},\n\t});\n}\n\n/**\n * Visual editing proxy type.\n *\n * Spread directly onto elements for entry-level annotations: `{...entry.edit}`\n * Access a field for field-level annotations: `{...entry.edit.title}`\n *\n * In production, spreading produces no attributes (noop).\n */\nexport type EditProxy = {\n\treadonly [field: string]: Partial<FieldAnnotation>;\n};\n","/**\n * Query functions for EmDash content\n *\n * These wrap Astro's getLiveCollection/getLiveEntry with type filtering.\n * Use these instead of calling Astro's functions directly.\n *\n * Error handling follows Astro's pattern - returns { entries/entry, error }\n * so callers can gracefully handle errors (including 404s).\n *\n * Preview mode is handled implicitly via ALS request context —\n * no parameters needed. The middleware verifies the preview token\n * and sets the context; query functions read it automatically.\n */\n\nimport { getFallbackChain, getI18nConfig, isI18nEnabled } from \"./i18n/config.js\";\nimport { getRequestContext } from \"./request-context.js\";\nimport {\n\tcreateEditable,\n\tcreateNoop,\n\ttype EditProxy,\n\ttype EditableOptions,\n} from \"./visual-editing/editable.js\";\n\n/**\n * Collection type registry for type-safe queries.\n *\n * This interface is extended by the generated emdash-env.d.ts file\n * to provide type inference for collection names and their data shapes.\n *\n * @example\n * ```ts\n * // In emdash-env.d.ts (generated):\n * declare module \"emdash\" {\n * interface EmDashCollections {\n * posts: { title: string; content: PortableTextBlock[]; };\n * pages: { title: string; body: PortableTextBlock[]; };\n * }\n * }\n *\n * // Then in your code:\n * const { entries } = await getEmDashCollection(\"posts\");\n * // entries[0].data.title is typed as string\n * ```\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface EmDashCollections {}\n\n/**\n * Helper type to infer the data type for a collection.\n * Returns the registered type if known, otherwise falls back to Record<string, unknown>.\n */\nexport type InferCollectionData<T extends string> = T extends keyof EmDashCollections\n\t? EmDashCollections[T]\n\t: Record<string, unknown>;\n\n/**\n * Sort direction\n */\nexport type SortDirection = \"asc\" | \"desc\";\n\n/**\n * Order by specification - field name to direction\n * @example { created_at: \"desc\" } - Sort by created_at descending\n * @example { title: \"asc\" } - Sort by title ascending\n * @example { published_at: \"desc\", title: \"asc\" } - Multi-field sort\n */\nexport type OrderBySpec = Record<string, SortDirection>;\n\nexport interface CollectionFilter {\n\tstatus?: \"draft\" | \"published\" | \"archived\";\n\tlimit?: number;\n\t/**\n\t * Opaque cursor for keyset pagination.\n\t * Pass the `nextCursor` value from a previous result to fetch the next page.\n\t * @example\n\t * ```ts\n\t * const cursor = Astro.url.searchParams.get(\"cursor\") ?? undefined;\n\t * const { entries, nextCursor } = await getEmDashCollection(\"posts\", {\n\t * limit: 10,\n\t * cursor,\n\t * });\n\t * ```\n\t */\n\tcursor?: string;\n\t/**\n\t * Filter by field values or taxonomy terms\n\t * @example { category: 'news' } - Filter by taxonomy term\n\t * @example { category: ['news', 'featured'] } - Filter by multiple terms (OR)\n\t */\n\twhere?: Record<string, string | string[]>;\n\t/**\n\t * Order results by field(s)\n\t * @default { created_at: \"desc\" }\n\t * @example { created_at: \"desc\" } - Sort by created_at descending (default)\n\t * @example { title: \"asc\" } - Sort by title ascending\n\t * @example { published_at: \"desc\", title: \"asc\" } - Multi-field sort\n\t */\n\torderBy?: OrderBySpec;\n\t/**\n\t * Filter by locale. When set, only returns entries in this locale.\n\t * Only relevant when i18n is configured.\n\t * @example \"en\" — English entries only\n\t * @example \"fr\" — French entries only\n\t */\n\tlocale?: string;\n}\n\nexport interface ContentEntry<T = Record<string, unknown>> {\n\tid: string;\n\tdata: T;\n\t/** Visual editing annotations. Spread onto elements: {...entry.edit.title} */\n\tedit: EditProxy;\n}\n\n/** Cache hint returned by the content loader for route caching */\nexport interface CacheHint {\n\ttags?: string[];\n\tlastModified?: Date;\n}\n\n/**\n * Result from getEmDashCollection\n */\nexport interface CollectionResult<T> {\n\t/** The entries (empty array if error or none found) */\n\tentries: ContentEntry<T>[];\n\t/** Error if the query failed */\n\terror?: Error;\n\t/** Cache hint for route caching (pass to Astro.cache.set()) */\n\tcacheHint: CacheHint;\n\t/**\n\t * Opaque cursor for the next page.\n\t * Undefined when there are no more results.\n\t * Pass this as `cursor` in the next query to get the next page.\n\t */\n\tnextCursor?: string;\n}\n\n/**\n * Result from getEmDashEntry\n */\nexport interface EntryResult<T> {\n\t/** The entry, or null if not found */\n\tentry: ContentEntry<T> | null;\n\t/** Error if the query failed (not set for \"not found\", only for actual errors) */\n\terror?: Error;\n\t/** Whether we're in preview mode (valid token was provided) */\n\tisPreview: boolean;\n\t/** Set when a fallback locale was used instead of the requested locale */\n\tfallbackLocale?: string;\n\t/** Cache hint for route caching (pass to Astro.cache.set()) */\n\tcacheHint: CacheHint;\n}\n\nconst COLLECTION_NAME = \"_emdash\";\n\n/** Symbol key for edit metadata on PT arrays — avoids collision with user data */\nconst EMDASH_EDIT = Symbol.for(\"__emdash\");\n\n/** Edit metadata attached to PT arrays in edit mode */\nexport interface EditFieldMeta {\n\tcollection: string;\n\tid: string;\n\tfield: string;\n}\n\n/** Type guard for EditFieldMeta */\nfunction isEditFieldMeta(value: unknown): value is EditFieldMeta {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tif (!(\"collection\" in value) || !(\"id\" in value) || !(\"field\" in value)) return false;\n\t// After `in` checks, TS narrows to Record<\"collection\" | \"id\" | \"field\", unknown>\n\tconst { collection, id, field } = value;\n\treturn typeof collection === \"string\" && typeof id === \"string\" && typeof field === \"string\";\n}\n\n/**\n * Read edit metadata from a value (returns undefined if not tagged).\n * Uses Object.getOwnPropertyDescriptor to access Symbol-keyed property\n * without an unsafe type assertion.\n */\nexport function getEditMeta(value: unknown): EditFieldMeta | undefined {\n\tif (value && typeof value === \"object\") {\n\t\tconst desc = Object.getOwnPropertyDescriptor(value, EMDASH_EDIT);\n\t\tconst meta: unknown = desc?.value;\n\t\tif (isEditFieldMeta(meta)) {\n\t\t\treturn meta;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Tag PT-like arrays in entry data with edit metadata (non-enumerable).\n * A PT array is identified by: is an array, first element has _type property.\n */\nfunction tagEditableFields(data: Record<string, unknown>, collection: string, id: string): void {\n\tfor (const [field, value] of Object.entries(data)) {\n\t\tif (\n\t\t\tArray.isArray(value) &&\n\t\t\tvalue.length > 0 &&\n\t\t\tvalue[0] &&\n\t\t\ttypeof value[0] === \"object\" &&\n\t\t\t\"_type\" in value[0]\n\t\t) {\n\t\t\tObject.defineProperty(value, EMDASH_EDIT, {\n\t\t\t\tvalue: { collection, id, field } satisfies EditFieldMeta,\n\t\t\t\tenumerable: false,\n\t\t\t\tconfigurable: true,\n\t\t\t});\n\t\t}\n\t}\n}\n\n/** Safely read a string field from a Record, with optional fallback */\nfunction dataStr(data: Record<string, unknown>, key: string, fallback = \"\"): string {\n\tconst val = data[key];\n\treturn typeof val === \"string\" ? val : fallback;\n}\n\n/** Type guard for Record<string, unknown> */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** Extract data as Record from an Astro entry (which is any-typed) */\nfunction entryData(entry: { data?: unknown }): Record<string, unknown> {\n\treturn isRecord(entry.data) ? entry.data : {};\n}\n\n/** Extract the database ID from entry data (data.id is the ULID, entry.id is the slug) */\nfunction entryDatabaseId(entry: { id: string; data?: unknown }): string {\n\tconst d = entryData(entry);\n\treturn dataStr(d, \"id\") || entry.id;\n}\n\n/** Extract edit options from entry data for the proxy */\nfunction entryEditOptions(entry: { data?: unknown }): EditableOptions {\n\tconst data = entryData(entry);\n\tconst status = dataStr(data, \"status\", \"draft\");\n\tconst draftRevisionId = dataStr(data, \"draftRevisionId\") || undefined;\n\tconst liveRevisionId = dataStr(data, \"liveRevisionId\") || undefined;\n\tconst hasDraft = !!draftRevisionId && draftRevisionId !== liveRevisionId;\n\treturn { status, hasDraft };\n}\n\n/**\n * Get all entries of a content type\n *\n * Returns { entries, error } for graceful error handling.\n *\n * When emdash-env.d.ts is generated, the collection name will be\n * type-checked and the return type will be inferred automatically.\n *\n * @example\n * ```ts\n * import { getEmDashCollection } from \"emdash\";\n *\n * const { entries: posts, error } = await getEmDashCollection(\"posts\");\n * if (error) {\n * console.error(\"Failed to load posts:\", error);\n * return;\n * }\n * // posts[0].data.title is typed (if emdash-env.d.ts exists)\n *\n * // With filters\n * const { entries: drafts } = await getEmDashCollection(\"posts\", { status: \"draft\" });\n * ```\n */\nexport async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(\n\ttype: T,\n\tfilter?: CollectionFilter,\n): Promise<CollectionResult<D>> {\n\t// Dynamic import to avoid build-time issues\n\tconst { getLiveCollection } = await import(\"astro:content\");\n\n\t// Resolve locale: explicit filter > ALS context > defaultLocale (when i18n enabled)\n\t// Without this, queries return all locale rows, producing broken IDs\n\tconst ctx = getRequestContext();\n\tconst i18nConfig = getI18nConfig();\n\tconst resolvedLocale =\n\t\tfilter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined);\n\n\tconst result = await getLiveCollection(COLLECTION_NAME, {\n\t\ttype,\n\t\tstatus: filter?.status,\n\t\tlimit: filter?.limit,\n\t\tcursor: filter?.cursor,\n\t\twhere: filter?.where,\n\t\torderBy: filter?.orderBy,\n\t\tlocale: resolvedLocale,\n\t});\n\n\tconst { entries, error, cacheHint } = result;\n\t// nextCursor is returned by the emdash loader but not part of Astro's base\n\t// LiveLoader return type. Extract it safely via property descriptor to avoid\n\t// an unsafe type assertion on the `any`-typed result object.\n\tconst rawCursor = Object.getOwnPropertyDescriptor(result, \"nextCursor\")?.value;\n\tconst nextCursor: string | undefined = typeof rawCursor === \"string\" ? rawCursor : undefined;\n\n\tif (error) {\n\t\treturn { entries: [], error, cacheHint: {} };\n\t}\n\n\tconst isEditMode = ctx?.editMode ?? false;\n\tconst entriesWithEdit = entries.map((entry: ContentEntry<D>) => {\n\t\tconst dbId = entryDatabaseId(entry);\n\t\tif (isEditMode) {\n\t\t\ttagEditableFields(entryData(entry), type, dbId);\n\t\t}\n\t\treturn {\n\t\t\t...entry,\n\t\t\tedit: isEditMode ? createEditable(type, dbId, entryEditOptions(entry)) : createNoop(),\n\t\t};\n\t});\n\n\t// Eagerly hydrate bylines for all entries\n\tawait hydrateEntryBylines(type, entriesWithEdit);\n\n\treturn { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };\n}\n\n/**\n * Get a single entry by type and ID/slug\n *\n * Returns { entry, error, isPreview } for graceful error handling.\n * - entry is null if not found (not an error)\n * - error is set only for actual errors (db issues, etc.)\n *\n * Preview mode is detected automatically from request context (ALS).\n * When the URL has a valid `_preview` token, the middleware sets preview\n * context and this function serves draft revision data if available.\n *\n * @example\n * ```ts\n * import { getEmDashEntry } from \"emdash\";\n *\n * // Simple usage — preview just works via middleware\n * const { entry: post, isPreview, error } = await getEmDashEntry(\"posts\", \"my-slug\");\n * if (!post) return Astro.redirect(\"/404\");\n * ```\n */\nexport async function getEmDashEntry<T extends string, D = InferCollectionData<T>>(\n\ttype: T,\n\tid: string,\n\toptions?: { locale?: string },\n): Promise<EntryResult<D>> {\n\t// Dynamic import to avoid build-time issues\n\tconst { getLiveEntry } = await import(\"astro:content\");\n\n\t// Check ALS for preview and edit mode context\n\tconst ctx = getRequestContext();\n\tconst preview = ctx?.preview;\n\tconst isEditMode = ctx?.editMode ?? false;\n\tconst isPreviewMode = !!preview && preview.collection === type;\n\t// Edit mode implies preview — editors should see draft content\n\tconst serveDrafts = isPreviewMode || isEditMode;\n\n\t// Resolve locale: explicit option > ALS context > undefined (no filter)\n\tconst requestedLocale = options?.locale ?? ctx?.locale;\n\n\t/** Wrap a raw Astro entry with edit proxy, tagging editable fields if needed */\n\tfunction wrapEntry(raw: ContentEntry<D>): ContentEntry<D> {\n\t\tconst dbId = entryDatabaseId(raw);\n\t\tif (isEditMode) {\n\t\t\ttagEditableFields(entryData(raw), type, dbId);\n\t\t}\n\t\treturn {\n\t\t\t...raw,\n\t\t\tedit: isEditMode ? createEditable(type, dbId, entryEditOptions(raw)) : createNoop(),\n\t\t};\n\t}\n\n\t/** Check if an entry is publicly visible (published or scheduled past its time) */\n\tfunction isVisible(entry: ContentEntry<D>): boolean {\n\t\tconst data = entryData(entry);\n\t\tconst status = dataStr(data, \"status\");\n\t\tconst scheduledAt = dataStr(data, \"scheduledAt\") || undefined;\n\t\tconst isPublished = status === \"published\";\n\t\tconst isScheduledAndReady =\n\t\t\tstatus === \"scheduled\" && scheduledAt && new Date(scheduledAt) <= new Date();\n\t\treturn isPublished || !!isScheduledAndReady;\n\t}\n\n\t// Build the fallback chain: [requestedLocale, fallback1, ..., defaultLocale]\n\t// When i18n is disabled or no locale requested, just use a single-element chain\n\tconst localeChain =\n\t\trequestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];\n\n\t/** Return a successful EntryResult with bylines hydrated */\n\tasync function successResult(\n\t\twrapped: ContentEntry<D>,\n\t\topts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },\n\t): Promise<EntryResult<D>> {\n\t\tawait hydrateEntryBylines(type, [wrapped]);\n\t\treturn {\n\t\t\tentry: wrapped,\n\t\t\tisPreview: opts.isPreview,\n\t\t\tfallbackLocale: opts.fallbackLocale,\n\t\t\tcacheHint: opts.cacheHint,\n\t\t};\n\t}\n\n\tif (serveDrafts) {\n\t\t// Draft mode: try each locale in the fallback chain\n\t\tfor (let i = 0; i < localeChain.length; i++) {\n\t\t\tconst locale = localeChain[i];\n\t\t\tconst fallbackLocale = i > 0 ? locale : undefined;\n\n\t\t\tconst {\n\t\t\t\tentry: baseEntry,\n\t\t\t\terror: baseError,\n\t\t\t\tcacheHint,\n\t\t\t} = await getLiveEntry(COLLECTION_NAME, {\n\t\t\t\ttype,\n\t\t\t\tid,\n\t\t\t\tlocale,\n\t\t\t});\n\n\t\t\tif (baseError) {\n\t\t\t\treturn { entry: null, error: baseError, isPreview: serveDrafts, cacheHint: {} };\n\t\t\t}\n\n\t\t\tif (!baseEntry) continue; // Try next locale in chain\n\n\t\t\t// Preview tokens are item-scoped: verify the resolved entry matches.\n\t\t\t// Edit mode (authenticated editors) has collection-wide draft access.\n\t\t\tif (isPreviewMode && !isEditMode) {\n\t\t\t\tconst dbId = entryDatabaseId(baseEntry);\n\t\t\t\tif (preview!.id !== dbId && preview!.id !== id) {\n\t\t\t\t\t// Token doesn't match — serve only if publicly visible, without draft access\n\t\t\t\t\tif (isVisible(baseEntry)) {\n\t\t\t\t\t\treturn successResult(wrapEntry(baseEntry), {\n\t\t\t\t\t\t\tisPreview: false,\n\t\t\t\t\t\t\tfallbackLocale,\n\t\t\t\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\t// Not visible — try next locale in fallback chain\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check if entry has a draft revision — if so, re-fetch with revision data\n\t\t\tconst baseData = entryData(baseEntry);\n\t\t\tconst draftRevisionId = dataStr(baseData, \"draftRevisionId\") || undefined;\n\n\t\t\tif (draftRevisionId) {\n\t\t\t\tconst { entry: draftEntry, error: draftError } = await getLiveEntry(COLLECTION_NAME, {\n\t\t\t\t\ttype,\n\t\t\t\t\tid,\n\t\t\t\t\trevisionId: draftRevisionId,\n\t\t\t\t\tlocale,\n\t\t\t\t});\n\n\t\t\t\tif (!draftError && draftEntry) {\n\t\t\t\t\treturn successResult(wrapEntry(draftEntry), {\n\t\t\t\t\t\tisPreview: serveDrafts,\n\t\t\t\t\t\tfallbackLocale,\n\t\t\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn successResult(wrapEntry(baseEntry), {\n\t\t\t\tisPreview: serveDrafts,\n\t\t\t\tfallbackLocale,\n\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t});\n\t\t}\n\n\t\t// No entry found in any locale\n\t\treturn { entry: null, isPreview: serveDrafts, cacheHint: {} };\n\t}\n\n\t// Normal mode: try each locale in the fallback chain, only return published content\n\tfor (let i = 0; i < localeChain.length; i++) {\n\t\tconst locale = localeChain[i];\n\t\tconst fallbackLocale = i > 0 ? locale : undefined;\n\n\t\tconst { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale });\n\t\tif (error) {\n\t\t\treturn { entry: null, error, isPreview: false, cacheHint: {} };\n\t\t}\n\n\t\tif (entry && isVisible(entry)) {\n\t\t\treturn successResult(wrapEntry(entry), {\n\t\t\t\tisPreview: false,\n\t\t\t\tfallbackLocale,\n\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t});\n\t\t}\n\t\t// Entry not found or not visible in this locale — try next\n\t}\n\n\treturn { entry: null, isPreview: false, cacheHint: {} };\n}\n\n/**\n * Eagerly hydrate byline data onto entry.data for one or more entries.\n *\n * Attaches `bylines` (array of ContentBylineCredit) and `byline`\n * (primary BylineSummary or null) to each entry's data object.\n * Uses batch queries to avoid N+1.\n *\n * Fails silently if the byline tables don't exist yet (pre-migration).\n */\nasync function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {\n\tif (entries.length === 0) return;\n\n\ttry {\n\t\tconst { getBylinesForEntries } = await import(\"./bylines/index.js\");\n\n\t\tconst ids = entries.map((e) => dataStr(entryData(e), \"id\")).filter(Boolean);\n\t\tif (ids.length === 0) return;\n\n\t\tconst bylinesMap = await getBylinesForEntries(type, ids);\n\n\t\tfor (const entry of entries) {\n\t\t\tconst data = entryData(entry);\n\t\t\tconst dbId = dataStr(data, \"id\");\n\t\t\tif (!dbId) continue;\n\n\t\t\tconst credits = bylinesMap.get(dbId) ?? [];\n\t\t\tdata.bylines = credits;\n\t\t\tdata.byline = credits[0]?.byline ?? null;\n\t\t}\n\t} catch (err) {\n\t\t// Only swallow \"table not found\" errors from pre-migration databases\n\t\tconst msg = err instanceof Error ? err.message : \"\";\n\t\tif (!msg.includes(\"no such table\")) {\n\t\t\tconsole.warn(\"[emdash] Failed to hydrate bylines:\", msg);\n\t\t}\n\t}\n}\n\n/**\n * Translation summary for a single locale variant\n */\nexport interface TranslationSummary {\n\t/** Content item ID */\n\tid: string;\n\t/** Locale code (e.g. \"en\", \"fr\") */\n\tlocale: string;\n\t/** URL slug */\n\tslug: string | null;\n\t/** Current status */\n\tstatus: string;\n}\n\n/**\n * Result from getTranslations\n */\nexport interface TranslationsResult {\n\t/** The translation group ID (shared across locales) */\n\ttranslationGroup: string;\n\t/** All locale variants in this group */\n\ttranslations: TranslationSummary[];\n\t/** Error if the query failed */\n\terror?: Error;\n}\n\n/**\n * Get all translations of a content item.\n *\n * Given a content entry, returns all locale variants that share the same\n * translation group. This is useful for building language switcher UI.\n *\n * @example\n * ```ts\n * import { getEmDashEntry, getTranslations } from \"emdash\";\n *\n * const { entry: post } = await getEmDashEntry(\"posts\", \"hello-world\", { locale: \"en\" });\n * const { translations } = await getTranslations(\"posts\", post.data.id);\n * // translations = [{ id: \"...\", locale: \"en\", slug: \"hello-world\", status: \"published\" }, ...]\n * ```\n */\nexport async function getTranslations(type: string, id: string): Promise<TranslationsResult> {\n\ttry {\n\t\tconst db = (await import(\"./loader.js\")).getDb;\n\t\tconst dbInstance = await db();\n\t\tconst { ContentRepository } = await import(\"./database/repositories/content.js\");\n\t\tconst repo = new ContentRepository(dbInstance);\n\n\t\t// Find the item to get its translation group\n\t\tconst item = await repo.findByIdOrSlug(type, id);\n\t\tif (!item) {\n\t\t\treturn {\n\t\t\t\ttranslationGroup: \"\",\n\t\t\t\ttranslations: [],\n\t\t\t\terror: new Error(`Content item not found: ${id}`),\n\t\t\t};\n\t\t}\n\n\t\tconst group = item.translationGroup || item.id;\n\t\tconst translations = await repo.findTranslations(type, group);\n\n\t\treturn {\n\t\t\ttranslationGroup: group,\n\t\t\ttranslations: translations.map((t) => ({\n\t\t\t\tid: t.id,\n\t\t\t\tlocale: t.locale || \"en\",\n\t\t\t\tslug: t.slug,\n\t\t\t\tstatus: t.status,\n\t\t\t})),\n\t\t};\n\t} catch (error) {\n\t\treturn {\n\t\t\ttranslationGroup: \"\",\n\t\t\ttranslations: [],\n\t\t\terror: error instanceof Error ? error : new Error(String(error)),\n\t\t};\n\t}\n}\n\n/**\n * Result from resolveEmDashPath\n */\nexport interface ResolvePathResult<T = Record<string, unknown>> {\n\t/** The matched entry */\n\tentry: ContentEntry<T>;\n\t/** The collection slug that matched */\n\tcollection: string;\n\t/** Extracted parameters from the URL pattern (e.g. { slug: \"my-post\" }) */\n\tparams: Record<string, string>;\n}\n\n/** Matches `{paramName}` placeholders in URL patterns */\nconst URL_PARAM_PATTERN = /\\{(\\w+)\\}/g;\n\n/** Convert a URL pattern like \"/blog/{slug}\" to a regex and param name list */\nfunction patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {\n\tconst paramNames: string[] = [];\n\tconst regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => {\n\t\tparamNames.push(name);\n\t\treturn \"([^/]+)\";\n\t});\n\treturn { regex: new RegExp(`^${regexStr}$`), paramNames };\n}\n\n/**\n * Resolve a URL path to a content entry by matching against collection URL patterns.\n *\n * Loads all collections with a `urlPattern` set, converts each pattern to a regex,\n * and tests the given path. On match, extracts the slug and fetches the entry.\n *\n * @example\n * ```ts\n * import { resolveEmDashPath } from \"emdash\";\n *\n * // Given pages with urlPattern \"/{slug}\" and posts with \"/blog/{slug}\":\n * const result = await resolveEmDashPath(\"/blog/hello-world\");\n * if (result) {\n * console.log(result.collection); // \"posts\"\n * console.log(result.params.slug); // \"hello-world\"\n * console.log(result.entry.data); // post data\n * }\n * ```\n */\nexport async function resolveEmDashPath<T = Record<string, unknown>>(\n\tpath: string,\n): Promise<ResolvePathResult<T> | null> {\n\tconst { getDb } = await import(\"./loader.js\");\n\tconst { SchemaRegistry } = await import(\"./schema/registry.js\");\n\tconst db = await getDb();\n\tconst registry = new SchemaRegistry(db);\n\tconst collections = await registry.listCollections();\n\n\tfor (const collection of collections) {\n\t\tif (!collection.urlPattern) continue;\n\n\t\tconst { regex, paramNames } = patternToRegex(collection.urlPattern);\n\t\tconst match = path.match(regex);\n\t\tif (!match) continue;\n\n\t\t// Extract params\n\t\tconst params: Record<string, string> = {};\n\t\tfor (let i = 0; i < paramNames.length; i++) {\n\t\t\tparams[paramNames[i]] = match[i + 1];\n\t\t}\n\n\t\t// Look up entry by slug (most common pattern)\n\t\tconst slug = params.slug;\n\t\tif (!slug) continue;\n\n\t\tconst { entry } = await getEmDashEntry<string, T>(collection.slug, slug);\n\t\tif (entry) {\n\t\t\treturn { entry, collection: collection.slug, params };\n\t\t}\n\t}\n\n\treturn null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"query-B6Vu0d2i.mjs","names":[],"sources":["../src/visual-editing/editable.ts","../src/query.ts"],"sourcesContent":["/**\n * Visual editing annotation system\n *\n * Creates Proxy objects that emit data-emdash-ref attributes when spread onto elements.\n */\n\nexport interface CMSAnnotation {\n\tcollection: string;\n\tid: string;\n\tfield?: string;\n\t/** Entry status — only present on entry-level annotations (not field-level) */\n\tstatus?: string;\n\t/** Whether the entry has unpublished draft changes */\n\thasDraft?: boolean;\n}\n\n/** The shape returned when spreading an edit annotation onto an element */\nexport interface FieldAnnotation {\n\t\"data-emdash-ref\": string;\n}\n\nexport interface EditableOptions {\n\t/** Entry status: \"draft\", \"published\", \"scheduled\" */\n\tstatus?: string;\n\t/** true when draftRevisionId exists and differs from liveRevisionId */\n\thasDraft?: boolean;\n}\n\n/**\n * Create an editable proxy for an entry.\n *\n * Usage:\n * - `{...entry.edit}` - entry-level annotation (includes status/hasDraft)\n * - `{...entry.edit.title}` - field-level annotation\n * - `{...entry.edit['nested.field']}` - nested field (bracket notation)\n */\nexport function createEditable(\n\tcollection: string,\n\tid: string,\n\toptions?: EditableOptions,\n): EditProxy {\n\tconst base: CMSAnnotation = {\n\t\tcollection,\n\t\tid,\n\t\t...(options?.status && { status: options.status }),\n\t\t...(options?.hasDraft && { hasDraft: true }),\n\t};\n\n\treturn new Proxy({} as EditProxy, {\n\t\tget(_, prop) {\n\t\t\tif (prop === \"toJSON\") return () => ({ \"data-emdash-ref\": JSON.stringify(base) });\n\t\t\tif (typeof prop === \"symbol\") return undefined;\n\n\t\t\t// data-emdash-ref access returns the entry-level string\n\t\t\tif (prop === \"data-emdash-ref\") return JSON.stringify(base);\n\n\t\t\t// Field-level: return a FieldAnnotation for the specific field\n\t\t\treturn {\n\t\t\t\t\"data-emdash-ref\": JSON.stringify({ ...base, field: String(prop) }),\n\t\t\t} satisfies FieldAnnotation;\n\t\t},\n\t\townKeys() {\n\t\t\treturn [\"data-emdash-ref\"];\n\t\t},\n\t\tgetOwnPropertyDescriptor(_, prop) {\n\t\t\tif (prop === \"data-emdash-ref\") {\n\t\t\t\treturn {\n\t\t\t\t\tconfigurable: true,\n\t\t\t\t\tenumerable: true,\n\t\t\t\t\tvalue: JSON.stringify(base),\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn undefined;\n\t\t},\n\t});\n}\n\n/**\n * Create a noop proxy for production mode.\n * Spreading this produces no attributes.\n */\nexport function createNoop(): EditProxy {\n\treturn new Proxy({} as EditProxy, {\n\t\tget(_, prop) {\n\t\t\tif (typeof prop === \"symbol\") return undefined;\n\t\t\t// All property access returns undefined in noop mode\n\t\t\treturn undefined;\n\t\t},\n\t\townKeys() {\n\t\t\treturn [];\n\t\t},\n\t\tgetOwnPropertyDescriptor() {\n\t\t\treturn undefined;\n\t\t},\n\t});\n}\n\n/**\n * Visual editing proxy type.\n *\n * Spread directly onto elements for entry-level annotations: `{...entry.edit}`\n * Access a field for field-level annotations: `{...entry.edit.title}`\n *\n * In production, spreading produces no attributes (noop).\n */\nexport type EditProxy = {\n\treadonly [field: string]: Partial<FieldAnnotation>;\n};\n","/**\n * Query functions for EmDash content\n *\n * These wrap Astro's getLiveCollection/getLiveEntry with type filtering.\n * Use these instead of calling Astro's functions directly.\n *\n * Error handling follows Astro's pattern - returns { entries/entry, error }\n * so callers can gracefully handle errors (including 404s).\n *\n * Preview mode is handled implicitly via ALS request context —\n * no parameters needed. The middleware verifies the preview token\n * and sets the context; query functions read it automatically.\n */\n\nimport { getFallbackChain, getI18nConfig, isI18nEnabled } from \"./i18n/config.js\";\nimport { getRequestContext } from \"./request-context.js\";\nimport {\n\tcreateEditable,\n\tcreateNoop,\n\ttype EditProxy,\n\ttype EditableOptions,\n} from \"./visual-editing/editable.js\";\n\n/**\n * Collection type registry for type-safe queries.\n *\n * This interface is extended by the generated emdash-env.d.ts file\n * to provide type inference for collection names and their data shapes.\n *\n * @example\n * ```ts\n * // In emdash-env.d.ts (generated):\n * declare module \"emdash\" {\n * interface EmDashCollections {\n * posts: { title: string; content: PortableTextBlock[]; };\n * pages: { title: string; body: PortableTextBlock[]; };\n * }\n * }\n *\n * // Then in your code:\n * const { entries } = await getEmDashCollection(\"posts\");\n * // entries[0].data.title is typed as string\n * ```\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface EmDashCollections {}\n\n/**\n * Helper type to infer the data type for a collection.\n * Returns the registered type if known, otherwise falls back to Record<string, unknown>.\n */\nexport type InferCollectionData<T extends string> = T extends keyof EmDashCollections\n\t? EmDashCollections[T]\n\t: Record<string, unknown>;\n\n/**\n * Sort direction\n */\nexport type SortDirection = \"asc\" | \"desc\";\n\n/**\n * Order by specification - field name to direction\n * @example { created_at: \"desc\" } - Sort by created_at descending\n * @example { title: \"asc\" } - Sort by title ascending\n * @example { published_at: \"desc\", title: \"asc\" } - Multi-field sort\n */\nexport type OrderBySpec = Record<string, SortDirection>;\n\nexport interface CollectionFilter {\n\tstatus?: \"draft\" | \"published\" | \"archived\";\n\tlimit?: number;\n\t/**\n\t * Opaque cursor for keyset pagination.\n\t * Pass the `nextCursor` value from a previous result to fetch the next page.\n\t * @example\n\t * ```ts\n\t * const cursor = Astro.url.searchParams.get(\"cursor\") ?? undefined;\n\t * const { entries, nextCursor } = await getEmDashCollection(\"posts\", {\n\t * limit: 10,\n\t * cursor,\n\t * });\n\t * ```\n\t */\n\tcursor?: string;\n\t/**\n\t * Filter by field values or taxonomy terms\n\t * @example { category: 'news' } - Filter by taxonomy term\n\t * @example { category: ['news', 'featured'] } - Filter by multiple terms (OR)\n\t */\n\twhere?: Record<string, string | string[]>;\n\t/**\n\t * Order results by field(s)\n\t * @default { created_at: \"desc\" }\n\t * @example { created_at: \"desc\" } - Sort by created_at descending (default)\n\t * @example { title: \"asc\" } - Sort by title ascending\n\t * @example { published_at: \"desc\", title: \"asc\" } - Multi-field sort\n\t */\n\torderBy?: OrderBySpec;\n\t/**\n\t * Filter by locale. When set, only returns entries in this locale.\n\t * Only relevant when i18n is configured.\n\t * @example \"en\" — English entries only\n\t * @example \"fr\" — French entries only\n\t */\n\tlocale?: string;\n}\n\nexport interface ContentEntry<T = Record<string, unknown>> {\n\tid: string;\n\tdata: T;\n\t/** Visual editing annotations. Spread onto elements: {...entry.edit.title} */\n\tedit: EditProxy;\n}\n\n/** Cache hint returned by the content loader for route caching */\nexport interface CacheHint {\n\ttags?: string[];\n\tlastModified?: Date;\n}\n\n/**\n * Result from getEmDashCollection\n */\nexport interface CollectionResult<T> {\n\t/** The entries (empty array if error or none found) */\n\tentries: ContentEntry<T>[];\n\t/** Error if the query failed */\n\terror?: Error;\n\t/** Cache hint for route caching (pass to Astro.cache.set()) */\n\tcacheHint: CacheHint;\n\t/**\n\t * Opaque cursor for the next page.\n\t * Undefined when there are no more results.\n\t * Pass this as `cursor` in the next query to get the next page.\n\t */\n\tnextCursor?: string;\n}\n\n/**\n * Result from getEmDashEntry\n */\nexport interface EntryResult<T> {\n\t/** The entry, or null if not found */\n\tentry: ContentEntry<T> | null;\n\t/** Error if the query failed (not set for \"not found\", only for actual errors) */\n\terror?: Error;\n\t/** Whether we're in preview mode (valid token was provided) */\n\tisPreview: boolean;\n\t/** Set when a fallback locale was used instead of the requested locale */\n\tfallbackLocale?: string;\n\t/** Cache hint for route caching (pass to Astro.cache.set()) */\n\tcacheHint: CacheHint;\n}\n\nconst COLLECTION_NAME = \"_emdash\";\n\n/** Symbol key for edit metadata on PT arrays — avoids collision with user data */\nconst EMDASH_EDIT = Symbol.for(\"__emdash\");\n\n/** Edit metadata attached to PT arrays in edit mode */\nexport interface EditFieldMeta {\n\tcollection: string;\n\tid: string;\n\tfield: string;\n}\n\n/** Type guard for EditFieldMeta */\nfunction isEditFieldMeta(value: unknown): value is EditFieldMeta {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tif (!(\"collection\" in value) || !(\"id\" in value) || !(\"field\" in value)) return false;\n\t// After `in` checks, TS narrows to Record<\"collection\" | \"id\" | \"field\", unknown>\n\tconst { collection, id, field } = value;\n\treturn typeof collection === \"string\" && typeof id === \"string\" && typeof field === \"string\";\n}\n\n/**\n * Read edit metadata from a value (returns undefined if not tagged).\n * Uses Object.getOwnPropertyDescriptor to access Symbol-keyed property\n * without an unsafe type assertion.\n */\nexport function getEditMeta(value: unknown): EditFieldMeta | undefined {\n\tif (value && typeof value === \"object\") {\n\t\tconst desc = Object.getOwnPropertyDescriptor(value, EMDASH_EDIT);\n\t\tconst meta: unknown = desc?.value;\n\t\tif (isEditFieldMeta(meta)) {\n\t\t\treturn meta;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Tag PT-like arrays in entry data with edit metadata (non-enumerable).\n * A PT array is identified by: is an array, first element has _type property.\n */\nfunction tagEditableFields(data: Record<string, unknown>, collection: string, id: string): void {\n\tfor (const [field, value] of Object.entries(data)) {\n\t\tif (\n\t\t\tArray.isArray(value) &&\n\t\t\tvalue.length > 0 &&\n\t\t\tvalue[0] &&\n\t\t\ttypeof value[0] === \"object\" &&\n\t\t\t\"_type\" in value[0]\n\t\t) {\n\t\t\tObject.defineProperty(value, EMDASH_EDIT, {\n\t\t\t\tvalue: { collection, id, field } satisfies EditFieldMeta,\n\t\t\t\tenumerable: false,\n\t\t\t\tconfigurable: true,\n\t\t\t});\n\t\t}\n\t}\n}\n\n/** Safely read a string field from a Record, with optional fallback */\nfunction dataStr(data: Record<string, unknown>, key: string, fallback = \"\"): string {\n\tconst val = data[key];\n\treturn typeof val === \"string\" ? val : fallback;\n}\n\n/** Type guard for Record<string, unknown> */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** Extract data as Record from an Astro entry (which is any-typed) */\nfunction entryData(entry: { data?: unknown }): Record<string, unknown> {\n\treturn isRecord(entry.data) ? entry.data : {};\n}\n\n/** Extract the database ID from entry data (data.id is the ULID, entry.id is the slug) */\nfunction entryDatabaseId(entry: { id: string; data?: unknown }): string {\n\tconst d = entryData(entry);\n\treturn dataStr(d, \"id\") || entry.id;\n}\n\n/** Extract edit options from entry data for the proxy */\nfunction entryEditOptions(entry: { data?: unknown }): EditableOptions {\n\tconst data = entryData(entry);\n\tconst status = dataStr(data, \"status\", \"draft\");\n\tconst draftRevisionId = dataStr(data, \"draftRevisionId\") || undefined;\n\tconst liveRevisionId = dataStr(data, \"liveRevisionId\") || undefined;\n\tconst hasDraft = !!draftRevisionId && draftRevisionId !== liveRevisionId;\n\treturn { status, hasDraft };\n}\n\n/**\n * Get all entries of a content type\n *\n * Returns { entries, error } for graceful error handling.\n *\n * When emdash-env.d.ts is generated, the collection name will be\n * type-checked and the return type will be inferred automatically.\n *\n * @example\n * ```ts\n * import { getEmDashCollection } from \"emdash\";\n *\n * const { entries: posts, error } = await getEmDashCollection(\"posts\");\n * if (error) {\n * console.error(\"Failed to load posts:\", error);\n * return;\n * }\n * // posts[0].data.title is typed (if emdash-env.d.ts exists)\n *\n * // With filters\n * const { entries: drafts } = await getEmDashCollection(\"posts\", { status: \"draft\" });\n * ```\n */\nexport async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(\n\ttype: T,\n\tfilter?: CollectionFilter,\n): Promise<CollectionResult<D>> {\n\t// Dynamic import to avoid build-time issues\n\tconst { getLiveCollection } = await import(\"astro:content\");\n\n\t// Resolve locale: explicit filter > ALS context > defaultLocale (when i18n enabled)\n\t// Without this, queries return all locale rows, producing broken IDs\n\tconst ctx = getRequestContext();\n\tconst i18nConfig = getI18nConfig();\n\tconst resolvedLocale =\n\t\tfilter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined);\n\n\tconst result = await getLiveCollection(COLLECTION_NAME, {\n\t\ttype,\n\t\tstatus: filter?.status,\n\t\tlimit: filter?.limit,\n\t\tcursor: filter?.cursor,\n\t\twhere: filter?.where,\n\t\torderBy: filter?.orderBy,\n\t\tlocale: resolvedLocale,\n\t});\n\n\tconst { entries, error, cacheHint } = result;\n\t// nextCursor is returned by the emdash loader but not part of Astro's base\n\t// LiveLoader return type. Extract it safely via property descriptor to avoid\n\t// an unsafe type assertion on the `any`-typed result object.\n\tconst rawCursor = Object.getOwnPropertyDescriptor(result, \"nextCursor\")?.value;\n\tconst nextCursor: string | undefined = typeof rawCursor === \"string\" ? rawCursor : undefined;\n\n\tif (error) {\n\t\treturn { entries: [], error, cacheHint: {} };\n\t}\n\n\tconst isEditMode = ctx?.editMode ?? false;\n\tconst entriesWithEdit = entries.map((entry: ContentEntry<D>) => {\n\t\tconst dbId = entryDatabaseId(entry);\n\t\tif (isEditMode) {\n\t\t\ttagEditableFields(entryData(entry), type, dbId);\n\t\t}\n\t\treturn {\n\t\t\t...entry,\n\t\t\tedit: isEditMode ? createEditable(type, dbId, entryEditOptions(entry)) : createNoop(),\n\t\t};\n\t});\n\n\t// Eagerly hydrate bylines for all entries\n\tawait hydrateEntryBylines(type, entriesWithEdit);\n\n\treturn { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };\n}\n\n/**\n * Get a single entry by type and ID/slug\n *\n * Returns { entry, error, isPreview } for graceful error handling.\n * - entry is null if not found (not an error)\n * - error is set only for actual errors (db issues, etc.)\n *\n * Preview mode is detected automatically from request context (ALS).\n * When the URL has a valid `_preview` token, the middleware sets preview\n * context and this function serves draft revision data if available.\n *\n * @example\n * ```ts\n * import { getEmDashEntry } from \"emdash\";\n *\n * // Simple usage — preview just works via middleware\n * const { entry: post, isPreview, error } = await getEmDashEntry(\"posts\", \"my-slug\");\n * if (!post) return Astro.redirect(\"/404\");\n * ```\n */\nexport async function getEmDashEntry<T extends string, D = InferCollectionData<T>>(\n\ttype: T,\n\tid: string,\n\toptions?: { locale?: string },\n): Promise<EntryResult<D>> {\n\t// Dynamic import to avoid build-time issues\n\tconst { getLiveEntry } = await import(\"astro:content\");\n\n\t// Check ALS for preview and edit mode context\n\tconst ctx = getRequestContext();\n\tconst preview = ctx?.preview;\n\tconst isEditMode = ctx?.editMode ?? false;\n\tconst isPreviewMode = !!preview && preview.collection === type;\n\t// Edit mode implies preview — editors should see draft content\n\tconst serveDrafts = isPreviewMode || isEditMode;\n\n\t// Resolve locale: explicit option > ALS context > undefined (no filter)\n\tconst requestedLocale = options?.locale ?? ctx?.locale;\n\n\t/** Wrap a raw Astro entry with edit proxy, tagging editable fields if needed */\n\tfunction wrapEntry(raw: ContentEntry<D>): ContentEntry<D> {\n\t\tconst dbId = entryDatabaseId(raw);\n\t\tif (isEditMode) {\n\t\t\ttagEditableFields(entryData(raw), type, dbId);\n\t\t}\n\t\treturn {\n\t\t\t...raw,\n\t\t\tedit: isEditMode ? createEditable(type, dbId, entryEditOptions(raw)) : createNoop(),\n\t\t};\n\t}\n\n\t/** Check if an entry is publicly visible (published or scheduled past its time) */\n\tfunction isVisible(entry: ContentEntry<D>): boolean {\n\t\tconst data = entryData(entry);\n\t\tconst status = dataStr(data, \"status\");\n\t\tconst scheduledAt = dataStr(data, \"scheduledAt\") || undefined;\n\t\tconst isPublished = status === \"published\";\n\t\tconst isScheduledAndReady =\n\t\t\tstatus === \"scheduled\" && scheduledAt && new Date(scheduledAt) <= new Date();\n\t\treturn isPublished || !!isScheduledAndReady;\n\t}\n\n\t// Build the fallback chain: [requestedLocale, fallback1, ..., defaultLocale]\n\t// When i18n is disabled or no locale requested, just use a single-element chain\n\tconst localeChain =\n\t\trequestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];\n\n\t/** Return a successful EntryResult with bylines hydrated */\n\tasync function successResult(\n\t\twrapped: ContentEntry<D>,\n\t\topts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },\n\t): Promise<EntryResult<D>> {\n\t\tawait hydrateEntryBylines(type, [wrapped]);\n\t\treturn {\n\t\t\tentry: wrapped,\n\t\t\tisPreview: opts.isPreview,\n\t\t\tfallbackLocale: opts.fallbackLocale,\n\t\t\tcacheHint: opts.cacheHint,\n\t\t};\n\t}\n\n\tif (serveDrafts) {\n\t\t// Draft mode: try each locale in the fallback chain\n\t\tfor (let i = 0; i < localeChain.length; i++) {\n\t\t\tconst locale = localeChain[i];\n\t\t\tconst fallbackLocale = i > 0 ? locale : undefined;\n\n\t\t\tconst {\n\t\t\t\tentry: baseEntry,\n\t\t\t\terror: baseError,\n\t\t\t\tcacheHint,\n\t\t\t} = await getLiveEntry(COLLECTION_NAME, {\n\t\t\t\ttype,\n\t\t\t\tid,\n\t\t\t\tlocale,\n\t\t\t});\n\n\t\t\tif (baseError) {\n\t\t\t\treturn { entry: null, error: baseError, isPreview: serveDrafts, cacheHint: {} };\n\t\t\t}\n\n\t\t\tif (!baseEntry) continue; // Try next locale in chain\n\n\t\t\t// Preview tokens are item-scoped: verify the resolved entry matches.\n\t\t\t// Edit mode (authenticated editors) has collection-wide draft access.\n\t\t\tif (isPreviewMode && !isEditMode) {\n\t\t\t\tconst dbId = entryDatabaseId(baseEntry);\n\t\t\t\tif (preview!.id !== dbId && preview!.id !== id) {\n\t\t\t\t\t// Token doesn't match — serve only if publicly visible, without draft access\n\t\t\t\t\tif (isVisible(baseEntry)) {\n\t\t\t\t\t\treturn successResult(wrapEntry(baseEntry), {\n\t\t\t\t\t\t\tisPreview: false,\n\t\t\t\t\t\t\tfallbackLocale,\n\t\t\t\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\t// Not visible — try next locale in fallback chain\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check if entry has a draft revision — if so, re-fetch with revision data\n\t\t\tconst baseData = entryData(baseEntry);\n\t\t\tconst draftRevisionId = dataStr(baseData, \"draftRevisionId\") || undefined;\n\n\t\t\tif (draftRevisionId) {\n\t\t\t\tconst { entry: draftEntry, error: draftError } = await getLiveEntry(COLLECTION_NAME, {\n\t\t\t\t\ttype,\n\t\t\t\t\tid,\n\t\t\t\t\trevisionId: draftRevisionId,\n\t\t\t\t\tlocale,\n\t\t\t\t});\n\n\t\t\t\tif (!draftError && draftEntry) {\n\t\t\t\t\treturn successResult(wrapEntry(draftEntry), {\n\t\t\t\t\t\tisPreview: serveDrafts,\n\t\t\t\t\t\tfallbackLocale,\n\t\t\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn successResult(wrapEntry(baseEntry), {\n\t\t\t\tisPreview: serveDrafts,\n\t\t\t\tfallbackLocale,\n\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t});\n\t\t}\n\n\t\t// No entry found in any locale\n\t\treturn { entry: null, isPreview: serveDrafts, cacheHint: {} };\n\t}\n\n\t// Normal mode: try each locale in the fallback chain, only return published content\n\tfor (let i = 0; i < localeChain.length; i++) {\n\t\tconst locale = localeChain[i];\n\t\tconst fallbackLocale = i > 0 ? locale : undefined;\n\n\t\tconst { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale });\n\t\tif (error) {\n\t\t\treturn { entry: null, error, isPreview: false, cacheHint: {} };\n\t\t}\n\n\t\tif (entry && isVisible(entry)) {\n\t\t\treturn successResult(wrapEntry(entry), {\n\t\t\t\tisPreview: false,\n\t\t\t\tfallbackLocale,\n\t\t\t\tcacheHint: cacheHint ?? {},\n\t\t\t});\n\t\t}\n\t\t// Entry not found or not visible in this locale — try next\n\t}\n\n\treturn { entry: null, isPreview: false, cacheHint: {} };\n}\n\n/**\n * Eagerly hydrate byline data onto entry.data for one or more entries.\n *\n * Attaches `bylines` (array of ContentBylineCredit) and `byline`\n * (primary BylineSummary or null) to each entry's data object.\n * Uses batch queries to avoid N+1.\n *\n * Fails silently if the byline tables don't exist yet (pre-migration).\n */\nasync function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {\n\tif (entries.length === 0) return;\n\n\ttry {\n\t\tconst { getBylinesForEntries } = await import(\"./bylines/index.js\");\n\n\t\tconst ids = entries.map((e) => dataStr(entryData(e), \"id\")).filter(Boolean);\n\t\tif (ids.length === 0) return;\n\n\t\tconst bylinesMap = await getBylinesForEntries(type, ids);\n\n\t\tfor (const entry of entries) {\n\t\t\tconst data = entryData(entry);\n\t\t\tconst dbId = dataStr(data, \"id\");\n\t\t\tif (!dbId) continue;\n\n\t\t\tconst credits = bylinesMap.get(dbId) ?? [];\n\t\t\tdata.bylines = credits;\n\t\t\tdata.byline = credits[0]?.byline ?? null;\n\t\t}\n\t} catch (err) {\n\t\t// Only swallow \"table not found\" errors from pre-migration databases\n\t\tconst msg = err instanceof Error ? err.message : \"\";\n\t\tif (!msg.includes(\"no such table\")) {\n\t\t\tconsole.warn(\"[emdash] Failed to hydrate bylines:\", msg);\n\t\t}\n\t}\n}\n\n/**\n * Translation summary for a single locale variant\n */\nexport interface TranslationSummary {\n\t/** Content item ID */\n\tid: string;\n\t/** Locale code (e.g. \"en\", \"fr\") */\n\tlocale: string;\n\t/** URL slug */\n\tslug: string | null;\n\t/** Current status */\n\tstatus: string;\n}\n\n/**\n * Result from getTranslations\n */\nexport interface TranslationsResult {\n\t/** The translation group ID (shared across locales) */\n\ttranslationGroup: string;\n\t/** All locale variants in this group */\n\ttranslations: TranslationSummary[];\n\t/** Error if the query failed */\n\terror?: Error;\n}\n\n/**\n * Get all translations of a content item.\n *\n * Given a content entry, returns all locale variants that share the same\n * translation group. This is useful for building language switcher UI.\n *\n * @example\n * ```ts\n * import { getEmDashEntry, getTranslations } from \"emdash\";\n *\n * const { entry: post } = await getEmDashEntry(\"posts\", \"hello-world\", { locale: \"en\" });\n * const { translations } = await getTranslations(\"posts\", post.data.id);\n * // translations = [{ id: \"...\", locale: \"en\", slug: \"hello-world\", status: \"published\" }, ...]\n * ```\n */\nexport async function getTranslations(type: string, id: string): Promise<TranslationsResult> {\n\ttry {\n\t\tconst db = (await import(\"./loader.js\")).getDb;\n\t\tconst dbInstance = await db();\n\t\tconst { ContentRepository } = await import(\"./database/repositories/content.js\");\n\t\tconst repo = new ContentRepository(dbInstance);\n\n\t\t// Find the item to get its translation group\n\t\tconst item = await repo.findByIdOrSlug(type, id);\n\t\tif (!item) {\n\t\t\treturn {\n\t\t\t\ttranslationGroup: \"\",\n\t\t\t\ttranslations: [],\n\t\t\t\terror: new Error(`Content item not found: ${id}`),\n\t\t\t};\n\t\t}\n\n\t\tconst group = item.translationGroup || item.id;\n\t\tconst translations = await repo.findTranslations(type, group);\n\n\t\treturn {\n\t\t\ttranslationGroup: group,\n\t\t\ttranslations: translations.map((t) => ({\n\t\t\t\tid: t.id,\n\t\t\t\tlocale: t.locale || \"en\",\n\t\t\t\tslug: t.slug,\n\t\t\t\tstatus: t.status,\n\t\t\t})),\n\t\t};\n\t} catch (error) {\n\t\treturn {\n\t\t\ttranslationGroup: \"\",\n\t\t\ttranslations: [],\n\t\t\terror: error instanceof Error ? error : new Error(String(error)),\n\t\t};\n\t}\n}\n\n/**\n * Result from resolveEmDashPath\n */\nexport interface ResolvePathResult<T = Record<string, unknown>> {\n\t/** The matched entry */\n\tentry: ContentEntry<T>;\n\t/** The collection slug that matched */\n\tcollection: string;\n\t/** Extracted parameters from the URL pattern (e.g. { slug: \"my-post\" }) */\n\tparams: Record<string, string>;\n}\n\n/** Matches `{paramName}` placeholders in URL patterns */\nconst URL_PARAM_PATTERN = /\\{(\\w+)\\}/g;\n\n/** Convert a URL pattern like \"/blog/{slug}\" to a regex and param name list */\nfunction patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {\n\tconst paramNames: string[] = [];\n\tconst regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => {\n\t\tparamNames.push(name);\n\t\treturn \"([^/]+)\";\n\t});\n\treturn { regex: new RegExp(`^${regexStr}$`), paramNames };\n}\n\n/** Cached compiled URL patterns for resolveEmDashPath */\ninterface CachedPattern {\n\tslug: string;\n\tregex: RegExp;\n\tparamNames: string[];\n}\nlet cachedUrlPatterns: CachedPattern[] | null = null;\n\n/**\n * Invalidate the cached URL patterns used by resolveEmDashPath.\n * Call when collection URL patterns change (schema updates).\n */\nexport function invalidateUrlPatternCache(): void {\n\tcachedUrlPatterns = null;\n}\n\n/**\n * Resolve a URL path to a content entry by matching against collection URL patterns.\n *\n * Loads all collections with a `urlPattern` set, converts each pattern to a regex,\n * and tests the given path. On match, extracts the slug and fetches the entry.\n *\n * @example\n * ```ts\n * import { resolveEmDashPath } from \"emdash\";\n *\n * // Given pages with urlPattern \"/{slug}\" and posts with \"/blog/{slug}\":\n * const result = await resolveEmDashPath(\"/blog/hello-world\");\n * if (result) {\n * console.log(result.collection); // \"posts\"\n * console.log(result.params.slug); // \"hello-world\"\n * console.log(result.entry.data); // post data\n * }\n * ```\n */\nexport async function resolveEmDashPath<T = Record<string, unknown>>(\n\tpath: string,\n): Promise<ResolvePathResult<T> | null> {\n\t// Build and cache compiled patterns on first call\n\tif (!cachedUrlPatterns) {\n\t\tconst { getDb } = await import(\"./loader.js\");\n\t\tconst { SchemaRegistry } = await import(\"./schema/registry.js\");\n\t\tconst db = await getDb();\n\t\tconst registry = new SchemaRegistry(db);\n\t\tconst collections = await registry.listCollections();\n\n\t\tcachedUrlPatterns = [];\n\t\tfor (const collection of collections) {\n\t\t\tif (!collection.urlPattern) continue;\n\t\t\tconst { regex, paramNames } = patternToRegex(collection.urlPattern);\n\t\t\tcachedUrlPatterns.push({ slug: collection.slug, regex, paramNames });\n\t\t}\n\t}\n\n\tfor (const pattern of cachedUrlPatterns) {\n\t\tconst match = path.match(pattern.regex);\n\t\tif (!match) continue;\n\n\t\t// Extract params\n\t\tconst params: Record<string, string> = {};\n\t\tfor (let i = 0; i < pattern.paramNames.length; i++) {\n\t\t\tparams[pattern.paramNames[i]] = match[i + 1];\n\t\t}\n\n\t\t// Look up entry by slug (most common pattern)\n\t\tconst slug = params.slug;\n\t\tif (!slug) continue;\n\n\t\tconst { entry } = await getEmDashEntry<string, T>(pattern.slug, slug);\n\t\tif (entry) {\n\t\t\treturn { entry, collection: pattern.slug, params };\n\t\t}\n\t}\n\n\treturn null;\n}\n"],"mappings":"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"}
@@ -1,99 +1,9 @@
1
1
  import { r as currentTimestampValue } from "./dialect-helpers-DhTzaUxP.mjs";
2
2
  import { n as decodeCursor, r as encodeCursor } from "./types-CMMN0pNg.mjs";
3
+ import { i as matchPattern, n as interpolateDestination, r as isPattern, t as compilePattern } from "./patterns-CrCYkMBb.mjs";
3
4
  import { sql } from "kysely";
4
5
  import { ulid } from "ulidx";
5
6
 
6
- //#region src/redirects/patterns.ts
7
- /**
8
- * URL pattern matching for redirects.
9
- *
10
- * Uses Astro's route syntax: [param] for named segments, [...rest] for catch-all.
11
- * Compiles patterns to safe regexes -- no user-supplied regex, no ReDoS risk.
12
- *
13
- * @example
14
- * ```ts
15
- * const compiled = compilePattern("/old-blog/[...path]");
16
- * const match = matchPattern(compiled, "/old-blog/2024/01/post");
17
- * // match = { path: "2024/01/post" }
18
- *
19
- * interpolateDestination("/blog/[...path]", match);
20
- * // "/blog/2024/01/post"
21
- * ```
22
- */
23
- /** Matches [paramName] placeholders */
24
- const PARAM_PATTERN = /\[(\w+)\]/g;
25
- /** Matches [...splatName] placeholders */
26
- const SPLAT_PATTERN = /\[\.\.\.(\w+)\]/g;
27
- /** Combined pattern for validation: matches both [param] and [...splat] */
28
- const ANY_PLACEHOLDER = /\[(?:\.\.\.)?(\w+)\]/g;
29
- /** Split on capture groups in compiled regex string */
30
- const CAPTURE_GROUP_SPLIT = /(\([^)]+\))/;
31
- /** Escape regex-special characters in literal parts */
32
- const REGEX_SPECIAL_CHARS = /[.*+?^${}|\\]/g;
33
- /**
34
- * Returns true if a source string contains [param] or [...splat] placeholders.
35
- */
36
- function isPattern(source) {
37
- return source.match(ANY_PLACEHOLDER) !== null;
38
- }
39
- /**
40
- * Compile a URL pattern into a regex for matching.
41
- *
42
- * - `[param]` matches a single path segment (`[^/]+`)
43
- * - `[...rest]` matches one or more remaining segments (`.+`)
44
- */
45
- function compilePattern(source) {
46
- const paramNames = [];
47
- let regexStr = source.replace(SPLAT_PATTERN, (_match, name) => {
48
- paramNames.push(name);
49
- return "(.+)";
50
- });
51
- regexStr = regexStr.replace(PARAM_PATTERN, (_match, name) => {
52
- paramNames.push(name);
53
- return "([^/]+)";
54
- });
55
- const escaped = regexStr.split(CAPTURE_GROUP_SPLIT).map((part, i) => {
56
- if (i % 2 === 1) return part;
57
- return part.replace(REGEX_SPECIAL_CHARS, "\\$&");
58
- }).join("");
59
- return {
60
- regex: new RegExp(`^${escaped}$`),
61
- paramNames,
62
- source
63
- };
64
- }
65
- /**
66
- * Match a path against a compiled pattern.
67
- * Returns captured params or null if no match.
68
- */
69
- function matchPattern(compiled, path) {
70
- const match = path.match(compiled.regex);
71
- if (!match) return null;
72
- const params = {};
73
- for (let i = 0; i < compiled.paramNames.length; i++) {
74
- const value = match[i + 1];
75
- if (value !== void 0) params[compiled.paramNames[i]] = value;
76
- }
77
- return params;
78
- }
79
- /**
80
- * Interpolate captured params into a destination pattern.
81
- *
82
- * @example
83
- * interpolateDestination("/blog/[...path]", { path: "2024/01/post" })
84
- * // "/blog/2024/01/post"
85
- */
86
- function interpolateDestination(destination, params) {
87
- let result = destination.replace(SPLAT_PATTERN, (_match, name) => {
88
- return params[name] ?? "";
89
- });
90
- result = result.replace(PARAM_PATTERN, (_match, name) => {
91
- return params[name] ?? "";
92
- });
93
- return result;
94
- }
95
-
96
- //#endregion
97
7
  //#region src/database/repositories/redirect.ts
98
8
  function rowToRedirect(row) {
99
9
  return {
@@ -333,4 +243,4 @@ var RedirectRepository = class {
333
243
 
334
244
  //#endregion
335
245
  export { RedirectRepository as t };
336
- //# sourceMappingURL=redirect-DUAk-Yl_.mjs.map
246
+ //# sourceMappingURL=redirect-7lGhLBNZ.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect-7lGhLBNZ.mjs","names":[],"sources":["../src/database/repositories/redirect.ts"],"sourcesContent":["import { sql, type Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport {\n\tcompilePattern,\n\tmatchPattern,\n\tinterpolateDestination,\n\tisPattern,\n} from \"../../redirects/patterns.js\";\nimport { currentTimestampValue } from \"../dialect-helpers.js\";\nimport type { Database, RedirectTable } from \"../types.js\";\nimport { encodeCursor, decodeCursor, type FindManyResult } from \"./types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface Redirect {\n\tid: string;\n\tsource: string;\n\tdestination: string;\n\ttype: number;\n\tisPattern: boolean;\n\tenabled: boolean;\n\thits: number;\n\tlastHitAt: string | null;\n\tgroupName: string | null;\n\tauto: boolean;\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nexport interface CreateRedirectInput {\n\tsource: string;\n\tdestination: string;\n\ttype?: number;\n\tisPattern?: boolean;\n\tenabled?: boolean;\n\tgroupName?: string | null;\n\tauto?: boolean;\n}\n\nexport interface UpdateRedirectInput {\n\tsource?: string;\n\tdestination?: string;\n\ttype?: number;\n\tisPattern?: boolean;\n\tenabled?: boolean;\n\tgroupName?: string | null;\n}\n\nexport interface NotFoundEntry {\n\tid: string;\n\tpath: string;\n\treferrer: string | null;\n\tuserAgent: string | null;\n\tip: string | null;\n\tcreatedAt: string;\n}\n\nexport interface NotFoundSummary {\n\tpath: string;\n\tcount: number;\n\tlastSeen: string;\n\ttopReferrer: string | null;\n}\n\nexport interface RedirectMatch {\n\tredirect: Redirect;\n\tresolvedDestination: string;\n}\n\n// ---------------------------------------------------------------------------\n// Row mapping\n// ---------------------------------------------------------------------------\n\nfunction rowToRedirect(row: RedirectTable): Redirect {\n\treturn {\n\t\tid: row.id,\n\t\tsource: row.source,\n\t\tdestination: row.destination,\n\t\ttype: row.type,\n\t\tisPattern: row.is_pattern === 1,\n\t\tenabled: row.enabled === 1,\n\t\thits: row.hits,\n\t\tlastHitAt: row.last_hit_at,\n\t\tgroupName: row.group_name,\n\t\tauto: row.auto === 1,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Repository\n// ---------------------------------------------------------------------------\n\nexport class RedirectRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t// --- CRUD ---------------------------------------------------------------\n\n\tasync findById(id: string): Promise<Redirect | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? rowToRedirect(row) : null;\n\t}\n\n\tasync findBySource(source: string): Promise<Redirect | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.where(\"source\", \"=\", source)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? rowToRedirect(row) : null;\n\t}\n\n\tasync findMany(opts: {\n\t\tcursor?: string;\n\t\tlimit?: number;\n\t\tsearch?: string;\n\t\tgroup?: string;\n\t\tenabled?: boolean;\n\t\tauto?: boolean;\n\t}): Promise<FindManyResult<Redirect>> {\n\t\tconst limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.orderBy(\"id\", \"desc\")\n\t\t\t.limit(limit + 1);\n\n\t\tif (opts.search) {\n\t\t\tconst term = `%${opts.search}%`;\n\t\t\tquery = query.where((eb) =>\n\t\t\t\teb.or([eb(\"source\", \"like\", term), eb(\"destination\", \"like\", term)]),\n\t\t\t);\n\t\t}\n\n\t\tif (opts.group !== undefined) {\n\t\t\tquery = query.where(\"group_name\", \"=\", opts.group);\n\t\t}\n\n\t\tif (opts.enabled !== undefined) {\n\t\t\tquery = query.where(\"enabled\", \"=\", opts.enabled ? 1 : 0);\n\t\t}\n\n\t\tif (opts.auto !== undefined) {\n\t\t\tquery = query.where(\"auto\", \"=\", opts.auto ? 1 : 0);\n\t\t}\n\n\t\tif (opts.cursor) {\n\t\t\tconst decoded = decodeCursor(opts.cursor);\n\t\t\tif (decoded) {\n\t\t\t\tquery = query.where((eb) =>\n\t\t\t\t\teb.or([\n\t\t\t\t\t\teb(\"created_at\", \"<\", decoded.orderValue),\n\t\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \"<\", decoded.id)]),\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconst rows = await query.execute();\n\t\tconst items = rows.slice(0, limit).map(rowToRedirect);\n\t\tconst result: FindManyResult<Redirect> = { items };\n\n\t\tif (rows.length > limit) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tasync create(input: CreateRedirectInput): Promise<Redirect> {\n\t\tconst id = ulid();\n\t\tconst now = new Date().toISOString();\n\t\tconst patternFlag = input.isPattern ?? isPattern(input.source);\n\n\t\tawait this.db\n\t\t\t.insertInto(\"_emdash_redirects\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tsource: input.source,\n\t\t\t\tdestination: input.destination,\n\t\t\t\ttype: input.type ?? 301,\n\t\t\t\tis_pattern: patternFlag ? 1 : 0,\n\t\t\t\tenabled: input.enabled !== false ? 1 : 0,\n\t\t\t\thits: 0,\n\t\t\t\tlast_hit_at: null,\n\t\t\t\tgroup_name: input.groupName ?? null,\n\t\t\t\tauto: input.auto ? 1 : 0,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t})\n\t\t\t.execute();\n\n\t\treturn (await this.findById(id))!;\n\t}\n\n\tasync update(id: string, input: UpdateRedirectInput): Promise<Redirect | null> {\n\t\tconst existing = await this.findById(id);\n\t\tif (!existing) return null;\n\n\t\tconst now = new Date().toISOString();\n\t\tconst values: Record<string, unknown> = { updated_at: now };\n\n\t\tif (input.source !== undefined) {\n\t\t\tvalues.source = input.source;\n\t\t\tvalues.is_pattern =\n\t\t\t\tinput.isPattern !== undefined ? (input.isPattern ? 1 : 0) : isPattern(input.source) ? 1 : 0;\n\t\t} else if (input.isPattern !== undefined) {\n\t\t\tvalues.is_pattern = input.isPattern ? 1 : 0;\n\t\t}\n\n\t\tif (input.destination !== undefined) values.destination = input.destination;\n\t\tif (input.type !== undefined) values.type = input.type;\n\t\tif (input.enabled !== undefined) values.enabled = input.enabled ? 1 : 0;\n\t\tif (input.groupName !== undefined) values.group_name = input.groupName;\n\n\t\tawait this.db.updateTable(\"_emdash_redirects\").set(values).where(\"id\", \"=\", id).execute();\n\n\t\treturn (await this.findById(id))!;\n\t}\n\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_redirects\")\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn BigInt(result.numDeletedRows) > 0n;\n\t}\n\n\t/**\n\t * Fetch all enabled redirects (for loop detection graph building).\n\t * Not paginated — returns the full set.\n\t */\n\tasync findAllEnabled(): Promise<Redirect[]> {\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.where(\"enabled\", \"=\", 1)\n\t\t\t.execute();\n\t\treturn rows.map(rowToRedirect);\n\t}\n\n\t// --- Matching -----------------------------------------------------------\n\n\tasync findExactMatch(path: string): Promise<Redirect | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.where(\"source\", \"=\", path)\n\t\t\t.where(\"enabled\", \"=\", 1)\n\t\t\t.where(\"is_pattern\", \"=\", 0)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? rowToRedirect(row) : null;\n\t}\n\n\tasync findEnabledPatternRules(): Promise<Redirect[]> {\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"_emdash_redirects\")\n\t\t\t.selectAll()\n\t\t\t.where(\"enabled\", \"=\", 1)\n\t\t\t.where(\"is_pattern\", \"=\", 1)\n\t\t\t.execute();\n\t\treturn rows.map(rowToRedirect);\n\t}\n\n\t/**\n\t * Match a request path against all enabled redirect rules.\n\t * Checks exact matches first (indexed), then pattern rules.\n\t * Returns the matched redirect and the resolved destination URL.\n\t */\n\tasync matchPath(path: string): Promise<RedirectMatch | null> {\n\t\t// 1. Exact match (fast, indexed)\n\t\tconst exact = await this.findExactMatch(path);\n\t\tif (exact) {\n\t\t\treturn { redirect: exact, resolvedDestination: exact.destination };\n\t\t}\n\n\t\t// 2. Pattern match\n\t\tconst patterns = await this.findEnabledPatternRules();\n\t\tfor (const redirect of patterns) {\n\t\t\tconst compiled = compilePattern(redirect.source);\n\t\t\tconst params = matchPattern(compiled, path);\n\t\t\tif (params) {\n\t\t\t\tconst resolved = interpolateDestination(redirect.destination, params);\n\t\t\t\treturn { redirect, resolvedDestination: resolved };\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// --- Hit tracking -------------------------------------------------------\n\n\tasync recordHit(id: string): Promise<void> {\n\t\tawait sql`\n\t\t\tUPDATE _emdash_redirects\n\t\t\tSET hits = hits + 1, last_hit_at = ${currentTimestampValue(this.db)}, updated_at = ${currentTimestampValue(this.db)}\n\t\t\tWHERE id = ${id}\n\t\t`.execute(this.db);\n\t}\n\n\t// --- Auto-redirects (slug change) ---------------------------------------\n\n\t/**\n\t * Create an auto-redirect when a content slug changes.\n\t * Uses the collection's URL pattern to compute old/new URLs.\n\t * Collapses existing redirect chains pointing to the old URL.\n\t */\n\tasync createAutoRedirect(\n\t\tcollection: string,\n\t\toldSlug: string,\n\t\tnewSlug: string,\n\t\tcontentId: string,\n\t\turlPattern: string | null,\n\t): Promise<Redirect> {\n\t\tconst oldUrl = urlPattern\n\t\t\t? urlPattern.replace(\"{slug}\", oldSlug).replace(\"{id}\", contentId)\n\t\t\t: `/${collection}/${oldSlug}`;\n\t\tconst newUrl = urlPattern\n\t\t\t? urlPattern.replace(\"{slug}\", newSlug).replace(\"{id}\", contentId)\n\t\t\t: `/${collection}/${newSlug}`;\n\n\t\t// Collapse chains: update any existing redirects pointing to the old URL\n\t\tawait this.collapseChains(oldUrl, newUrl);\n\n\t\t// Check if a redirect from this source already exists\n\t\tconst existing = await this.findBySource(oldUrl);\n\t\tif (existing) {\n\t\t\t// Update the existing redirect to point to the new URL\n\t\t\treturn (await this.update(existing.id, { destination: newUrl }))!;\n\t\t}\n\n\t\treturn this.create({\n\t\t\tsource: oldUrl,\n\t\t\tdestination: newUrl,\n\t\t\ttype: 301,\n\t\t\tisPattern: false,\n\t\t\tauto: true,\n\t\t\tgroupName: \"Auto: slug change\",\n\t\t});\n\t}\n\n\t/**\n\t * Update all redirects whose destination matches oldDestination\n\t * to point to newDestination instead. Prevents redirect chains.\n\t * Returns the number of updated rows.\n\t */\n\tasync collapseChains(oldDestination: string, newDestination: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.updateTable(\"_emdash_redirects\")\n\t\t\t.set({\n\t\t\t\tdestination: newDestination,\n\t\t\t\tupdated_at: new Date().toISOString(),\n\t\t\t})\n\t\t\t.where(\"destination\", \"=\", oldDestination)\n\t\t\t.executeTakeFirst();\n\t\treturn Number(result.numUpdatedRows);\n\t}\n\n\t// --- 404 log ------------------------------------------------------------\n\n\tasync log404(entry: {\n\t\tpath: string;\n\t\treferrer?: string | null;\n\t\tuserAgent?: string | null;\n\t\tip?: string | null;\n\t}): Promise<void> {\n\t\tawait this.db\n\t\t\t.insertInto(\"_emdash_404_log\")\n\t\t\t.values({\n\t\t\t\tid: ulid(),\n\t\t\t\tpath: entry.path,\n\t\t\t\treferrer: entry.referrer ?? null,\n\t\t\t\tuser_agent: entry.userAgent ?? null,\n\t\t\t\tip: entry.ip ?? null,\n\t\t\t\tcreated_at: new Date().toISOString(),\n\t\t\t})\n\t\t\t.execute();\n\t}\n\n\tasync find404s(opts: {\n\t\tcursor?: string;\n\t\tlimit?: number;\n\t\tsearch?: string;\n\t}): Promise<FindManyResult<NotFoundEntry>> {\n\t\tconst limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_404_log\")\n\t\t\t.selectAll()\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.orderBy(\"id\", \"desc\")\n\t\t\t.limit(limit + 1);\n\n\t\tif (opts.search) {\n\t\t\tquery = query.where(\"path\", \"like\", `%${opts.search}%`);\n\t\t}\n\n\t\tif (opts.cursor) {\n\t\t\tconst decoded = decodeCursor(opts.cursor);\n\t\t\tif (decoded) {\n\t\t\t\tquery = query.where((eb) =>\n\t\t\t\t\teb.or([\n\t\t\t\t\t\teb(\"created_at\", \"<\", decoded.orderValue),\n\t\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \"<\", decoded.id)]),\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconst rows = await query.execute();\n\t\tconst items: NotFoundEntry[] = rows.slice(0, limit).map((row) => ({\n\t\t\tid: row.id,\n\t\t\tpath: row.path,\n\t\t\treferrer: row.referrer,\n\t\t\tuserAgent: row.user_agent,\n\t\t\tip: row.ip,\n\t\t\tcreatedAt: row.created_at,\n\t\t}));\n\n\t\tconst result: FindManyResult<NotFoundEntry> = { items };\n\t\tif (rows.length > limit) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tasync get404Summary(limit = 50): Promise<NotFoundSummary[]> {\n\t\tconst rows = await sql<{\n\t\t\tpath: string;\n\t\t\tcount: number;\n\t\t\tlast_seen: string;\n\t\t\ttop_referrer: string | null;\n\t\t}>`\n\t\t\tSELECT\n\t\t\t\tpath,\n\t\t\t\tCOUNT(*) as count,\n\t\t\t\tMAX(created_at) as last_seen,\n\t\t\t\t(\n\t\t\t\t\tSELECT referrer FROM _emdash_404_log AS inner_log\n\t\t\t\t\tWHERE inner_log.path = _emdash_404_log.path\n\t\t\t\t\t\tAND referrer IS NOT NULL AND referrer != ''\n\t\t\t\t\tGROUP BY referrer\n\t\t\t\t\tORDER BY COUNT(*) DESC\n\t\t\t\t\tLIMIT 1\n\t\t\t\t) as top_referrer\n\t\t\tFROM _emdash_404_log\n\t\t\tGROUP BY path\n\t\t\tORDER BY count DESC\n\t\t\tLIMIT ${limit}\n\t\t`.execute(this.db);\n\n\t\treturn rows.rows.map((row) => ({\n\t\t\tpath: row.path,\n\t\t\tcount: Number(row.count),\n\t\t\tlastSeen: row.last_seen,\n\t\t\ttopReferrer: row.top_referrer,\n\t\t}));\n\t}\n\n\tasync delete404(id: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_404_log\")\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn BigInt(result.numDeletedRows) > 0n;\n\t}\n\n\tasync clear404s(): Promise<number> {\n\t\tconst result = await this.db.deleteFrom(\"_emdash_404_log\").executeTakeFirst();\n\t\treturn Number(result.numDeletedRows);\n\t}\n\n\tasync prune404s(olderThan: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_404_log\")\n\t\t\t.where(\"created_at\", \"<\", olderThan)\n\t\t\t.executeTakeFirst();\n\t\treturn Number(result.numDeletedRows);\n\t}\n}\n"],"mappings":"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"}
@@ -1,36 +1,11 @@
1
1
  import { t as __exportAll } from "./chunk-ClPoSABd.mjs";
2
2
  import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
3
3
  import { a as isSqlite, c as tableExists, n as currentTimestamp, s as listTablesLike } from "./dialect-helpers-DhTzaUxP.mjs";
4
- import { i as RESERVED_FIELD_SLUGS, n as FIELD_TYPE_TO_COLUMN, r as RESERVED_COLLECTION_SLUGS } from "./types-DuNbGKjF.mjs";
4
+ import { t as withTransaction } from "./transaction-Cn2rjY78.mjs";
5
+ import { i as RESERVED_FIELD_SLUGS, n as FIELD_TYPE_TO_COLUMN, r as RESERVED_COLLECTION_SLUGS } from "./types-xxCWI3j0.mjs";
5
6
  import { sql } from "kysely";
6
7
  import { ulid } from "ulidx";
7
8
 
8
- //#region src/database/transaction.ts
9
- /**
10
- * Run a callback inside a transaction if supported, or directly if not.
11
- *
12
- * Probes the database once on first call to determine if transactions work.
13
- * The result is cached for the lifetime of the process/worker.
14
- */
15
- let transactionsSupported = null;
16
- const TRANSACTIONS_NOT_SUPPORTED_RE = /transactions are not supported/i;
17
- async function withTransaction(db, fn) {
18
- if (transactionsSupported === true) return db.transaction().execute(fn);
19
- if (transactionsSupported === false) return fn(db);
20
- try {
21
- const result = await db.transaction().execute(fn);
22
- transactionsSupported = true;
23
- return result;
24
- } catch (error) {
25
- if (error instanceof Error && TRANSACTIONS_NOT_SUPPORTED_RE.test(error.message)) {
26
- transactionsSupported = false;
27
- return fn(db);
28
- }
29
- throw error;
30
- }
31
- }
32
-
33
- //#endregion
34
9
  //#region src/search/fts-manager.ts
35
10
  /**
36
11
  * FTS5 Manager
@@ -101,7 +76,12 @@ var FTSManager = class {
101
76
  await this.createTriggers(collectionSlug, searchableFields);
102
77
  }
103
78
  /**
104
- * Create triggers to keep FTS table in sync with content table
79
+ * Create triggers to keep FTS table in sync with content table.
80
+ *
81
+ * The insert and update triggers only add rows to the FTS index when
82
+ * `deleted_at IS NULL`. This keeps soft-deleted content out of the
83
+ * search index and ensures the FTS row count matches the non-deleted
84
+ * content count (which `verifyAndRepairIndex` relies on).
105
85
  */
106
86
  async createTriggers(collectionSlug, searchableFields) {
107
87
  this.validateInputs(collectionSlug, searchableFields);
@@ -112,6 +92,7 @@ var FTSManager = class {
112
92
  await sql.raw(`
113
93
  CREATE TRIGGER IF NOT EXISTS "${ftsTable}_insert"
114
94
  AFTER INSERT ON "${contentTable}"
95
+ WHEN NEW.deleted_at IS NULL
115
96
  BEGIN
116
97
  INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
117
98
  VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
@@ -123,7 +104,8 @@ var FTSManager = class {
123
104
  BEGIN
124
105
  DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
125
106
  INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
126
- VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
107
+ SELECT NEW.rowid, NEW.id, NEW.locale, ${newFieldList}
108
+ WHERE NEW.deleted_at IS NULL;
127
109
  END
128
110
  `).execute(this.db);
129
111
  await sql.raw(`
@@ -215,16 +197,18 @@ var FTSManager = class {
215
197
  return (await this.db.selectFrom("_emdash_fields").select("slug").where("collection_id", "=", collection.id).where("searchable", "=", 1).execute()).map((f) => f.slug);
216
198
  }
217
199
  /**
218
- * Enable search for a collection
200
+ * Enable search for a collection.
219
201
  *
220
- * Creates the FTS table and triggers, and populates from existing content.
202
+ * Uses rebuildIndex to ensure a clean state -- drop any existing FTS
203
+ * table/triggers, recreate them, and populate from content. This avoids
204
+ * duplicate rows when triggers have already populated the index (e.g.
205
+ * during seeding where content is inserted before search is enabled).
221
206
  */
222
207
  async enableSearch(collectionSlug, options) {
223
208
  if (!isSqlite(this.db)) throw new Error("Full-text search is only available with SQLite databases");
224
209
  const searchableFields = await this.getSearchableFields(collectionSlug);
225
210
  if (searchableFields.length === 0) throw new Error(`No searchable fields defined for collection "${collectionSlug}". Mark at least one field as searchable before enabling search.`);
226
- await this.createFtsTable(collectionSlug, searchableFields, options?.weights);
227
- await this.populateFromContent(collectionSlug, searchableFields);
211
+ await this.rebuildIndex(collectionSlug, searchableFields, options?.weights);
228
212
  await this.setSearchConfig(collectionSlug, {
229
213
  enabled: true,
230
214
  weights: options?.weights
@@ -854,5 +838,5 @@ var SchemaRegistry = class {
854
838
  };
855
839
 
856
840
  //#endregion
857
- export { withTransaction as a, FTSManager as i, SchemaRegistry as n, registry_exports as r, SchemaError as t };
858
- //# sourceMappingURL=registry-DU18yVo0.mjs.map
841
+ export { FTSManager as i, SchemaRegistry as n, registry_exports as r, SchemaError as t };
842
+ //# sourceMappingURL=registry-BgnP3ysR.mjs.map