@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.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- 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
|
+
}
|