@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,888 @@
1
+ /**
2
+ * Public URLs of content entries, pack-wide.
3
+ *
4
+ * EmDash resolves an entry's URL from its collection's `url_pattern`
5
+ * ({slug}, {id} and WordPress-style date tokens; emdash/src/i18n/resolve.ts
6
+ * interpolateUrlPattern, which isn't exported), falling back to
7
+ * /<collection>/<slug>. Themes can route entries any way they like, though,
8
+ * e.g. WordPress-style /{category}/{slug}/. The site tells the pack about
9
+ * such routes with coywolfPlugin({ urls }):
10
+ *
11
+ * ```js
12
+ * coywolfPlugin({ urls: { posts: "/{term:category|uncategorized}/{slug}/" } })
13
+ * ```
14
+ *
15
+ * Override patterns take EmDash's tokens plus `{term:<taxonomy>}` (the slug
16
+ * of the entry's first term in that taxonomy, ordered the way EmDash's
17
+ * getTermsForEntries orders them: by label) with an optional fallback after
18
+ * a pipe, `{term:category|uncategorized}`. `{category}` is shorthand for
19
+ * exactly that. `{termpath:<taxonomy>|fallback}` is the same term with its
20
+ * ancestors in front, WordPress's hierarchical %category%: `news/seo` for
21
+ * SEO under News. Parents come from EmDash's taxonomy table (`parent_id`),
22
+ * with the `termParents` option filling in terms that have none.
23
+ * `{pagepath}` is the entry's slug with its parents' slugs in front
24
+ * (`apps/coywolf-seo`); EmDash has no parent for entries, so they come from
25
+ * the `pageParents` option. Collections without an override resolve the way
26
+ * EmDash does (getPublicUrl inside the plugin context, url_pattern otherwise).
27
+ *
28
+ * Configuration: coywolfPlugin() options reach createPlugin(), which
29
+ * EmDash's generated plugins module (virtual:emdash/plugins) calls when the
30
+ * Worker isolate starts, so configureContentUrls() runs before any request
31
+ * reaches the middleware or an Astro component. Same pattern as the search
32
+ * module's rate limit options.
33
+ *
34
+ * Sources: every resolver takes either the plugin context (hooks and
35
+ * routes) or the site's D1 database (middleware, Astro components). With a
36
+ * context, terms come from EmDash's getTermsForEntries and non-overridden
37
+ * collections from getPublicUrl (published entries only). With D1, terms are
38
+ * read with the same SQL EmDash uses and url_pattern is interpolated
39
+ * locally. No locale prefix is applied to override patterns.
40
+ */
41
+ import type { PluginContext } from "emdash";
42
+
43
+ // ── Pattern interpolation (pure) ─────────────────────────────────
44
+
45
+ const REPEATED_SLASHES = /\/{2,}/g;
46
+ const DATE_TOKEN = /\{(year|month|day|hour|minute|second)\}/g;
47
+ const DATE_TOKEN_TEST = /\{(year|month|day|hour|minute|second)\}/;
48
+ const TERM_TOKEN = /\{(term|termpath):([A-Za-z0-9_-]+)(?:\|([^{}/]*))?\}/g;
49
+ const UNRESOLVED_TERM = /\{term(?:path)?:[^}]*\}/;
50
+ const PAGE_PATH = "{pagepath}";
51
+ /** Longest ancestor chain followed (cycles stop earlier). */
52
+ const MAX_DEPTH = 32;
53
+ const OFFSETLESS_DATETIME = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
54
+ const pad2 = (n: number) => String(n).padStart(2, "0");
55
+
56
+ export type TrailingSlash = "always" | "never" | "ignore";
57
+
58
+ function parseUtcDate(date: string | Date): Date {
59
+ if (date instanceof Date) return date;
60
+ const offsetless = OFFSETLESS_DATETIME.exec(date);
61
+ return new Date(offsetless ? `${offsetless[1]}T${offsetless[2]}Z` : date);
62
+ }
63
+
64
+ function applyDateTokens(path: string, date: string | Date | null | undefined): string {
65
+ const d = date == null ? null : parseUtcDate(date);
66
+ if (!d || Number.isNaN(d.getTime())) return path;
67
+ const parts: Record<string, string> = {
68
+ year: String(d.getUTCFullYear()),
69
+ month: pad2(d.getUTCMonth() + 1),
70
+ day: pad2(d.getUTCDate()),
71
+ hour: pad2(d.getUTCHours()),
72
+ minute: pad2(d.getUTCMinutes()),
73
+ second: pad2(d.getUTCSeconds()),
74
+ };
75
+ return path.replace(DATE_TOKEN, (match, key: string) => parts[key] ?? match);
76
+ }
77
+
78
+ /** Expand pack shorthands: `{category}` → `{term:category|uncategorized}`. */
79
+ export function expandPattern(pattern: string): string {
80
+ return pattern.replaceAll("{category}", "{term:category|uncategorized}");
81
+ }
82
+
83
+ /** Taxonomies named by `{term:…}` and `{termpath:…}` tokens. */
84
+ export function patternTaxonomies(pattern: string | null): string[] {
85
+ if (!pattern) return [];
86
+ return [...new Set([...expandPattern(pattern).matchAll(TERM_TOKEN)].map((m) => m[2] as string))];
87
+ }
88
+
89
+ /** Taxonomies named by `{termpath:…}` tokens (those that need term parents). */
90
+ export function patternTermPathTaxonomies(pattern: string | null): string[] {
91
+ if (!pattern) return [];
92
+ return [...new Set([...expandPattern(pattern).matchAll(TERM_TOKEN)].filter((m) => m[1] === "termpath").map((m) => m[2] as string))];
93
+ }
94
+
95
+ /** True when the pattern has a `{pagepath}` token. */
96
+ export function patternUsesPagePath(pattern: string | null): boolean {
97
+ return !!pattern && pattern.includes(PAGE_PATH);
98
+ }
99
+
100
+ /**
101
+ * A slug with its ancestors in front (root first), following `parents`
102
+ * (slug → parent slug). Stops at a cycle or after 32 levels.
103
+ */
104
+ export function ancestorTrail(slug: string, parents: ReadonlyMap<string, string> | Record<string, string> | null | undefined): string[] {
105
+ const trail = [slug];
106
+ if (!parents) return trail;
107
+ const get = parents instanceof Map ? (s: string) => parents.get(s) : (s: string) => (Object.hasOwn(parents, s) ? (parents as Record<string, string>)[s] : undefined);
108
+ let up = get(slug);
109
+ while (up && !trail.includes(up) && trail.length < MAX_DEPTH) {
110
+ trail.unshift(up);
111
+ up = get(up);
112
+ }
113
+ return trail;
114
+ }
115
+
116
+ const encodeSegments = (segments: string[]) => segments.map((s) => encodeURIComponent(s)).join("/");
117
+
118
+ /** True when the pattern needs a publish date to resolve. */
119
+ export function patternUsesDate(pattern: string | null): boolean {
120
+ return !!pattern && DATE_TOKEN_TEST.test(pattern);
121
+ }
122
+
123
+ /**
124
+ * EmDash's interpolateUrlPattern, plus `{term:<taxonomy>|fallback}` tokens
125
+ * filled from `terms` (taxonomy → first term slug), `{termpath:…}` from
126
+ * `termPaths` (taxonomy → the term's slugs, root first; the bare term when
127
+ * missing) and `{pagepath}` from `pagePath` (the entry's slugs, root first;
128
+ * the slug when missing). A term token with no term and no fallback stays
129
+ * literal, like a date token without a date. The result has no trailing
130
+ * slash (see applyTrailingSlash).
131
+ */
132
+ export function interpolateUrlPattern(options: {
133
+ pattern: string | null;
134
+ collection: string;
135
+ slug: string;
136
+ id: string;
137
+ date?: string | Date | null;
138
+ terms?: Record<string, string | null | undefined>;
139
+ termPaths?: Record<string, string[] | null | undefined>;
140
+ pagePath?: string[] | null;
141
+ }): string {
142
+ const { pattern, collection, slug, id, date, terms, termPaths, pagePath } = options;
143
+ const basePattern = pattern == null ? `/${encodeURIComponent(collection)}/{slug}` : expandPattern(pattern);
144
+ let path = basePattern.replace(TERM_TOKEN, (match, kind: string, taxonomy: string, fallback: string | undefined) => {
145
+ const trail = kind === "termpath" ? termPaths?.[taxonomy] : null;
146
+ if (trail?.length) return encodeSegments(trail);
147
+ const term = terms?.[taxonomy];
148
+ if (term) return encodeURIComponent(term);
149
+ return fallback ? fallback : match;
150
+ });
151
+ path = path.replaceAll(PAGE_PATH, pagePath?.length ? encodeSegments(pagePath) : encodeURIComponent(slug));
152
+ path = path.replaceAll("{slug}", encodeURIComponent(slug)).replaceAll("{id}", encodeURIComponent(id));
153
+ path = applyDateTokens(path, date);
154
+ path = path.replace(REPEATED_SLASHES, "/");
155
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
156
+ if (!path.startsWith("/")) path = `/${path}`;
157
+ return path;
158
+ }
159
+
160
+ /**
161
+ * Astro's trailingSlash policy, as EmDash applies it. With "ignore", an
162
+ * override pattern keeps its own trailing slash (`/{slug}/` → `/a/`), while
163
+ * EmDash's url_pattern resolution never has one.
164
+ */
165
+ export function applyTrailingSlash(path: string, policy: TrailingSlash | undefined, patternHasSlash = false): string {
166
+ if (path === "/") return path;
167
+ const bare = path.replace(/\/+$/, "");
168
+ if (policy === "always" || (policy !== "never" && patternHasSlash)) return `${bare}/`;
169
+ return bare;
170
+ }
171
+
172
+ /** The path an override pattern gives an entry, or null when a term token can't be filled. */
173
+ export function overridePath(
174
+ pattern: string,
175
+ entry: {
176
+ collection: string;
177
+ id: string;
178
+ slug: string;
179
+ date?: string | Date | null;
180
+ terms?: Record<string, string | null | undefined>;
181
+ termPaths?: Record<string, string[] | null | undefined>;
182
+ pagePath?: string[] | null;
183
+ },
184
+ policy: TrailingSlash | undefined,
185
+ ): string | null {
186
+ const expanded = expandPattern(pattern);
187
+ const path = interpolateUrlPattern({ pattern: expanded, ...entry });
188
+ if (UNRESOLVED_TERM.test(path)) return null;
189
+ return applyTrailingSlash(path, policy, expanded.endsWith("/"));
190
+ }
191
+
192
+ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
193
+ const ANY_TOKEN = /\{(?:term(?:path)?:[A-Za-z0-9_-]+(?:\|[^{}/]*)?|[a-z]+)\}/g;
194
+ const ONE_SEGMENT = "([^/]+)";
195
+ const SEGMENTS = "([^/]+(?:/[^/]+)*)";
196
+
197
+ /**
198
+ * Compile a pattern into a regex over decoded paths without a trailing
199
+ * slash. Captures the {slug} and {id} tokens (and {pagepath}, whose last
200
+ * segment is the slug). `{termpath:…}` and `{pagepath}` match one or more
201
+ * segments, other tokens one.
202
+ */
203
+ export function compilePattern(pattern: string): { regex: RegExp; slug: number; id: number; pagePath: number } {
204
+ let source = expandPattern(pattern).replace(REPEATED_SLASHES, "/");
205
+ if (!source.startsWith("/")) source = `/${source}`;
206
+ if (source.length > 1) source = source.replace(/\/+$/, "");
207
+ let regex = "";
208
+ let last = 0;
209
+ let group = 0;
210
+ let slug = 0;
211
+ let id = 0;
212
+ let pagePath = 0;
213
+ for (const m of source.matchAll(ANY_TOKEN)) {
214
+ regex += escapeRegex(source.slice(last, m.index));
215
+ group++;
216
+ if (m[0] === "{slug}" && !slug) slug = group;
217
+ if (m[0] === "{id}" && !id) id = group;
218
+ if (m[0] === PAGE_PATH && !pagePath) pagePath = group;
219
+ regex += m[0] === PAGE_PATH || m[0].startsWith("{termpath:") ? SEGMENTS : ONE_SEGMENT;
220
+ last = (m.index ?? 0) + m[0].length;
221
+ }
222
+ regex += escapeRegex(source.slice(last));
223
+ return { regex: new RegExp(`^${regex}$`), slug, id, pagePath };
224
+ }
225
+
226
+ const safeDecode = (path: string) => {
227
+ try {
228
+ return decodeURI(path);
229
+ } catch {
230
+ return path;
231
+ }
232
+ };
233
+ /** Comparable form of a path: decoded, no trailing slash. */
234
+ export const comparablePath = (path: string) => {
235
+ const p = safeDecode(path.split(/[?#]/)[0]);
236
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
237
+ };
238
+
239
+ // ── Configuration ────────────────────────────────────────────────
240
+
241
+ /** Collection slugs are validated before being used as table names. */
242
+ export const COLLECTION_SLUG = /^[a-z][a-z0-9_]*$/;
243
+
244
+ export interface ContentUrlOptions {
245
+ /** Collection → URL pattern, for collections the theme routes differently from EmDash's url_pattern. */
246
+ urls?: Record<string, string>;
247
+ /** Trailing-slash policy for the pack's entry URLs. Default: EmDash's (Astro's `trailingSlash`). */
248
+ trailingSlash?: TrailingSlash;
249
+ /**
250
+ * Taxonomy → term slug → parent term slug, for `{termpath:…}`. Used only
251
+ * for terms with no parent in EmDash (e.g. before parents are restored
252
+ * after a WordPress import).
253
+ */
254
+ termParents?: Record<string, Record<string, string>>;
255
+ /** Entry slug → parent entry slug, for `{pagepath}` (EmDash entries have no parent). */
256
+ pageParents?: Record<string, string>;
257
+ }
258
+
259
+ interface UrlConfig {
260
+ urls: Map<string, string>;
261
+ trailingSlash?: TrailingSlash;
262
+ termParents: Map<string, Map<string, string>>;
263
+ pageParents: Map<string, string>;
264
+ }
265
+
266
+ let config: UrlConfig = { urls: new Map(), termParents: new Map(), pageParents: new Map() };
267
+
268
+ const TAXONOMY_NAME = /^[A-Za-z0-9_-]+$/;
269
+
270
+ /** A slug → parent slug map from an option, dropping non-string and self-parent entries. */
271
+ function parentMap(input: unknown): Map<string, string> {
272
+ const out = new Map<string, string>();
273
+ if (!input || typeof input !== "object") return out;
274
+ for (const [slug, parent] of Object.entries(input as Record<string, unknown>)) {
275
+ if (typeof parent === "string" && parent.trim() && slug && parent.trim() !== slug) out.set(slug, parent.trim());
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** Set from coywolfPlugin()/createPlugin() options. Invalid collection slugs are ignored. */
281
+ export function configureContentUrls(options: ContentUrlOptions = {}): void {
282
+ const urls = new Map<string, string>();
283
+ for (const [collection, pattern] of Object.entries(options.urls ?? {})) {
284
+ if (COLLECTION_SLUG.test(collection) && typeof pattern === "string" && pattern.trim()) urls.set(collection, expandPattern(pattern.trim()));
285
+ }
286
+ const termParents = new Map<string, Map<string, string>>();
287
+ for (const [taxonomy, map] of Object.entries(options.termParents ?? {})) {
288
+ if (TAXONOMY_NAME.test(taxonomy)) termParents.set(taxonomy, parentMap(map));
289
+ }
290
+ config = { urls, trailingSlash: options.trailingSlash, termParents, pageParents: parentMap(options.pageParents) };
291
+ }
292
+
293
+ /** The entry's slugs, root first, from the `pageParents` option. */
294
+ export function pageTrail(slug: string): string[] {
295
+ return ancestorTrail(slug, config.pageParents);
296
+ }
297
+
298
+ /** The URL configuration as text, for cache keys (answers with URLs change when it does). */
299
+ export function contentUrlFingerprint(): string {
300
+ const pairs = (m: Map<string, unknown>) => [...m.entries()].map(([k, v]) => [k, v instanceof Map ? [...v.entries()] : v]);
301
+ return JSON.stringify([pairs(config.urls), config.trailingSlash ?? "", pairs(config.termParents), pairs(config.pageParents)]);
302
+ }
303
+
304
+ /** Collections the `urls` option routes. */
305
+ export function overriddenCollections(): string[] {
306
+ return [...config.urls.keys()];
307
+ }
308
+
309
+ /** The configured override pattern for a collection (shorthands expanded), if any. */
310
+ export function urlOverride(collection: string): string | null {
311
+ return config.urls.get(collection) ?? null;
312
+ }
313
+
314
+ // ── Sources ──────────────────────────────────────────────────────
315
+
316
+ /** The plugin context, or the parts of it URL resolution uses. */
317
+ export type UrlContext = Pick<PluginContext, "site" | "content" | "schema">;
318
+ export type UrlSource = UrlContext | D1Database;
319
+
320
+ const isD1 = (source: UrlSource): source is D1Database => typeof (source as D1Database).prepare === "function";
321
+
322
+ interface HostConfig {
323
+ trailingSlash?: TrailingSlash;
324
+ defaultLocale?: string;
325
+ }
326
+ let hostConfig: Promise<HostConfig> | null = null;
327
+ /** EmDash's build-time config (virtual:emdash/config), as menus read it. Empty outside a site build. */
328
+ function readHostConfig(): Promise<HostConfig> {
329
+ hostConfig ??= (async () => {
330
+ try {
331
+ // @ts-ignore: provided by EmDash's Vite integration in the site build; absent under Node tests.
332
+ const mod = (await import("virtual:emdash/config")) as { default?: { trailingSlash?: TrailingSlash; i18n?: { defaultLocale?: string } | null } };
333
+ return { trailingSlash: mod.default?.trailingSlash, defaultLocale: mod.default?.i18n?.defaultLocale };
334
+ } catch {
335
+ return {};
336
+ }
337
+ })();
338
+ return hostConfig;
339
+ }
340
+
341
+ async function trailingPolicy(source: UrlSource): Promise<TrailingSlash> {
342
+ if (config.trailingSlash) return config.trailingSlash;
343
+ if (!isD1(source) && source.site.trailingSlash) return source.site.trailingSlash;
344
+ return (await readHostConfig()).trailingSlash ?? "ignore";
345
+ }
346
+
347
+ const SQL_CHUNK = 50;
348
+ function chunks<T>(items: T[], size = SQL_CHUNK): T[][] {
349
+ const out: T[][] = [];
350
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
351
+ return out;
352
+ }
353
+
354
+ /** A SQL statement and its bound values, for callers that batch statements (D1 batch). */
355
+ export interface SqlQuery {
356
+ sql: string;
357
+ binds: unknown[];
358
+ }
359
+
360
+ export interface PrimaryTermRow {
361
+ entry_id: string;
362
+ name: string;
363
+ slug: string;
364
+ }
365
+
366
+ /** The site's default locale (EmDash's i18n config; "en" without one). */
367
+ export async function hostDefaultLocale(): Promise<string> {
368
+ return (await readHostConfig()).defaultLocale ?? "en";
369
+ }
370
+
371
+ /**
372
+ * EmDash's selectEntryTermRows SQL for the given entries (or, with `ids`
373
+ * null, every published entry of the collection), for all wanted taxonomies
374
+ * at once, ordered by label. Feed the rows to collectPrimaryTerms.
375
+ */
376
+ export function primaryTermsQuery(collection: string, ids: string[] | null, taxonomies: string[], defaultLocale: string): SqlQuery {
377
+ const where = ids ? `content.id IN (${ids.map(() => "?").join(",")})` : "content.status = 'published' AND content.deleted_at IS NULL";
378
+ return {
379
+ sql: `SELECT content.id AS entry_id,
380
+ coalesce(exact_term.name, default_term.name) AS name,
381
+ coalesce(exact_term.slug, default_term.slug) AS slug
382
+ FROM "ec_${collection}" AS content
383
+ INNER JOIN content_taxonomies AS pivot
384
+ ON pivot.entry_id = content.translation_group
385
+ AND pivot.collection = ?
386
+ LEFT JOIN taxonomies AS exact_term
387
+ ON exact_term.translation_group = pivot.taxonomy_id
388
+ AND exact_term.locale = content.locale
389
+ LEFT JOIN taxonomies AS default_term
390
+ ON default_term.translation_group = pivot.taxonomy_id
391
+ AND default_term.locale = ?
392
+ WHERE ${where}
393
+ AND coalesce(exact_term.id, default_term.id) IS NOT NULL
394
+ AND coalesce(exact_term.name, default_term.name) IN (${taxonomies.map(() => "?").join(",")})
395
+ ORDER BY coalesce(exact_term.label, default_term.label) ASC`,
396
+ binds: [collection, defaultLocale, ...(ids ?? []), ...taxonomies],
397
+ };
398
+ }
399
+
400
+ /** Fold primaryTermsQuery rows into entry id → taxonomy → first term slug (entries missing from `out` are added). */
401
+ export function collectPrimaryTerms(rows: PrimaryTermRow[], out: Map<string, Record<string, string>> = new Map()): Map<string, Record<string, string>> {
402
+ for (const row of rows) {
403
+ let terms = out.get(row.entry_id);
404
+ if (!terms) {
405
+ terms = {};
406
+ out.set(row.entry_id, terms);
407
+ }
408
+ if (row.slug && !(row.name in terms)) terms[row.name] = row.slug;
409
+ }
410
+ return out;
411
+ }
412
+
413
+ export interface TermParentRow {
414
+ name: string;
415
+ id: string;
416
+ slug: string;
417
+ parent_id: string | null;
418
+ locale: string | null;
419
+ translation_group: string | null;
420
+ }
421
+
422
+ /** Taxonomy names usable in SQL and options. */
423
+ export function validTaxonomies(taxonomies: string[]): string[] {
424
+ return [...new Set(taxonomies)].filter((t) => TAXONOMY_NAME.test(t));
425
+ }
426
+
427
+ /** The taxonomy rows termParentsFromRows needs, in one query. */
428
+ export function termParentsQuery(taxonomies: string[]): SqlQuery {
429
+ const wanted = validTaxonomies(taxonomies);
430
+ return { sql: `SELECT name, id, slug, parent_id, locale, translation_group FROM taxonomies WHERE name IN (${wanted.map(() => "?").join(",")})`, binds: wanted };
431
+ }
432
+
433
+ /** Term parents per taxonomy from termParentsQuery rows, then the `termParents` option for terms with none. */
434
+ export function termParentsFromRows(input: TermParentRow[], taxonomies: string[], defaultLocale: string): Map<string, Map<string, string>> {
435
+ const wanted = validTaxonomies(taxonomies);
436
+ const out = new Map<string, Map<string, string>>();
437
+ for (const taxonomy of wanted) out.set(taxonomy, new Map());
438
+ // Default locale first, so its slugs win where locales disagree.
439
+ const rows = [...input].sort((a, b) => Number(b.locale === defaultLocale) - Number(a.locale === defaultLocale));
440
+ const slugOf = new Map<string, string>();
441
+ for (const r of rows) {
442
+ for (const ref of [r.translation_group ?? r.id, r.id]) {
443
+ for (const key of [`${r.name}|${r.locale ?? ""}|${ref}`, `${r.name}||*|${ref}`]) if (!slugOf.has(key)) slugOf.set(key, r.slug);
444
+ }
445
+ }
446
+ for (const r of rows) {
447
+ if (!r.parent_id || !r.slug) continue;
448
+ const parent = slugOf.get(`${r.name}|${r.locale ?? ""}|${r.parent_id}`) ?? slugOf.get(`${r.name}||*|${r.parent_id}`);
449
+ const map = out.get(r.name);
450
+ if (map && parent && parent !== r.slug && !map.has(r.slug)) map.set(r.slug, parent);
451
+ }
452
+ applyTermParentOption(out, wanted);
453
+ return out;
454
+ }
455
+
456
+ function applyTermParentOption(out: Map<string, Map<string, string>>, wanted: string[]): void {
457
+ for (const taxonomy of wanted) {
458
+ const fallback = config.termParents.get(taxonomy);
459
+ if (!fallback) continue;
460
+ const map = out.get(taxonomy)!;
461
+ for (const [slug, parent] of fallback) if (!map.has(slug)) map.set(slug, parent);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * First term slug per entry for each taxonomy, ordered as EmDash's
467
+ * getTermsForEntries orders them (label ascending, locale-resolved terms).
468
+ */
469
+ export async function primaryTerms(source: UrlSource, collection: string, ids: string[], taxonomies: string[]): Promise<Map<string, Record<string, string>>> {
470
+ const out = new Map<string, Record<string, string>>();
471
+ const unique = [...new Set(ids)];
472
+ for (const id of unique) out.set(id, {});
473
+ if (!unique.length || !taxonomies.length || !COLLECTION_SLUG.test(collection)) return out;
474
+
475
+ if (!isD1(source)) {
476
+ const { getTermsForEntries } = await import("emdash");
477
+ for (const taxonomy of taxonomies) {
478
+ const map = await getTermsForEntries(collection, unique, taxonomy).catch(() => new Map());
479
+ for (const [id, terms] of map as Map<string, Array<{ slug: string }>>) {
480
+ const first = terms[0]?.slug;
481
+ if (first) out.get(id)![taxonomy] = first;
482
+ }
483
+ }
484
+ return out;
485
+ }
486
+
487
+ // The SQL of EmDash's selectEntryTermRows (taxonomies/index.ts), for all wanted taxonomies at once.
488
+ const defaultLocale = await hostDefaultLocale();
489
+ for (const chunk of chunks(unique)) {
490
+ let rows: PrimaryTermRow[];
491
+ try {
492
+ const q = primaryTermsQuery(collection, chunk, taxonomies, defaultLocale);
493
+ rows = (await source.prepare(q.sql).bind(...q.binds).all<PrimaryTermRow>()).results;
494
+ } catch {
495
+ return out; // No taxonomy tables yet: no terms (fallbacks apply).
496
+ }
497
+ collectPrimaryTerms(rows, out);
498
+ }
499
+ return out;
500
+ }
501
+
502
+ /**
503
+ * Term parents per taxonomy (term slug → parent term slug), read in one
504
+ * query: EmDash's `taxonomies.parent_id` (the parent's translation_group, or
505
+ * a row id before EmDash's migration 045), then the `termParents` option for
506
+ * terms with no parent in the database. A parent in the entry's own locale
507
+ * wins; otherwise the default locale's slug is used.
508
+ */
509
+ export async function termParentMaps(source: UrlSource, taxonomies: string[]): Promise<Map<string, Map<string, string>>> {
510
+ const wanted = [...new Set(taxonomies)].filter((t) => TAXONOMY_NAME.test(t));
511
+ const out = new Map<string, Map<string, string>>();
512
+ for (const taxonomy of wanted) out.set(taxonomy, new Map());
513
+ if (!wanted.length) return out;
514
+
515
+ if (!isD1(source)) {
516
+ const { getTaxonomyTerms } = await import("emdash");
517
+ type Node = { slug: string; children?: Node[] };
518
+ for (const taxonomy of wanted) {
519
+ const roots = (await getTaxonomyTerms(taxonomy, { includeCounts: false }).catch(() => [])) as Node[];
520
+ const map = out.get(taxonomy)!;
521
+ const walk = (nodes: Node[], parent: string | null, depth: number) => {
522
+ if (depth > MAX_DEPTH) return;
523
+ for (const node of nodes) {
524
+ if (parent && node.slug && node.slug !== parent && !map.has(node.slug)) map.set(node.slug, parent);
525
+ if (node.children?.length) walk(node.children, node.slug, depth + 1);
526
+ }
527
+ };
528
+ walk(roots, null, 0);
529
+ }
530
+ applyTermParentOption(out, wanted);
531
+ return out;
532
+ }
533
+ let rows: TermParentRow[] = [];
534
+ try {
535
+ const q = termParentsQuery(wanted);
536
+ rows = (await source.prepare(q.sql).bind(...q.binds).all<TermParentRow>()).results;
537
+ } catch {
538
+ rows = []; // No taxonomy table yet: only the option applies.
539
+ }
540
+ return termParentsFromRows(rows, wanted, await hostDefaultLocale());
541
+ }
542
+
543
+ // ── Collections (D1) ─────────────────────────────────────────────
544
+
545
+ export interface CollectionInfo {
546
+ slug: string;
547
+ label: string;
548
+ labelSingular: string | null;
549
+ urlPattern: string | null;
550
+ titleField: string | null;
551
+ }
552
+
553
+ /** Read collection metadata from the site's D1 database. */
554
+ export async function readCollections(db: D1Database, slugs?: string[]): Promise<Map<string, CollectionInfo>> {
555
+ const wanted = slugs?.filter((s) => COLLECTION_SLUG.test(s));
556
+ if (wanted && wanted.length === 0) return new Map();
557
+ const sql = wanted?.length
558
+ ? `SELECT slug, label, label_singular, url_pattern, title_field FROM _emdash_collections WHERE slug IN (${wanted.map(() => "?").join(",")})`
559
+ : "SELECT slug, label, label_singular, url_pattern, title_field FROM _emdash_collections";
560
+ const { results } = await db
561
+ .prepare(sql)
562
+ .bind(...(wanted ?? []))
563
+ .all<{ slug: string; label: string; label_singular: string | null; url_pattern: string | null; title_field: string | null }>();
564
+ const out = new Map<string, CollectionInfo>();
565
+ for (const r of results) {
566
+ out.set(r.slug, { slug: r.slug, label: r.label, labelSingular: r.label_singular, urlPattern: r.url_pattern, titleField: r.title_field });
567
+ }
568
+ return out;
569
+ }
570
+
571
+ // ── Entry URLs ───────────────────────────────────────────────────
572
+
573
+ export interface EntryRef {
574
+ id: string;
575
+ slug?: string | null;
576
+ /** Publish date (for date tokens). `date` is accepted as an alias. */
577
+ publishedAt?: string | Date | null;
578
+ date?: string | Date | null;
579
+ locale?: string | null;
580
+ /** When known and not "published", context sources answer null (like getPublicUrl). */
581
+ status?: string | null;
582
+ /** First term slug per taxonomy, when the caller already read them (primaryTermsQuery); skips the terms query. */
583
+ terms?: Record<string, string>;
584
+ }
585
+
586
+ /** What a caller already knows, so the resolver doesn't read it again. */
587
+ export interface EntryUrlResolverOptions {
588
+ /** Collection info (D1 sources): route patterns come from here instead of a query per collection. */
589
+ collections?: ReadonlyMap<string, CollectionInfo>;
590
+ /** Term parents per taxonomy (e.g. from a per-isolate cache), instead of termParentMaps. */
591
+ termParents?: (taxonomies: string[]) => Promise<Map<string, Map<string, string>>>;
592
+ }
593
+
594
+ interface CollectionRoute {
595
+ /** Override pattern, or EmDash's url_pattern (null = /<collection>/{slug}). */
596
+ pattern: string | null;
597
+ override: boolean;
598
+ routable: boolean;
599
+ }
600
+
601
+ export interface EntryUrlResolver {
602
+ /** Root-relative paths keyed by entry id (null: no public URL). */
603
+ urls(collection: string, entries: EntryRef[]): Promise<Map<string, string | null>>;
604
+ url(collection: string, entry: EntryRef): Promise<string | null>;
605
+ }
606
+
607
+ /**
608
+ * A resolver that remembers collection info, the trailing-slash policy and
609
+ * (with a context) whether local interpolation matches getPublicUrl for each
610
+ * collection and locale. Use one per request or build.
611
+ */
612
+ export function createEntryUrlResolver(source: UrlSource, options: EntryUrlResolverOptions = {}): EntryUrlResolver {
613
+ const routes = new Map<string, Promise<CollectionRoute | null>>();
614
+ const verdicts = new Map<string, "local" | "exact">();
615
+ let policy: Promise<TrailingSlash> | null = null;
616
+ const getPolicy = () => (policy ??= trailingPolicy(source));
617
+ const parents = new Map<string, Promise<Map<string, string>>>();
618
+
619
+ /** Term parents per taxonomy, loaded once per resolver (taxonomies not seen yet in one query). */
620
+ async function termParents(taxonomies: string[]): Promise<Map<string, Map<string, string>>> {
621
+ const missing = taxonomies.filter((t) => !parents.has(t));
622
+ if (missing.length) {
623
+ const batch = (options.termParents ? options.termParents(missing) : termParentMaps(source, missing)).catch(() => new Map<string, Map<string, string>>());
624
+ for (const t of missing) parents.set(t, batch.then((m) => m.get(t) ?? new Map()));
625
+ }
626
+ const out = new Map<string, Map<string, string>>();
627
+ for (const t of taxonomies) out.set(t, await parents.get(t)!);
628
+ return out;
629
+ }
630
+
631
+ function route(collection: string): Promise<CollectionRoute | null> {
632
+ let r = routes.get(collection);
633
+ if (!r) {
634
+ r = (async (): Promise<CollectionRoute | null> => {
635
+ const override = urlOverride(collection);
636
+ if (override) return { pattern: override, override: true, routable: true };
637
+ if (isD1(source)) {
638
+ const known = options.collections;
639
+ const info = known ? known.get(collection) : (await readCollections(source, [collection]).catch(() => new Map<string, CollectionInfo>())).get(collection);
640
+ return { pattern: info?.urlPattern ?? null, override: false, routable: true };
641
+ }
642
+ const info = await source.schema?.getCollection(collection).catch(() => null);
643
+ if (!info) return null;
644
+ return { pattern: info.urlPattern ?? null, override: false, routable: info.routable };
645
+ })();
646
+ routes.set(collection, r);
647
+ }
648
+ return r;
649
+ }
650
+
651
+ /** Publish dates for entries that didn't bring one, when the pattern needs them (D1 only). */
652
+ async function fillDates(collection: string, entries: EntryRef[]): Promise<Map<string, string | null>> {
653
+ const dates = new Map<string, string | null>();
654
+ const missing = entries.filter((e) => e.publishedAt === undefined && e.date === undefined).map((e) => e.id);
655
+ if (!missing.length || !isD1(source) || !COLLECTION_SLUG.test(collection)) return dates;
656
+ for (const chunk of chunks([...new Set(missing)])) {
657
+ try {
658
+ const { results } = await source
659
+ .prepare(`SELECT id, published_at FROM "ec_${collection}" WHERE id IN (${chunk.map(() => "?").join(",")})`)
660
+ .bind(...chunk)
661
+ .all<{ id: string; published_at: string | null }>();
662
+ for (const r of results) dates.set(r.id, r.published_at);
663
+ } catch {
664
+ // Leave date tokens unresolved rather than fail.
665
+ }
666
+ }
667
+ return dates;
668
+ }
669
+
670
+ async function exact(collection: string, id: string): Promise<string | null> {
671
+ if (isD1(source)) return null;
672
+ const url = await source.content?.getPublicUrl?.(collection, id).catch(() => null);
673
+ if (!url) return null;
674
+ try {
675
+ const path = new URL(url).pathname;
676
+ // A pack-level trailingSlash option wins over EmDash's.
677
+ return config.trailingSlash ? applyTrailingSlash(path, config.trailingSlash) : path;
678
+ } catch {
679
+ return null;
680
+ }
681
+ }
682
+
683
+ async function urls(collection: string, entries: EntryRef[]): Promise<Map<string, string | null>> {
684
+ const out = new Map<string, string | null>();
685
+ const r = await route(collection);
686
+ const usable = entries.filter((e) => {
687
+ const ok = !!r?.routable && !!e.slug && (isD1(source) || !e.status || e.status === "published");
688
+ if (!ok) out.set(e.id, null);
689
+ return ok;
690
+ });
691
+ if (!r || !usable.length) return out;
692
+ const trailing = await getPolicy();
693
+ const dates = patternUsesDate(r.pattern) ? await fillDates(collection, usable) : new Map<string, string | null>();
694
+ const dateOf = (e: EntryRef) => e.publishedAt ?? e.date ?? dates.get(e.id) ?? null;
695
+
696
+ if (r.override && r.pattern) {
697
+ const taxonomies = patternTaxonomies(r.pattern);
698
+ const pathTaxonomies = patternTermPathTaxonomies(r.pattern);
699
+ const usesPagePath = patternUsesPagePath(r.pattern);
700
+ const prefilled = usable.every((e) => e.terms !== undefined);
701
+ const [terms, termParentsByTaxonomy] = await Promise.all([
702
+ !taxonomies.length
703
+ ? new Map<string, Record<string, string>>()
704
+ : prefilled
705
+ ? new Map(usable.map((e) => [e.id, e.terms as Record<string, string>]))
706
+ : primaryTerms(source, collection, usable.map((e) => e.id), taxonomies),
707
+ pathTaxonomies.length ? termParents(pathTaxonomies) : new Map<string, Map<string, string>>(),
708
+ ]);
709
+ for (const e of usable) {
710
+ const entryTerms = terms.get(e.id);
711
+ let termPaths: Record<string, string[]> | undefined;
712
+ for (const taxonomy of pathTaxonomies) {
713
+ const leaf = entryTerms?.[taxonomy];
714
+ if (leaf) (termPaths ??= {})[taxonomy] = ancestorTrail(leaf, termParentsByTaxonomy.get(taxonomy));
715
+ }
716
+ const slug = e.slug as string;
717
+ out.set(e.id, overridePath(r.pattern, { collection, id: e.id, slug, date: dateOf(e), terms: entryTerms, termPaths, pagePath: usesPagePath ? pageTrail(slug) : null }, trailing));
718
+ }
719
+ return out;
720
+ }
721
+
722
+ for (const e of usable) {
723
+ const local = applyTrailingSlash(interpolateUrlPattern({ pattern: r.pattern, collection, slug: e.slug as string, id: e.id, date: dateOf(e) }), trailing);
724
+ if (isD1(source) || !source.content?.getPublicUrl) {
725
+ out.set(e.id, local);
726
+ continue;
727
+ }
728
+ // Context: getPublicUrl is authoritative (locale prefixes). Verify local interpolation once per collection and locale.
729
+ const group = `${collection}|${e.locale ?? ""}`;
730
+ const verdict = verdicts.get(group);
731
+ if (verdict === "local") out.set(e.id, local);
732
+ else if (verdict === "exact") out.set(e.id, await exact(collection, e.id));
733
+ else {
734
+ const real = await exact(collection, e.id);
735
+ if (real !== null) verdicts.set(group, real === local ? "local" : "exact");
736
+ out.set(e.id, real);
737
+ }
738
+ }
739
+ return out;
740
+ }
741
+
742
+ return {
743
+ urls,
744
+ async url(collection, entry) {
745
+ return (await urls(collection, [entry])).get(entry.id) ?? null;
746
+ },
747
+ };
748
+ }
749
+
750
+ /** Root-relative public path of one entry, or null. */
751
+ export async function entryUrl(source: UrlSource, collection: string, entry: EntryRef): Promise<string | null> {
752
+ return createEntryUrlResolver(source).url(collection, entry);
753
+ }
754
+
755
+ /** Root-relative public paths of a list of entries (terms and dates read in batches), keyed by id. */
756
+ export async function entryUrls(source: UrlSource, collection: string, entries: EntryRef[]): Promise<Map<string, string | null>> {
757
+ return createEntryUrlResolver(source).urls(collection, entries);
758
+ }
759
+
760
+ /** An absolute URL from a root-relative path and the site URL. */
761
+ export function absoluteUrl(path: string, siteUrl: string): string {
762
+ return `${siteUrl.replace(/\/+$/, "")}${path}`;
763
+ }
764
+
765
+ // ── Reverse: path → entry ────────────────────────────────────────
766
+
767
+ export interface MatchedEntry {
768
+ collection: string;
769
+ id: string;
770
+ /** The entry's canonical path (equal to the requested path, ignoring a trailing slash and encoding). */
771
+ path: string;
772
+ }
773
+
774
+ interface Candidate extends EntryRef {
775
+ slug: string;
776
+ }
777
+
778
+ /** Published entries of a collection with this slug (or id). */
779
+ async function findPublished(source: UrlSource, collection: string, by: "slug" | "id", value: string): Promise<Candidate[]> {
780
+ if (!COLLECTION_SLUG.test(collection)) return [];
781
+ if (isD1(source)) {
782
+ try {
783
+ const { results } = await source
784
+ .prepare(`SELECT id, slug, published_at, locale FROM "ec_${collection}" WHERE ${by} = ? AND status = 'published' AND deleted_at IS NULL LIMIT 20`)
785
+ .bind(value)
786
+ .all<{ id: string; slug: string | null; published_at: string | null; locale: string | null }>();
787
+ return results.filter((r) => r.slug).map((r) => ({ id: r.id, slug: r.slug as string, publishedAt: r.published_at, locale: r.locale, status: "published" }));
788
+ } catch {
789
+ return [];
790
+ }
791
+ }
792
+ let id = value;
793
+ if (by === "slug") {
794
+ const { getEmDashEntry } = await import("emdash");
795
+ const { entry } = await getEmDashEntry(collection, value).catch(() => ({ entry: null }));
796
+ if (!entry) return [];
797
+ const dataId = (entry.data as Record<string, unknown>).id;
798
+ id = typeof dataId === "string" && dataId ? dataId : entry.id;
799
+ }
800
+ const item = await source.content?.get(collection, id).catch(() => null);
801
+ if (!item || item.status !== "published" || !item.slug) return [];
802
+ return [{ id: item.id, slug: item.slug, publishedAt: item.publishedAt, locale: item.locale, status: item.status }];
803
+ }
804
+
805
+ async function verify(resolver: EntryUrlResolver, collection: string, candidates: Candidate[], wanted: string): Promise<MatchedEntry | null> {
806
+ if (!candidates.length) return null;
807
+ const urls = await resolver.urls(collection, candidates);
808
+ for (const c of candidates) {
809
+ const path = urls.get(c.id);
810
+ if (path && comparablePath(path) === wanted) return { collection, id: c.id, path };
811
+ }
812
+ return null;
813
+ }
814
+
815
+ async function matchPattern(source: UrlSource, resolver: EntryUrlResolver, collection: string, pattern: string, wanted: string): Promise<MatchedEntry | null> {
816
+ let compiled: ReturnType<typeof compilePattern>;
817
+ try {
818
+ compiled = compilePattern(pattern);
819
+ } catch {
820
+ return null;
821
+ }
822
+ const m = compiled.regex.exec(wanted);
823
+ if (!m) return null;
824
+ const by = compiled.slug || compiled.pagePath ? "slug" : compiled.id ? "id" : null;
825
+ if (!by) return null;
826
+ // {pagepath}: the slug is its last segment.
827
+ const value = compiled.slug ? m[compiled.slug] : compiled.pagePath ? m[compiled.pagePath]?.split("/").pop() : m[compiled.id];
828
+ if (!value) return null;
829
+ return verify(resolver, collection, await findPublished(source, collection, by, value), wanted);
830
+ }
831
+
832
+ /**
833
+ * The published entry a site path belongs to, or null. Override patterns
834
+ * first: e.g. for `/{term:category}/{slug}/` the entry is looked up by slug
835
+ * and its primary category must match; for `/{termpath:category}/{slug}/`
836
+ * the whole category path must (`/news/seo/a/`, not `/seo/a/`), and for
837
+ * `/{pagepath}/` the whole parent chain. Then EmDash's own routing
838
+ * (resolveEmDashPath with a context; url_pattern or /<collection>/{slug}
839
+ * from D1). Every match is verified by resolving the entry's URL back.
840
+ */
841
+ export async function matchEntryPath(source: UrlSource, path: string): Promise<MatchedEntry | null> {
842
+ const wanted = comparablePath(path);
843
+ if (!wanted.startsWith("/")) return null;
844
+ const resolver = createEntryUrlResolver(source);
845
+
846
+ for (const [collection, pattern] of config.urls) {
847
+ const found = await matchPattern(source, resolver, collection, pattern, wanted);
848
+ if (found) return found;
849
+ }
850
+
851
+ if (isD1(source)) {
852
+ let rows: Array<{ slug: string; url_pattern: string | null; routable?: number | null }> = [];
853
+ try {
854
+ rows = (await source.prepare("SELECT slug, url_pattern, routable FROM _emdash_collections").all<{ slug: string; url_pattern: string | null; routable: number | null }>()).results;
855
+ } catch {
856
+ rows = [...(await readCollections(source).catch(() => new Map<string, CollectionInfo>())).values()].map((c) => ({ slug: c.slug, url_pattern: c.urlPattern }));
857
+ }
858
+ for (const row of rows) {
859
+ if (config.urls.has(row.slug) || row.routable === 0) continue;
860
+ const found = await matchPattern(source, resolver, row.slug, row.url_pattern ?? `/${row.slug}/{slug}`, wanted);
861
+ if (found) return found;
862
+ }
863
+ return null;
864
+ }
865
+
866
+ // Context: EmDash's resolver for url_pattern collections, then the default /<collection>/<slug>.
867
+ const { resolveEmDashPath } = await import("emdash");
868
+ for (const candidate of new Set([wanted, `${wanted}/`])) {
869
+ const match = await resolveEmDashPath(candidate).catch(() => null);
870
+ if (!match) continue;
871
+ const dataId = (match.entry.data as Record<string, unknown>).id;
872
+ const id = typeof dataId === "string" && dataId ? dataId : match.entry.id;
873
+ const item = await source.content?.get(match.collection, id).catch(() => null);
874
+ if (item?.slug && item.status === "published") {
875
+ const found = await verify(resolver, match.collection, [{ id: item.id, slug: item.slug, publishedAt: item.publishedAt, locale: item.locale, status: item.status }], wanted);
876
+ if (found) return found;
877
+ }
878
+ }
879
+ const segments = wanted.split("/").filter(Boolean);
880
+ if (segments.length === 2 && !config.urls.has(segments[0])) {
881
+ const info = await source.schema?.getCollection(segments[0]).catch(() => null);
882
+ if (info?.routable && !info.urlPattern) {
883
+ const found = await verify(resolver, segments[0], await findPublished(source, segments[0], "slug", segments[1]), wanted);
884
+ if (found) return found;
885
+ }
886
+ }
887
+ return null;
888
+ }