emdash 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/{adapters-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
  2. package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
  3. package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
  4. package/dist/apply-Cma_PiF6.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +25 -11
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +38 -25
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.mjs +2 -2
  11. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  12. package/dist/astro/middleware/redirect.mjs +20 -8
  13. package/dist/astro/middleware/redirect.mjs.map +1 -1
  14. package/dist/astro/middleware/request-context.mjs +12 -2
  15. package/dist/astro/middleware/request-context.mjs.map +1 -1
  16. package/dist/astro/middleware/setup.mjs +1 -1
  17. package/dist/astro/middleware.d.mts.map +1 -1
  18. package/dist/astro/middleware.mjs +52 -45
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +9 -9
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
  23. package/dist/byline-WuOq9MFJ.mjs.map +1 -0
  24. package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
  25. package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
  26. package/dist/cache-E3Dts-yT.mjs +56 -0
  27. package/dist/cache-E3Dts-yT.mjs.map +1 -0
  28. package/dist/cli/index.mjs +13 -13
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
  34. package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
  35. package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
  36. package/dist/content-BsBoyj8G.mjs.map +1 -0
  37. package/dist/db/index.d.mts +3 -3
  38. package/dist/db/index.mjs +2 -2
  39. package/dist/db/libsql.d.mts +1 -1
  40. package/dist/db/postgres.d.mts +1 -1
  41. package/dist/db/sqlite.d.mts +1 -1
  42. package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
  43. package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
  44. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  45. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  46. package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
  47. package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
  48. package/dist/{index-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
  49. package/dist/index-CRg3PWfZ.d.mts.map +1 -0
  50. package/dist/index.d.mts +11 -11
  51. package/dist/index.mjs +20 -20
  52. package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
  53. package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
  54. package/dist/{loader-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
  55. package/dist/loader-BYzwzORf.mjs.map +1 -0
  56. package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
  57. package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
  58. package/dist/media/index.d.mts +1 -1
  59. package/dist/media/index.mjs +1 -1
  60. package/dist/media/local-runtime.d.mts +7 -7
  61. package/dist/{mode-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
  62. package/dist/{mode-CYeM2rPt.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
  63. package/dist/page/index.d.mts +1 -1
  64. package/dist/patterns-CrCYkMBb.mjs +93 -0
  65. package/dist/patterns-CrCYkMBb.mjs.map +1 -0
  66. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
  67. package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
  68. package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
  69. package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
  70. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  71. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  72. package/dist/{query-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
  73. package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
  74. package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
  75. package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
  76. package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
  77. package/dist/registry-BgnP3ysR.mjs.map +1 -0
  78. package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
  79. package/dist/runner-Cd-_WyDo.mjs.map +1 -0
  80. package/dist/{runner-DTqkzOzc.d.mts → runner-DYv3rX8P.d.mts} +10 -3
  81. package/dist/runner-DYv3rX8P.d.mts.map +1 -0
  82. package/dist/runtime.d.mts +6 -6
  83. package/dist/runtime.mjs +2 -2
  84. package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
  85. package/dist/search-B5p9D36n.mjs.map +1 -0
  86. package/dist/seed/index.d.mts +2 -2
  87. package/dist/seed/index.mjs +10 -10
  88. package/dist/seo/index.d.mts +1 -1
  89. package/dist/storage/local.d.mts +1 -1
  90. package/dist/storage/local.mjs +1 -1
  91. package/dist/storage/s3.d.mts +11 -3
  92. package/dist/storage/s3.d.mts.map +1 -1
  93. package/dist/storage/s3.mjs +76 -15
  94. package/dist/storage/s3.mjs.map +1 -1
  95. package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
  96. package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
  97. package/dist/transaction-Cn2rjY78.mjs +28 -0
  98. package/dist/transaction-Cn2rjY78.mjs.map +1 -0
  99. package/dist/{transport-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
  100. package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
  101. package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
  102. package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
  103. package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
  104. package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
  105. package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
  106. package/dist/types-BYWYxLcp.d.mts.map +1 -0
  107. package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
  108. package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
  109. package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
  110. package/dist/types-DNZpaCBk.d.mts.map +1 -0
  111. package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
  112. package/dist/types-Dz9_WMS6.mjs.map +1 -0
  113. package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
  114. package/dist/{types-CcreFIIH.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
  115. package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
  116. package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
  117. package/dist/{validate-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
  118. package/dist/{validate-B7KP7VLM.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
  119. package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
  120. package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
  121. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  122. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  123. package/dist/version-DlTDRdpv.mjs +7 -0
  124. package/dist/version-DlTDRdpv.mjs.map +1 -0
  125. package/package.json +7 -5
  126. package/src/api/handlers/content.ts +36 -25
  127. package/src/api/handlers/menus.ts +19 -16
  128. package/src/api/handlers/redirects.ts +95 -3
  129. package/src/api/schemas/redirects.ts +1 -0
  130. package/src/astro/integration/index.ts +2 -3
  131. package/src/astro/integration/runtime.ts +8 -14
  132. package/src/astro/integration/vite-config.ts +14 -4
  133. package/src/astro/middleware/redirect.ts +30 -15
  134. package/src/astro/middleware.ts +11 -19
  135. package/src/astro/routes/admin.astro +2 -2
  136. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  137. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  138. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  139. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  140. package/src/astro/routes/api/manifest.ts +3 -1
  141. package/src/astro/routes/api/redirects/[id].ts +3 -0
  142. package/src/astro/routes/api/redirects/index.ts +2 -0
  143. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
  144. package/src/astro/routes/api/schema/collections/index.ts +1 -0
  145. package/src/astro/storage/adapters.ts +19 -5
  146. package/src/astro/storage/types.ts +12 -4
  147. package/src/astro/types.ts +1 -0
  148. package/src/bylines/index.ts +50 -2
  149. package/src/cleanup.ts +3 -3
  150. package/src/cli/commands/bundle-utils.ts +5 -5
  151. package/src/database/dialect-helpers.ts +3 -0
  152. package/src/database/migrations/011_sections.ts +2 -2
  153. package/src/database/migrations/runner.ts +23 -2
  154. package/src/database/repositories/byline.ts +2 -1
  155. package/src/database/repositories/content.ts +5 -0
  156. package/src/database/repositories/redirect.ts +13 -0
  157. package/src/database/validate.ts +10 -10
  158. package/src/emdash-runtime.ts +23 -9
  159. package/src/index.ts +3 -0
  160. package/src/loader.ts +2 -0
  161. package/src/mcp/server.ts +40 -67
  162. package/src/menus/index.ts +4 -0
  163. package/src/plugins/context.ts +28 -4
  164. package/src/plugins/cron.ts +29 -4
  165. package/src/plugins/hooks.ts +22 -10
  166. package/src/plugins/index.ts +1 -0
  167. package/src/plugins/manager.ts +6 -2
  168. package/src/plugins/marketplace.ts +33 -3
  169. package/src/plugins/routes.ts +3 -3
  170. package/src/plugins/types.ts +7 -0
  171. package/src/query.ts +37 -14
  172. package/src/redirects/cache.ts +68 -0
  173. package/src/redirects/loops.ts +318 -0
  174. package/src/schema/registry.ts +3 -0
  175. package/src/search/fts-manager.ts +24 -11
  176. package/src/search/query.ts +8 -9
  177. package/src/seed/apply.ts +49 -28
  178. package/src/storage/s3.ts +94 -25
  179. package/src/storage/types.ts +13 -5
  180. package/src/utils/slugify.ts +11 -0
  181. package/src/version.ts +12 -0
  182. package/src/visual-editing/toolbar.ts +11 -1
  183. package/dist/apply-wmVEOSbR.mjs.map +0 -1
  184. package/dist/byline-1WQPlISL.mjs.map +0 -1
  185. package/dist/bylines-BYdTYmia.mjs.map +0 -1
  186. package/dist/content-BmXndhdi.mjs.map +0 -1
  187. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  188. package/dist/index-UHEVQMus.d.mts.map +0 -1
  189. package/dist/loader-CHb2v0jm.mjs.map +0 -1
  190. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  191. package/dist/registry-1EvbAfsC.mjs.map +0 -1
  192. package/dist/runner-BoN0-FPi.mjs.map +0 -1
  193. package/dist/runner-DTqkzOzc.d.mts.map +0 -1
  194. package/dist/search-BsYMed12.mjs.map +0 -1
  195. package/dist/types-6dqxBqsH.d.mts.map +0 -1
  196. package/dist/types-Bec-r_3_.mjs.map +0 -1
  197. package/dist/types-BljtYPSd.d.mts.map +0 -1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Redirect loop and chain detection utilities.
3
+ *
4
+ * Builds a directed graph from redirect rules and detects:
5
+ * - Cycles (loops): /a → /b → /c → /a
6
+ * - Long chains: /a → /b → /c → /d → /e (exceeding a warning threshold)
7
+ *
8
+ * Handles both exact and pattern redirects. When the walker encounters
9
+ * a path with no exact source match, it tests against compiled pattern
10
+ * sources and resolves the destination using captured parameters.
11
+ */
12
+
13
+ import {
14
+ compilePattern,
15
+ matchPattern,
16
+ interpolateDestination,
17
+ type CompiledPattern,
18
+ } from "./patterns.js";
19
+
20
+ export interface RedirectEdge {
21
+ id: string;
22
+ source: string;
23
+ destination: string;
24
+ enabled: boolean;
25
+ isPattern: boolean;
26
+ }
27
+
28
+ interface CompiledPatternRedirect {
29
+ id: string;
30
+ compiled: CompiledPattern;
31
+ destination: string;
32
+ }
33
+
34
+ /**
35
+ * Compile all enabled pattern redirects for matching during graph walks.
36
+ */
37
+ function compilePatterns(edges: RedirectEdge[]): CompiledPatternRedirect[] {
38
+ const result: CompiledPatternRedirect[] = [];
39
+ for (const edge of edges) {
40
+ if (edge.enabled && edge.isPattern) {
41
+ result.push({
42
+ id: edge.id,
43
+ compiled: compilePattern(edge.source),
44
+ destination: edge.destination,
45
+ });
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ /** Single-segment dummy value for representative path generation */
52
+ const DUMMY_SEGMENT = "__p__";
53
+
54
+ /** Splat pattern: [...paramName] */
55
+ const SPLAT_RE = /\[\.\.\.(\w+)\]/g;
56
+
57
+ /** Param pattern: [paramName] */
58
+ const PARAM_RE = /\[(\w+)\]/g;
59
+
60
+ /**
61
+ * Extract the literal prefix from a pattern source (everything before the
62
+ * first placeholder), stripped of leading segments shared with a base path.
63
+ * e.g., "/new/docs/[slug]" → "docs/__p__" (the part after "/new/")
64
+ */
65
+ function extractPatternSuffix(patternSource: string): string {
66
+ // Replace placeholders with dummy values
67
+ let result = patternSource.replace(SPLAT_RE, DUMMY_SEGMENT);
68
+ SPLAT_RE.lastIndex = 0;
69
+ result = result.replace(PARAM_RE, DUMMY_SEGMENT);
70
+ // Strip leading slash and first segment (e.g., "/new/docs/__p__" → "docs/__p__")
71
+ const parts = result.split("/").filter(Boolean);
72
+ return parts.slice(1).join("/");
73
+ }
74
+
75
+ /**
76
+ * Generate representative concrete paths from a template string.
77
+ * Replaces [param] with a dummy segment and [...rest] with multiple
78
+ * depth variants. For catch-alls, also generates representatives using
79
+ * literal prefixes from existing pattern sources to catch cross-pattern loops.
80
+ */
81
+ function generateRepresentatives(template: string, existingEdges?: RedirectEdge[]): string[] {
82
+ const hasSplat = SPLAT_RE.test(template);
83
+ SPLAT_RE.lastIndex = 0;
84
+
85
+ if (hasSplat) {
86
+ // Extract the static prefix before the catch-all (e.g., "/old/" from "/old/[...path]")
87
+ const splatIndex = template.indexOf("[...");
88
+ const prefix = template.slice(0, splatIndex);
89
+
90
+ const reps = [
91
+ template.replace(SPLAT_RE, DUMMY_SEGMENT).replace(PARAM_RE, DUMMY_SEGMENT),
92
+ template
93
+ .replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
94
+ .replace(PARAM_RE, DUMMY_SEGMENT),
95
+ template
96
+ .replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
97
+ .replace(PARAM_RE, DUMMY_SEGMENT),
98
+ ];
99
+
100
+ // Add representatives derived from existing pattern sources' literal prefixes
101
+ if (existingEdges) {
102
+ for (const edge of existingEdges) {
103
+ if (edge.enabled && edge.isPattern && edge.source !== template) {
104
+ const suffix = extractPatternSuffix(edge.source);
105
+ if (suffix) {
106
+ reps.push(`${prefix}${suffix}`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return reps;
113
+ }
114
+
115
+ return [template.replace(PARAM_RE, DUMMY_SEGMENT)];
116
+ }
117
+
118
+ /**
119
+ * Resolve the next hop for a given path. Tries exact match first,
120
+ * then pattern matching with parameter interpolation for concrete paths,
121
+ * then representative-based matching for template strings.
122
+ */
123
+ function resolveNext(
124
+ path: string,
125
+ graph: Map<string, { destination: string; id: string }>,
126
+ patterns: CompiledPatternRedirect[],
127
+ edges?: RedirectEdge[],
128
+ ): { destination: string; id: string } | null {
129
+ // Exact match (fast) — works for both real paths and template strings
130
+ const exact = graph.get(path);
131
+ if (exact) return exact;
132
+
133
+ if (!path.includes("[")) {
134
+ // Concrete path — try pattern matching directly
135
+ for (const pr of patterns) {
136
+ const params = matchPattern(pr.compiled, path);
137
+ if (params) {
138
+ const resolved = interpolateDestination(pr.destination, params);
139
+ return { destination: resolved, id: pr.id };
140
+ }
141
+ }
142
+ } else {
143
+ // Template string — generate representative paths and test against patterns
144
+ const representatives = generateRepresentatives(path, edges);
145
+ for (const pr of patterns) {
146
+ for (const rep of representatives) {
147
+ const params = matchPattern(pr.compiled, rep);
148
+ if (params) {
149
+ const resolved = interpolateDestination(pr.destination, params);
150
+ return { destination: resolved, id: pr.id };
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Build an adjacency map from redirect edges.
161
+ * Includes both exact and pattern redirects — pattern redirects use their
162
+ * template strings as literal graph edges, which works because EmDash
163
+ * patterns pass parameters through without transformation.
164
+ */
165
+ function buildGraph(edges: RedirectEdge[]): Map<string, { destination: string; id: string }> {
166
+ const graph = new Map<string, { destination: string; id: string }>();
167
+ for (const edge of edges) {
168
+ if (edge.enabled) {
169
+ graph.set(edge.source, { destination: edge.destination, id: edge.id });
170
+ }
171
+ }
172
+ return graph;
173
+ }
174
+
175
+ /**
176
+ * Detect all redirect IDs that participate in cycles.
177
+ * Walks every node in the graph once, collecting IDs from any cycles found.
178
+ *
179
+ * @returns Array of redirect IDs that are part of a loop
180
+ */
181
+ export function detectLoops(edges: RedirectEdge[]): string[] {
182
+ const graph = buildGraph(edges);
183
+ const patterns = compilePatterns(edges);
184
+ const visited = new Set<string>();
185
+ const loopRedirectIds = new Set<string>();
186
+
187
+ for (const [startSource] of graph) {
188
+ if (visited.has(startSource)) continue;
189
+
190
+ const path: string[] = [];
191
+ const pathSet = new Set<string>();
192
+ const pathIds: string[] = [];
193
+ let current: string | undefined = startSource;
194
+
195
+ while (current) {
196
+ if (pathSet.has(current)) {
197
+ // Found a cycle — collect IDs of redirects in the loop
198
+ const loopStart = path.indexOf(current);
199
+ for (const id of pathIds.slice(loopStart)) loopRedirectIds.add(id);
200
+ break;
201
+ }
202
+
203
+ if (visited.has(current)) {
204
+ break;
205
+ }
206
+
207
+ const next = resolveNext(current, graph, patterns, edges);
208
+ if (!next) break;
209
+
210
+ path.push(current);
211
+ pathSet.add(current);
212
+ pathIds.push(next.id);
213
+ current = next.destination;
214
+ }
215
+
216
+ for (const node of path) visited.add(node);
217
+ }
218
+
219
+ return [...loopRedirectIds];
220
+ }
221
+
222
+ /**
223
+ * Find a compiled pattern redirect whose source matches the given resolved path,
224
+ * returning the source template string for display purposes.
225
+ */
226
+ function findMatchingTemplate(
227
+ resolvedPath: string,
228
+ patterns: CompiledPatternRedirect[],
229
+ ): string | null {
230
+ for (const pr of patterns) {
231
+ if (matchPattern(pr.compiled, resolvedPath) !== null) {
232
+ return pr.compiled.source;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Check if adding or updating a redirect would create a loop.
240
+ *
241
+ * Walks the chain from `destination` through existing redirects.
242
+ * If it reaches `source`, a cycle would form.
243
+ *
244
+ * @returns The loop path if a cycle would be created, or null if safe
245
+ */
246
+ export function wouldCreateLoop(
247
+ source: string,
248
+ destination: string,
249
+ existingEdges: RedirectEdge[],
250
+ excludeId?: string,
251
+ ): string[] | null {
252
+ const filtered = excludeId ? existingEdges.filter((e) => e.id !== excludeId) : existingEdges;
253
+ const graph = buildGraph(filtered);
254
+ const patterns = compilePatterns(filtered);
255
+
256
+ // If the proposed source is a pattern, compile it so we can check
257
+ // whether resolved paths would match it (not just string equality)
258
+ const sourceIsPattern = source.includes("[");
259
+ const compiledSource = sourceIsPattern ? compilePattern(source) : null;
260
+
261
+ // Determine starting points for the walk. If the destination is a
262
+ // template, generate representative concrete paths AND find existing
263
+ // exact sources in the graph that match the template.
264
+ let startingPoints: string[];
265
+ if (destination.includes("[")) {
266
+ const reps = generateRepresentatives(destination, filtered);
267
+ // Also find existing exact graph keys that match this template
268
+ const compiled = compilePattern(destination);
269
+ for (const [key] of graph) {
270
+ if (!key.includes("[") && matchPattern(compiled, key) !== null) {
271
+ reps.push(key);
272
+ }
273
+ }
274
+ // Always include the destination itself — it may be an exact graph key
275
+ // (e.g., /a/sub/[...path] exists as a literal source in the graph)
276
+ reps.push(destination);
277
+ startingPoints = reps;
278
+ } else {
279
+ startingPoints = [destination];
280
+ }
281
+
282
+ for (const start of startingPoints) {
283
+ const path = [source, destination];
284
+ let current = start;
285
+ const seen = new Set<string>([source, destination, start]);
286
+
287
+ // Walk the chain until it ends or we revisit a node
288
+ // eslint-disable-next-line no-constant-condition -- terminates via return/break when chain ends or cycle found
289
+ while (true) {
290
+ const next = resolveNext(current, graph, patterns, filtered);
291
+ if (!next) break; // chain ends, try next starting point
292
+
293
+ // Check if we've looped back — either exact match or pattern match
294
+ const loopsBack =
295
+ seen.has(next.destination) ||
296
+ (compiledSource !== null && matchPattern(compiledSource, next.destination) !== null);
297
+
298
+ if (loopsBack) {
299
+ // Show the source template instead of dummy resolved path
300
+ const displayPath =
301
+ !seen.has(next.destination) && compiledSource !== null ? source : next.destination;
302
+ path.push(displayPath);
303
+ return path; // cycle found
304
+ }
305
+
306
+ // If the resolved path contains dummy segments, try to find the
307
+ // original pattern template that produced it for cleaner display
308
+ const cleanDest = next.destination.includes(DUMMY_SEGMENT)
309
+ ? (findMatchingTemplate(next.destination, patterns) ?? next.destination)
310
+ : next.destination;
311
+ path.push(cleanDest);
312
+ seen.add(next.destination);
313
+ current = next.destination;
314
+ }
315
+ }
316
+
317
+ return null;
318
+ }
@@ -6,6 +6,7 @@ import { ulid } from "ulidx";
6
6
  import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js";
7
7
  import { withTransaction } from "../database/transaction.js";
8
8
  import type { CollectionTable, Database, FieldTable } from "../database/types.js";
9
+ import { validateIdentifier } from "../database/validate.js";
9
10
  import { FTSManager } from "../search/fts-manager.js";
10
11
  import {
11
12
  type Collection,
@@ -684,6 +685,7 @@ export class SchemaRegistry {
684
685
  * Get table name for a collection
685
686
  */
686
687
  private getTableName(slug: string): string {
688
+ validateIdentifier(slug, "collection slug");
687
689
  return `ec_${slug}`;
688
690
  }
689
691
 
@@ -691,6 +693,7 @@ export class SchemaRegistry {
691
693
  * Get column name for a field
692
694
  */
693
695
  private getColumnName(slug: string): string {
696
+ validateIdentifier(slug, "field slug");
694
697
  return slug;
695
698
  }
696
699
 
@@ -39,6 +39,7 @@ export class FTSManager {
39
39
  * Uses _emdash_ prefix to clearly mark as internal/system table
40
40
  */
41
41
  getFtsTableName(collectionSlug: string): string {
42
+ validateIdentifier(collectionSlug, "collection slug");
42
43
  return `_emdash_fts_${collectionSlug}`;
43
44
  }
44
45
 
@@ -46,6 +47,7 @@ export class FTSManager {
46
47
  * Get the content table name for a collection
47
48
  */
48
49
  getContentTableName(collectionSlug: string): string {
50
+ validateIdentifier(collectionSlug, "collection slug");
49
51
  return `ec_${collectionSlug}`;
50
52
  }
51
53
 
@@ -98,19 +100,26 @@ export class FTSManager {
98
100
  }
99
101
 
100
102
  /**
101
- * Create triggers to keep FTS table in sync with content table
103
+ * Create triggers to keep FTS table in sync with content table.
104
+ *
105
+ * The insert and update triggers only add rows to the FTS index when
106
+ * `deleted_at IS NULL`. This keeps soft-deleted content out of the
107
+ * search index and ensures the FTS row count matches the non-deleted
108
+ * content count (which `verifyAndRepairIndex` relies on).
102
109
  */
103
110
  private async createTriggers(collectionSlug: string, searchableFields: string[]): Promise<void> {
111
+ this.validateInputs(collectionSlug, searchableFields);
104
112
  const ftsTable = this.getFtsTableName(collectionSlug);
105
113
  const contentTable = this.getContentTableName(collectionSlug);
106
114
  const fieldList = searchableFields.join(", ");
107
115
  const newFieldList = searchableFields.map((f) => `NEW.${f}`).join(", ");
108
116
 
109
- // Insert trigger
117
+ // Insert trigger - only index non-deleted content
110
118
  await sql
111
119
  .raw(`
112
120
  CREATE TRIGGER IF NOT EXISTS "${ftsTable}_insert"
113
121
  AFTER INSERT ON "${contentTable}"
122
+ WHEN NEW.deleted_at IS NULL
114
123
  BEGIN
115
124
  INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
116
125
  VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
@@ -118,7 +127,9 @@ export class FTSManager {
118
127
  `)
119
128
  .execute(this.db);
120
129
 
121
- // Update trigger - delete old, insert new
130
+ // Update trigger - always remove the old FTS row, only re-insert
131
+ // if the row is not soft-deleted. This handles both content edits
132
+ // and soft-delete operations (UPDATE SET deleted_at = ...).
122
133
  await sql
123
134
  .raw(`
124
135
  CREATE TRIGGER IF NOT EXISTS "${ftsTable}_update"
@@ -126,7 +137,8 @@ export class FTSManager {
126
137
  BEGIN
127
138
  DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
128
139
  INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
129
- VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
140
+ SELECT NEW.rowid, NEW.id, NEW.locale, ${newFieldList}
141
+ WHERE NEW.deleted_at IS NULL;
130
142
  END
131
143
  `)
132
144
  .execute(this.db);
@@ -147,6 +159,7 @@ export class FTSManager {
147
159
  * Drop triggers for a collection
148
160
  */
149
161
  private async dropTriggers(collectionSlug: string): Promise<void> {
162
+ this.validateInputs(collectionSlug);
150
163
  const ftsTable = this.getFtsTableName(collectionSlug);
151
164
 
152
165
  await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_insert"`).execute(this.db);
@@ -287,9 +300,12 @@ export class FTSManager {
287
300
  }
288
301
 
289
302
  /**
290
- * Enable search for a collection
303
+ * Enable search for a collection.
291
304
  *
292
- * Creates the FTS table and triggers, and populates from existing content.
305
+ * Uses rebuildIndex to ensure a clean state -- drop any existing FTS
306
+ * table/triggers, recreate them, and populate from content. This avoids
307
+ * duplicate rows when triggers have already populated the index (e.g.
308
+ * during seeding where content is inserted before search is enabled).
293
309
  */
294
310
  async enableSearch(
295
311
  collectionSlug: string,
@@ -308,11 +324,8 @@ export class FTSManager {
308
324
  );
309
325
  }
310
326
 
311
- // Create FTS table
312
- await this.createFtsTable(collectionSlug, searchableFields, options?.weights);
313
-
314
- // Populate from existing content
315
- await this.populateFromContent(collectionSlug, searchableFields);
327
+ // Rebuild from scratch to ensure clean state (no duplicate rows)
328
+ await this.rebuildIndex(collectionSlug, searchableFields, options?.weights);
316
329
 
317
330
  // Update search config
318
331
  await this.setSearchConfig(collectionSlug, {
@@ -368,22 +368,21 @@ function escapeQuery(query: string): string {
368
368
  return "";
369
369
  }
370
370
 
371
- // FTS5 special characters that need escaping in terms: " * ^
372
- // We'll wrap terms in quotes to handle most cases
373
- // But first, escape any existing quotes
371
+ // If already a quoted phrase, escape only interior quotes and preserve phrase syntax
372
+ if (query.startsWith('"') && query.endsWith('"') && query.length >= 2) {
373
+ const inner = query.slice(1, -1);
374
+ return `"${inner.replace(DOUBLE_QUOTE_PATTERN, '""')}"`;
375
+ }
376
+
377
+ // Escape any existing quotes
374
378
  const escaped = query.replace(DOUBLE_QUOTE_PATTERN, '""');
375
379
 
376
380
  // If the query contains FTS5 operators (AND, OR, NOT, NEAR),
377
- // pass through as-is (user knows what they're doing)
381
+ // pass through with quotes escaped but operators preserved
378
382
  if (FTS_OPERATORS_PATTERN.test(query)) {
379
383
  return escaped;
380
384
  }
381
385
 
382
- // If already quoted, pass through
383
- if (query.startsWith('"') && query.endsWith('"')) {
384
- return query;
385
- }
386
-
387
386
  // For simple queries, wrap each word to handle special chars
388
387
  const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);
389
388
  if (terms.length === 0) {
package/src/seed/apply.ts CHANGED
@@ -15,6 +15,7 @@ import { ContentRepository } from "../database/repositories/content.js";
15
15
  import { MediaRepository } from "../database/repositories/media.js";
16
16
  import { RedirectRepository } from "../database/repositories/redirect.js";
17
17
  import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
18
+ import { withTransaction } from "../database/transaction.js";
18
19
  import type { Database } from "../database/types.js";
19
20
  import type { MediaValue } from "../fields/types.js";
20
21
  import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js";
@@ -342,7 +343,6 @@ export async function applySeed(
342
343
  // 7. Content (created before menus so refs can resolve)
343
344
  if (includeContent && seed.content) {
344
345
  const contentRepo = new ContentRepository(db);
345
- const bylineRepo = new BylineRepository(db);
346
346
 
347
347
  // Create content entries
348
348
  for (const [collectionSlug, entries] of Object.entries(seed.content)) {
@@ -366,25 +366,30 @@ export async function applySeed(
366
366
  result,
367
367
  );
368
368
 
369
+ // Update content + bylines + taxonomies atomically
369
370
  const status = entry.status || "published";
370
- await contentRepo.update(collectionSlug, existing.id, {
371
- status,
372
- data: resolvedData,
371
+ await withTransaction(db, async (trx) => {
372
+ const trxContentRepo = new ContentRepository(trx);
373
+ const trxBylineRepo = new BylineRepository(trx);
374
+
375
+ await trxContentRepo.update(collectionSlug, existing.id, {
376
+ status,
377
+ data: resolvedData,
378
+ });
379
+
380
+ await applyContentBylines(
381
+ trxBylineRepo,
382
+ collectionSlug,
383
+ existing.id,
384
+ entry,
385
+ seedBylineIdMap,
386
+ true,
387
+ );
388
+ await applyContentTaxonomies(trx, collectionSlug, existing.id, entry, true);
373
389
  });
374
390
 
375
391
  seedIdMap.set(entry.id, existing.id);
376
392
  result.content.updated++;
377
-
378
- // Update bylines and taxonomy assignments
379
- await applyContentBylines(
380
- bylineRepo,
381
- collectionSlug,
382
- existing.id,
383
- entry,
384
- seedBylineIdMap,
385
- true,
386
- );
387
- await applyContentTaxonomies(db, collectionSlug, existing.id, entry, true);
388
393
  continue;
389
394
  }
390
395
 
@@ -410,24 +415,30 @@ export async function applySeed(
410
415
  }
411
416
  }
412
417
 
413
- // Create entry
418
+ // Create entry + bylines + taxonomies atomically
414
419
  const status = entry.status || "published";
415
- const created = await contentRepo.create({
416
- type: collectionSlug,
417
- slug: entry.slug,
418
- status,
419
- data: resolvedData,
420
- locale: entry.locale,
421
- translationOf,
422
- // Set published_at for published content so RSS/Archives work correctly
423
- publishedAt: status === "published" ? new Date().toISOString() : null,
420
+ const created = await withTransaction(db, async (trx) => {
421
+ const trxContentRepo = new ContentRepository(trx);
422
+ const trxBylineRepo = new BylineRepository(trx);
423
+
424
+ const item = await trxContentRepo.create({
425
+ type: collectionSlug,
426
+ slug: entry.slug,
427
+ status,
428
+ data: resolvedData,
429
+ locale: entry.locale,
430
+ translationOf,
431
+ publishedAt: status === "published" ? new Date().toISOString() : null,
432
+ });
433
+
434
+ await applyContentBylines(trxBylineRepo, collectionSlug, item.id, entry, seedBylineIdMap);
435
+ await applyContentTaxonomies(trx, collectionSlug, item.id, entry, false);
436
+
437
+ return item;
424
438
  });
425
439
 
426
440
  seedIdMap.set(entry.id, created.id);
427
441
  result.content.created++;
428
-
429
- await applyContentBylines(bylineRepo, collectionSlug, created.id, entry, seedBylineIdMap);
430
- await applyContentTaxonomies(db, collectionSlug, created.id, entry, false);
431
442
  }
432
443
  }
433
444
  }
@@ -636,6 +647,16 @@ export async function applySeed(
636
647
  }
637
648
  }
638
649
 
650
+ // Invalidate caches that may have been affected by seed data.
651
+ // Seed creates bylines, redirects, and collections, all of which
652
+ // have module-level caches in the hot path.
653
+ const { invalidateBylineCache } = await import("../bylines/index.js");
654
+ const { invalidateRedirectCache } = await import("../redirects/cache.js");
655
+ const { invalidateUrlPatternCache } = await import("../query.js");
656
+ invalidateBylineCache();
657
+ invalidateRedirectCache();
658
+ invalidateUrlPatternCache();
659
+
639
660
  return result;
640
661
  }
641
662