@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Slug changes" (feature "redirects.slugChanges"): when a published entry's
|
|
3
|
+
* public URL changes (a new slug, or a new category or parent page), add a 301
|
|
4
|
+
* from the old URL to the new one.
|
|
5
|
+
*
|
|
6
|
+
* EmDash makes its own slug-change redirect from the collection's URL pattern,
|
|
7
|
+
* which can't express category or parent paths (posts here have no pattern, so
|
|
8
|
+
* it writes /posts/<old> → /posts/<new>, which aren't real URLs). This uses the
|
|
9
|
+
* pack's URL resolver (src/core/content-url.ts), the same one the sitemaps and
|
|
10
|
+
* breadcrumbs use, so the rule matches the URL readers and search engines saw.
|
|
11
|
+
*
|
|
12
|
+
* The old URL is remembered per entry in plugin storage: recorded before a save
|
|
13
|
+
* (from the live row) and after every save or publish. Rules are made with a
|
|
14
|
+
* "Slug change:" note; only those are ever edited or deleted.
|
|
15
|
+
*/
|
|
16
|
+
import type { PluginContext } from "emdash";
|
|
17
|
+
|
|
18
|
+
import { COLLECTION_SLUG, entryUrl, hostDefaultLocale, readCollections } from "../core/content-url.js";
|
|
19
|
+
import { REDIRECTS_TAG, purgePageCache } from "../pageCache/lib.js";
|
|
20
|
+
import { workerEnv } from "../shared.js";
|
|
21
|
+
import { invalidateRedirectCache } from "./middleware.js";
|
|
22
|
+
import { type EntrySnapshot, snapshotFromContent, snapshotFromRow } from "./removed-core.js";
|
|
23
|
+
import { deleteRule, ensureTable, normalizePath, saveRule } from "./rules.js";
|
|
24
|
+
import { type PlanRule, SLUG_NOTE_PREFIX, planSlugChange } from "./slug-core.js";
|
|
25
|
+
|
|
26
|
+
export const SLUG_CHANGES_FEATURE = "redirects.slugChanges";
|
|
27
|
+
const STORE = "redirects_urls";
|
|
28
|
+
|
|
29
|
+
export const slugStorage = { [STORE]: { indexes: ["at"] } };
|
|
30
|
+
|
|
31
|
+
interface KnownUrl {
|
|
32
|
+
url: string;
|
|
33
|
+
at: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: plugin storage collections are untyped here.
|
|
37
|
+
const urls = (ctx: PluginContext) => (ctx.storage as Record<string, any>)[STORE] as {
|
|
38
|
+
get(id: string): Promise<KnownUrl | null>;
|
|
39
|
+
put(id: string, data: KnownUrl): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const key = (collection: string, id: string) => `${collection}:${id}`;
|
|
43
|
+
const live = (entry: EntrySnapshot) => entry.status === "published" && Boolean(entry.slug);
|
|
44
|
+
/**
|
|
45
|
+
* Only default-locale entries: translations can share a slug with the default-locale
|
|
46
|
+
* entry, and the resolver's database path doesn't add a locale prefix, so a translation's
|
|
47
|
+
* rename would write a rule over the live default-locale page.
|
|
48
|
+
*/
|
|
49
|
+
const handled = async (entry: EntrySnapshot) => !entry.locale || entry.locale === (await hostDefaultLocale());
|
|
50
|
+
/** How long beforeSave may spend recording a URL: EmDash fails the save if a beforeSave hook times out (5 s). */
|
|
51
|
+
const BEFORE_SAVE_BUDGET_MS = 1500;
|
|
52
|
+
|
|
53
|
+
export function slugChangesModule(options: { database?: string }) {
|
|
54
|
+
async function db(): Promise<D1Database | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
return (await workerEnv())[options.database ?? "DB"] as D1Database | undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const urlOf = (d1: D1Database, collection: string, entry: EntrySnapshot) =>
|
|
63
|
+
entry.slug ? entryUrl(d1, collection, { id: entry.id, slug: entry.slug, publishedAt: entry.publishedAt, locale: entry.locale }).catch(() => null) : Promise.resolve(null);
|
|
64
|
+
|
|
65
|
+
/** Every exact rule whose source or target is either URL. */
|
|
66
|
+
async function related(d1: D1Database, a: string, b: string): Promise<PlanRule[]> {
|
|
67
|
+
await ensureTable(d1);
|
|
68
|
+
const paths = [normalizePath(a), normalizePath(b)];
|
|
69
|
+
const { results } = await d1
|
|
70
|
+
.prepare("SELECT id, source, target, note FROM coywolf_redirects WHERE is_regex = 0 AND (source IN (?, ?) OR target IN (?, ?, ?, ?))")
|
|
71
|
+
.bind(...paths, ...paths, `${paths[0]}/`, `${paths[1]}/`)
|
|
72
|
+
.all<PlanRule>();
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function moved(ctx: PluginContext, d1: D1Database, collection: string, entry: EntrySnapshot, from: string, to: string) {
|
|
77
|
+
const rules = await related(d1, from, to);
|
|
78
|
+
const plan = planSlugChange(from, to, rules);
|
|
79
|
+
if (plan.kind === "none") return;
|
|
80
|
+
for (const id of plan.deleteIds) await deleteRule(d1, id);
|
|
81
|
+
for (const id of plan.retargetIds) {
|
|
82
|
+
await d1.prepare("UPDATE coywolf_redirects SET target = ?, updated_at = ? WHERE id = ?").bind(to, new Date().toISOString(), id).run();
|
|
83
|
+
}
|
|
84
|
+
if (plan.keptManual) {
|
|
85
|
+
ctx.log.info("Slug change: a redirect for the old URL already exists; left as is", { from, to, rule: plan.keptManual.id });
|
|
86
|
+
} else {
|
|
87
|
+
await saveRule(d1, {
|
|
88
|
+
...(plan.upsert.id ? { id: plan.upsert.id } : {}),
|
|
89
|
+
source: from,
|
|
90
|
+
target: to,
|
|
91
|
+
type: 301,
|
|
92
|
+
note: `${SLUG_NOTE_PREFIX} ${entry.title ?? entry.slug ?? entry.id}`.slice(0, 500),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// EmDash collapses its own chains too, so its rows from those earlier URLs now point at the new one.
|
|
96
|
+
const retargetedSources = rules.filter((r) => plan.retargetIds.includes(r.id)).map((r) => r.source);
|
|
97
|
+
const replaced = await dropEmDashAuto(d1, collection, from, entry.slug, retargetedSources);
|
|
98
|
+
invalidateRedirectCache();
|
|
99
|
+
await purgePageCache({ tags: [REDIRECTS_TAG] });
|
|
100
|
+
ctx.log.info("Slug change redirect", { collection, from, to, deleted: plan.deleteIds.length, retargeted: plan.retargetIds.length, emdashAutoRemoved: replaced });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Delete the redirect EmDash made for the same change (its "Auto: slug change" rows).
|
|
105
|
+
* EmDash builds them from the collection's URL pattern: without one it writes
|
|
106
|
+
* /<collection>/<slug> paths that don't exist, and for a nested page it writes a
|
|
107
|
+
* top-level /<old> that can hijack a real page there. Its middleware runs before
|
|
108
|
+
* the pack's, so its rule would also answer first (without the trailing slash).
|
|
109
|
+
* Only auto rows from the old URL (or URLs whose rules were just pointed at the new one,
|
|
110
|
+
* since EmDash collapses its chains the same way) whose destination ends in the new
|
|
111
|
+
* slug; EmDash's own table and API keep working for everything else.
|
|
112
|
+
*/
|
|
113
|
+
async function dropEmDashAuto(d1: D1Database, collection: string, from: string, newSlug: string | null, earlier: string[] = []): Promise<number> {
|
|
114
|
+
const oldSlug = normalizePath(from).split("/").pop();
|
|
115
|
+
if (!oldSlug || !newSlug) return 0;
|
|
116
|
+
try {
|
|
117
|
+
const slugs = [oldSlug, ...earlier.map((path) => normalizePath(path).split("/").pop() ?? "")].filter(Boolean);
|
|
118
|
+
const sources = [...new Set([normalizePath(from), ...earlier.map(normalizePath), ...slugs.flatMap((slug) => [`/${collection}/${slug}`, `/${slug}`])])];
|
|
119
|
+
const result = await d1
|
|
120
|
+
.prepare(
|
|
121
|
+
`DELETE FROM _emdash_redirects WHERE auto = 1 AND group_name = 'Auto: slug change' AND rtrim(source, '/') IN (${sources.map(() => "?").join(", ")}) AND rtrim(destination, '/') LIKE ?`,
|
|
122
|
+
)
|
|
123
|
+
.bind(...sources, `%/${newSlug}`)
|
|
124
|
+
.run();
|
|
125
|
+
return result.meta.changes ?? 0;
|
|
126
|
+
} catch {
|
|
127
|
+
return 0; // EmDash's table changed shape: leave it.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An entry is live at `url`: delete our rules from that URL (left by another entry
|
|
133
|
+
* that used to be there, or by this one before it moved back). Exact rules are read
|
|
134
|
+
* before pages render, so one left behind would send readers away from the live page.
|
|
135
|
+
*/
|
|
136
|
+
async function freeUrl(ctx: PluginContext, d1: D1Database, url: string) {
|
|
137
|
+
await ensureTable(d1);
|
|
138
|
+
const { results } = await d1
|
|
139
|
+
.prepare("SELECT id FROM coywolf_redirects WHERE is_regex = 0 AND source = ? AND note LIKE ?")
|
|
140
|
+
.bind(normalizePath(url), `${SLUG_NOTE_PREFIX}%`)
|
|
141
|
+
.all<{ id: string }>();
|
|
142
|
+
if (!results.length) return;
|
|
143
|
+
for (const row of results) await deleteRule(d1, row.id);
|
|
144
|
+
invalidateRedirectCache();
|
|
145
|
+
await purgePageCache({ tags: [REDIRECTS_TAG] });
|
|
146
|
+
ctx.log.info("Slug change redirect removed: the URL is live again", { url, rules: results.length });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Remember a live entry's URL, from its row as stored now (before a save changes it). */
|
|
150
|
+
async function rememberStored(ctx: PluginContext, collection: string, id: string) {
|
|
151
|
+
if (!COLLECTION_SLUG.test(collection) || (await urls(ctx).get(key(collection, id)))) return;
|
|
152
|
+
const d1 = await db();
|
|
153
|
+
if (!d1) return;
|
|
154
|
+
const info = (await readCollections(d1, [collection])).get(collection);
|
|
155
|
+
const row = await d1.prepare(`SELECT * FROM "ec_${collection}" WHERE id = ? AND deleted_at IS NULL`).bind(id).first<Record<string, unknown>>();
|
|
156
|
+
if (!row) return;
|
|
157
|
+
const entry = snapshotFromRow(row, info?.titleField ?? null);
|
|
158
|
+
if (!live(entry) || !(await handled(entry))) return;
|
|
159
|
+
const url = await urlOf(d1, collection, entry);
|
|
160
|
+
if (url) await urls(ctx).put(key(collection, id), { url, at: new Date().toISOString() });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** After a save or publish: compare the live URL with the remembered one, redirect if it moved, remember the new one. */
|
|
164
|
+
async function settle(ctx: PluginContext, collection: string, content: Record<string, unknown>) {
|
|
165
|
+
if (!COLLECTION_SLUG.test(collection) || content.id == null) return;
|
|
166
|
+
const d1 = await db();
|
|
167
|
+
if (!d1) return;
|
|
168
|
+
const info = (await readCollections(d1, [collection])).get(collection);
|
|
169
|
+
const entry = snapshotFromContent(content, info?.titleField ?? null);
|
|
170
|
+
if (!live(entry) || !(await handled(entry))) return;
|
|
171
|
+
const url = await urlOf(d1, collection, entry);
|
|
172
|
+
if (!url) return;
|
|
173
|
+
const id = key(collection, entry.id);
|
|
174
|
+
const known = await urls(ctx).get(id);
|
|
175
|
+
if (known?.url === url) return;
|
|
176
|
+
if (known) await moved(ctx, d1, collection, entry, known.url, url);
|
|
177
|
+
else await freeUrl(ctx, d1, url);
|
|
178
|
+
await urls(ctx).put(id, { url, at: new Date().toISOString() });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const hooks = {
|
|
182
|
+
"content:beforeSave": async (event: { collection: string; id?: string; isNew: boolean }, ctx: PluginContext) => {
|
|
183
|
+
if (event.isNew || !event.id) return undefined;
|
|
184
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
185
|
+
const deadline = new Promise<"late">((resolve) => {
|
|
186
|
+
timer = setTimeout(() => resolve("late"), BEFORE_SAVE_BUDGET_MS);
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
const outcome = await Promise.race([rememberStored(ctx, event.collection, event.id).then(() => "done" as const), deadline]);
|
|
190
|
+
if (outcome === "late") ctx.log.warn("Slug changes: recording the current URL took too long; skipped", { collection: event.collection, id: event.id });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
ctx.log.warn("Slug changes: could not record the current URL", { error: String(error) });
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
return undefined; // Never changes or blocks the save.
|
|
197
|
+
},
|
|
198
|
+
"content:afterSave": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
|
|
199
|
+
await settle(ctx, event.collection, event.content).catch((error: unknown) => ctx.log.warn("Slug changes: could not check the URL", { error: String(error) }));
|
|
200
|
+
},
|
|
201
|
+
"content:afterPublish": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
|
|
202
|
+
await settle(ctx, event.collection, event.content).catch((error: unknown) => ctx.log.warn("Slug changes: could not check the URL", { error: String(error) }));
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return { hooks, storage: slugStorage };
|
|
207
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for "Slug changes" (feature "redirects.slugChanges"): what to do
|
|
3
|
+
* with the redirect rules when a published entry's public URL changes. No
|
|
4
|
+
* imports, so `node --test` can load this file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Rules this feature made carry a note starting with this, so it only ever edits its own. */
|
|
8
|
+
export const SLUG_NOTE_PREFIX = "Slug change:";
|
|
9
|
+
|
|
10
|
+
export interface PlanRule {
|
|
11
|
+
id: string;
|
|
12
|
+
source: string;
|
|
13
|
+
target: string;
|
|
14
|
+
note: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SlugPlan =
|
|
18
|
+
| { kind: "none"; reason: string }
|
|
19
|
+
| {
|
|
20
|
+
kind: "apply";
|
|
21
|
+
/** Our rules to delete: their source is the entry's URL again (renamed back), which they would shadow or loop. */
|
|
22
|
+
deleteIds: string[];
|
|
23
|
+
/** Our rules that pointed at the old URL: point them at the new one, so there's no chain. */
|
|
24
|
+
retargetIds: string[];
|
|
25
|
+
/** Create the old → new rule, or update our existing rule for the old URL. */
|
|
26
|
+
upsert: { id?: string };
|
|
27
|
+
/** A rule someone else made already answers the old URL: left alone. */
|
|
28
|
+
keptManual?: PlanRule;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const trim = (path: string) => (path.length > 1 ? path.replace(/\/+$/, "") : path);
|
|
32
|
+
export const isOurs = (rule: Pick<PlanRule, "note">) => (rule.note ?? "").startsWith(SLUG_NOTE_PREFIX);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The plan for an entry whose URL went from `oldUrl` to `newUrl`.
|
|
36
|
+
* `related` is every exact rule whose source or target is either URL (any note).
|
|
37
|
+
*/
|
|
38
|
+
export function planSlugChange(oldUrl: string | null, newUrl: string | null, related: PlanRule[]): SlugPlan {
|
|
39
|
+
if (!oldUrl || !newUrl) return { kind: "none", reason: "no URL" };
|
|
40
|
+
const from = trim(oldUrl);
|
|
41
|
+
const to = trim(newUrl);
|
|
42
|
+
if (from === to) return { kind: "none", reason: "unchanged" };
|
|
43
|
+
const ours = related.filter(isOurs);
|
|
44
|
+
const deleteIds = ours.filter((r) => trim(r.source) === to).map((r) => r.id);
|
|
45
|
+
const retargetIds = ours.filter((r) => trim(r.target) === from && trim(r.source) !== to && trim(r.source) !== from).map((r) => r.id);
|
|
46
|
+
const forOld = related.find((r) => trim(r.source) === from);
|
|
47
|
+
if (forOld && !isOurs(forOld)) return { kind: "apply", deleteIds, retargetIds, upsert: {}, keptManual: forOld };
|
|
48
|
+
return { kind: "apply", deleteIds, retargetIds, upsert: forOld ? { id: forOld.id } : {} };
|
|
49
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect export and import formats, shared by the Redirects page and the
|
|
3
|
+
* tests. An export imports back as is (Import updates rules with the same
|
|
4
|
+
* source), so a site's redirects can move between sites or into a
|
|
5
|
+
* spreadsheet and back.
|
|
6
|
+
*
|
|
7
|
+
* CSV: source, target, type, is_regex, enabled, note, then hits, last_hit,
|
|
8
|
+
* created_at, updated_at (for reference; Import ignores them)
|
|
9
|
+
* JSON: an array of rules, as the list route returns them
|
|
10
|
+
*
|
|
11
|
+
* Import also takes header-less rows of source, target, type, is_regex
|
|
12
|
+
* separated by commas or tabs, e.g. a Coywolf SEO export from WordPress.
|
|
13
|
+
*/
|
|
14
|
+
import { type CsvCell, parseCsv, toCsv, unguardCell } from "../core/csv.js";
|
|
15
|
+
import type { RedirectInput, RedirectRule } from "./rules.js";
|
|
16
|
+
|
|
17
|
+
export const REDIRECT_CSV_COLUMNS = ["source", "target", "type", "is_regex", "enabled", "note", "hits", "last_hit", "created_at", "updated_at"] as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* All rules as CSV. Cells are guarded against spreadsheet formulas; Import
|
|
21
|
+
* removes the guard again (see src/core/csv.ts), so the round trip is exact.
|
|
22
|
+
*/
|
|
23
|
+
export function redirectsToCsv(rules: RedirectRule[]): string {
|
|
24
|
+
const rows: CsvCell[][] = [[...REDIRECT_CSV_COLUMNS]];
|
|
25
|
+
for (const r of rules) rows.push([r.source, r.target, r.type, r.isRegex, r.enabled, r.note ?? "", r.hits, r.lastHit ?? "", r.createdAt, r.updatedAt]);
|
|
26
|
+
return toCsv(rows, { guard: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redirectsToJson(rules: RedirectRule[]): string {
|
|
30
|
+
return `${JSON.stringify(rules, null, "\t")}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const truthy = (value: string | undefined) => /^(1|true|yes|y)$/i.test((value ?? "").trim());
|
|
34
|
+
const falsy = (value: string | undefined) => /^(0|false|no|n|off)$/i.test((value ?? "").trim());
|
|
35
|
+
|
|
36
|
+
/** One import row to a rule; enabled and note are left out when the file doesn't have them. */
|
|
37
|
+
function fromCells(cells: { source?: string; target?: string; type?: string; regex?: string; enabled?: string; note?: string }): RedirectInput {
|
|
38
|
+
const type = (cells.type ?? "").trim();
|
|
39
|
+
const rule: RedirectInput = {
|
|
40
|
+
source: (cells.source ?? "").trim(),
|
|
41
|
+
target: (cells.target ?? "").trim(),
|
|
42
|
+
type: type ? Number(type) : 301,
|
|
43
|
+
isRegex: truthy(cells.regex),
|
|
44
|
+
};
|
|
45
|
+
if (cells.enabled !== undefined && cells.enabled.trim() !== "") rule.enabled = !falsy(cells.enabled);
|
|
46
|
+
if (cells.note !== undefined) rule.note = cells.note.trim() || null;
|
|
47
|
+
return rule;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fromJson(value: unknown): RedirectInput[] {
|
|
51
|
+
const list = Array.isArray(value) ? value : value && typeof value === "object" && Array.isArray((value as { redirects?: unknown }).redirects) ? (value as { redirects: unknown[] }).redirects : null;
|
|
52
|
+
if (!list) throw new Error("The JSON must be a list of redirects.");
|
|
53
|
+
return list.map((item, i) => {
|
|
54
|
+
if (!item || typeof item !== "object") throw new Error(`Item ${i + 1} isn't a redirect.`);
|
|
55
|
+
const r = item as Record<string, unknown>;
|
|
56
|
+
const rule: RedirectInput = {
|
|
57
|
+
source: typeof r.source === "string" ? r.source : "",
|
|
58
|
+
target: typeof r.target === "string" ? r.target : "",
|
|
59
|
+
type: r.type === undefined ? 301 : Number(r.type),
|
|
60
|
+
isRegex: r.isRegex === true,
|
|
61
|
+
};
|
|
62
|
+
if (typeof r.enabled === "boolean") rule.enabled = r.enabled;
|
|
63
|
+
if (typeof r.note === "string" || r.note === null) rule.note = r.note;
|
|
64
|
+
return rule;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse pasted or uploaded rules: a JSON array, or CSV/tab-separated rows with or without a header. */
|
|
69
|
+
export function parseRedirectsImport(text: string): RedirectInput[] {
|
|
70
|
+
const trimmed = text.replace(/^/, "").trim();
|
|
71
|
+
if (!trimmed) return [];
|
|
72
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(trimmed);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new Error(`That isn't valid JSON: ${(error as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
return fromJson(parsed);
|
|
80
|
+
}
|
|
81
|
+
const rows = parseCsv(trimmed).map((row) => row.map(unguardCell));
|
|
82
|
+
const header = rows[0].map((c) => c.trim().toLowerCase());
|
|
83
|
+
if (!header.includes("source")) {
|
|
84
|
+
return rows.map((r) => fromCells({ source: r[0], target: r[1], type: r[2], regex: r[3] }));
|
|
85
|
+
}
|
|
86
|
+
const at = (...names: string[]) => header.findIndex((h) => names.includes(h));
|
|
87
|
+
const [s, t, ty, rx, en, no] = [at("source"), at("target", "destination"), at("type", "code", "status"), at("is_regex", "regex", "isregex"), at("enabled"), at("note")];
|
|
88
|
+
const cell = (row: string[], i: number) => (i >= 0 ? (row[i] ?? "") : undefined);
|
|
89
|
+
return rows.slice(1).map((r) =>
|
|
90
|
+
fromCells({ source: cell(r, s), target: cell(r, t), type: cell(r, ty), regex: cell(r, rx), enabled: cell(r, en), note: cell(r, no) }),
|
|
91
|
+
);
|
|
92
|
+
}
|