@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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Old-site URLs left in imported content: `/wp-content/uploads/…` images and
|
|
3
|
+
* files, theme and plugin assets. EmDash's importer rewrites the media URLs
|
|
4
|
+
* it imported to the media library (1.2.0: image, gallery, column, cover,
|
|
5
|
+
* file and button blocks, embeds and HTML blocks, links in text and tables,
|
|
6
|
+
* and `-1024x683` sizes of an attachment's own file name), but not other
|
|
7
|
+
* blocks' fields (a testimonial's photo, a video's poster), sizes of large
|
|
8
|
+
* images whose attachment is the `-scaled` (or `-rotated`, edited) copy, or
|
|
9
|
+
* files that were never media-library attachments. This finds every old-site
|
|
10
|
+
* URL still in content (a generic scan, so what EmDash already rewrote just
|
|
11
|
+
* isn't found), maps each to a media library file (size variants to their
|
|
12
|
+
* original), lists what isn't in the library yet, rewrites content and builds
|
|
13
|
+
* redirect rules.
|
|
14
|
+
*
|
|
15
|
+
* Pure, no imports: runs in the Worker, the admin (browser) and tests.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type SiteUrlKind = "uploads" | "themes" | "plugins";
|
|
19
|
+
|
|
20
|
+
export interface SiteInfo {
|
|
21
|
+
/** `<wp:base_site_url>` (where WordPress is installed). */
|
|
22
|
+
siteUrl: string;
|
|
23
|
+
/** `<wp:base_blog_url>` (the home URL). */
|
|
24
|
+
homeUrl: string;
|
|
25
|
+
/** Lowercase host names of the old site, with and without `www.`. */
|
|
26
|
+
hosts: string[];
|
|
27
|
+
/** The folder WordPress is installed in ("/wp"), when it isn't the site root. */
|
|
28
|
+
prefixes: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
|
|
32
|
+
|
|
33
|
+
function text(body: string): string {
|
|
34
|
+
if (!body.includes("<![CDATA[")) return body.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
35
|
+
let out = "";
|
|
36
|
+
for (const m of body.matchAll(CDATA)) out += m[1];
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hostOf = (url: string): string | null => {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url.trim()).hostname.toLowerCase() || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Host names with their `www.` twin. */
|
|
49
|
+
export function withWww(hosts: Iterable<string>): string[] {
|
|
50
|
+
const out = new Set<string>();
|
|
51
|
+
for (const raw of hosts) {
|
|
52
|
+
const host = raw.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/:].*$/, "");
|
|
53
|
+
if (!host || !/^[a-z0-9.-]+$/.test(host)) continue;
|
|
54
|
+
out.add(host);
|
|
55
|
+
out.add(host.startsWith("www.") ? host.slice(4) : `www.${host}`);
|
|
56
|
+
}
|
|
57
|
+
return [...out];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The old site's URLs and host names, from the export's channel header. */
|
|
61
|
+
export function wxrSite(xml: string): SiteInfo {
|
|
62
|
+
const head = xml.split(/<item>/)[0] ?? "";
|
|
63
|
+
const tagText = (name: string) => {
|
|
64
|
+
const m = head.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
|
|
65
|
+
return m ? text(m[1] as string).trim() : "";
|
|
66
|
+
};
|
|
67
|
+
const siteUrl = tagText("wp:base_site_url");
|
|
68
|
+
const homeUrl = tagText("wp:base_blog_url") || tagText("link");
|
|
69
|
+
const hosts = [siteUrl, homeUrl, tagText("link")].map(hostOf).filter((h): h is string => Boolean(h));
|
|
70
|
+
const prefixes = new Set<string>();
|
|
71
|
+
for (const url of [siteUrl, homeUrl]) {
|
|
72
|
+
try {
|
|
73
|
+
const path = new URL(url).pathname.replace(/\/+$/, "");
|
|
74
|
+
if (path && /^(?:\/[\w.~-]+){1,3}$/.test(path)) prefixes.add(path);
|
|
75
|
+
} catch {
|
|
76
|
+
// Not a URL.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { siteUrl, homeUrl, hosts: withWww(hosts), prefixes: [...prefixes] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Host names typed by a person ("example.com, cdn.example.com" or full URLs). */
|
|
83
|
+
export function parseHosts(input: string): string[] {
|
|
84
|
+
return withWww(input.split(/[\s,]+/).map((h) => hostOf(/^[a-z]+:\/\//i.test(h) ? h : `https://${h}`) ?? ""));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
|
|
89
|
+
/** Characters a URL can hold here (stops at quotes, spaces, tags, brackets). */
|
|
90
|
+
const URL_CHARS = "[^\\s\"'<>()\\[\\]{}\\\\^`|]";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A regex finding the old site's `/wp-content/(uploads|themes|plugins)/…`
|
|
94
|
+
* URLs: absolute or protocol-relative on one of `hosts` (also through
|
|
95
|
+
* Jetpack's i0–i3.wp.com image CDN), or site-relative, optionally under the
|
|
96
|
+
* folder WordPress was installed in (`prefixes`, e.g. "/wp"). With
|
|
97
|
+
* `hosts === null`, any host and any one-folder prefix match (used to rewrite
|
|
98
|
+
* with a map, which holds only the old site's URLs anyway).
|
|
99
|
+
*/
|
|
100
|
+
export function siteUrlPattern(hosts: string[] | null, prefixes: string[] = []): RegExp {
|
|
101
|
+
const host = hosts === null ? "[a-z0-9.-]+" : hosts.length ? `(?:${hosts.map(escape).join("|")})` : null;
|
|
102
|
+
const origin = host ? `(?:(?:https?:)?\\/\\/(?:i[0-3]\\.wp\\.com\\/)?${host}(?::\\d+)?)?` : "";
|
|
103
|
+
const prefix = hosts === null ? "(?:\\/[\\w.~-]+)?" : prefixes.length ? `(?:${prefixes.map(escape).join("|")})?` : "";
|
|
104
|
+
return new RegExp(`(?<![\\w.\\-/])${origin}${prefix}\\/wp-content\\/(?:uploads|themes|plugins)\\/${URL_CHARS}+`, "gi");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Trailing punctuation that belongs to the prose, not the URL. */
|
|
108
|
+
const TRAILING = /[.,;:!?]+$/;
|
|
109
|
+
|
|
110
|
+
function scanString(value: string, re: RegExp, fn: (url: string) => string | undefined): string {
|
|
111
|
+
if (!value.includes("/wp-content/")) return value;
|
|
112
|
+
re.lastIndex = 0;
|
|
113
|
+
return value.replace(re, (whole: string) => {
|
|
114
|
+
const tail = whole.match(TRAILING)?.[0] ?? "";
|
|
115
|
+
const url = tail ? whole.slice(0, -tail.length) : whole;
|
|
116
|
+
const next = fn(url);
|
|
117
|
+
return next === undefined ? whole : next + tail;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Count every old-site URL in a value (deep: strings in objects and arrays). */
|
|
122
|
+
export function findSiteUrls(value: unknown, re: RegExp, into: Map<string, number> = new Map(), depth = 0): Map<string, number> {
|
|
123
|
+
if (depth > 40) return into;
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
scanString(value, re, (url) => {
|
|
126
|
+
into.set(url, (into.get(url) ?? 0) + 1);
|
|
127
|
+
return undefined;
|
|
128
|
+
});
|
|
129
|
+
} else if (Array.isArray(value)) {
|
|
130
|
+
for (const v of value) findSiteUrls(v, re, into, depth + 1);
|
|
131
|
+
} else if (value && typeof value === "object") {
|
|
132
|
+
for (const v of Object.values(value)) findSiteUrls(v, re, into, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
return into;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Replace mapped URLs everywhere in a value. Returns the same value when nothing changed. */
|
|
138
|
+
export function rewriteSiteUrls<T>(value: T, map: Record<string, string>, re: RegExp = siteUrlPattern(null)): { value: T; count: number } {
|
|
139
|
+
let count = 0;
|
|
140
|
+
const walk = (v: unknown, depth: number): unknown => {
|
|
141
|
+
if (depth > 40) return v;
|
|
142
|
+
if (typeof v === "string") {
|
|
143
|
+
const next = scanString(v, re, (url) => {
|
|
144
|
+
const to = Object.hasOwn(map, url) ? map[url] : undefined;
|
|
145
|
+
if (to === undefined || to === url) return undefined;
|
|
146
|
+
count++;
|
|
147
|
+
return to;
|
|
148
|
+
});
|
|
149
|
+
return next;
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(v)) {
|
|
152
|
+
let out: unknown[] | null = null;
|
|
153
|
+
v.forEach((item, i) => {
|
|
154
|
+
const next = walk(item, depth + 1);
|
|
155
|
+
if (next !== item) {
|
|
156
|
+
out ??= v.slice();
|
|
157
|
+
out[i] = next;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return out ?? v;
|
|
161
|
+
}
|
|
162
|
+
if (v && typeof v === "object") {
|
|
163
|
+
let out: Record<string, unknown> | null = null;
|
|
164
|
+
for (const [k, item] of Object.entries(v)) {
|
|
165
|
+
const next = walk(item, depth + 1);
|
|
166
|
+
if (next !== item) {
|
|
167
|
+
out ??= { ...(v as Record<string, unknown>) };
|
|
168
|
+
out[k] = next;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out ?? v;
|
|
172
|
+
}
|
|
173
|
+
return v;
|
|
174
|
+
};
|
|
175
|
+
const next = walk(value, 0) as T;
|
|
176
|
+
return { value: next, count };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Scheme and host of an absolute or protocol-relative URL (through Jetpack's CDN too). */
|
|
180
|
+
const ORIGIN = /^(?:(https?):)?\/\/(?:i[0-3]\.wp\.com\/)?([^/?#]+)/i;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Where to download a found URL from: its own origin (Jetpack CDN URLs from
|
|
184
|
+
* the site behind it, without resize parameters), or `origin` for
|
|
185
|
+
* site-relative URLs.
|
|
186
|
+
*/
|
|
187
|
+
export function sourceUrl(url: string, origin: string): string | null {
|
|
188
|
+
const path = url.replace(ORIGIN, "").split(/[?#]/)[0] as string;
|
|
189
|
+
if (!path.startsWith("/")) return null;
|
|
190
|
+
const m = url.match(ORIGIN);
|
|
191
|
+
if (m) return `${(m[1] ?? "https").toLowerCase()}://${m[2]}${path}`;
|
|
192
|
+
try {
|
|
193
|
+
return new URL(path, origin).toString();
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Where a found URL points: its kind, the path on the old site (no query), and the folder and file name below the kind's folder. */
|
|
200
|
+
export function siteUrlParts(url: string): { kind: SiteUrlKind; path: string; dir: string; name: string } | null {
|
|
201
|
+
const m = url.match(/\/wp-content\/(uploads|themes|plugins)\/([^?#]*)/i);
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
let rest = m[2] as string;
|
|
204
|
+
try {
|
|
205
|
+
rest = decodeURIComponent(rest);
|
|
206
|
+
} catch {
|
|
207
|
+
// Keep it as written.
|
|
208
|
+
}
|
|
209
|
+
const parts = rest.split("/").filter(Boolean);
|
|
210
|
+
const name = parts.pop() ?? "";
|
|
211
|
+
if (!name) return null;
|
|
212
|
+
const path = url.replace(ORIGIN, "").split(/[?#]/)[0] as string;
|
|
213
|
+
return { kind: (m[1] as string).toLowerCase() as SiteUrlKind, path, dir: parts.join("/").toLowerCase(), name };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The file name WordPress made a size variant from: strips `-300x200`,
|
|
218
|
+
* `-scaled`, `-rotated` and image-editor suffixes (`-e1589912345678`), in any
|
|
219
|
+
* order, and lowercases it. `photo-scaled.jpg`, `photo-1024x683.jpg` and
|
|
220
|
+
* `photo.jpg` all give `photo.jpg`.
|
|
221
|
+
*/
|
|
222
|
+
export function originalName(name: string): string {
|
|
223
|
+
const dot = name.lastIndexOf(".");
|
|
224
|
+
let stem = (dot > 0 ? name.slice(0, dot) : name).toLowerCase();
|
|
225
|
+
const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
226
|
+
for (let i = 0; i < 6; i++) {
|
|
227
|
+
const next = stem.replace(/-\d+x\d+$/, "").replace(/-(?:scaled|rotated)$/, "").replace(/-e\d{10,}$/, "");
|
|
228
|
+
if (next === stem || !next) break;
|
|
229
|
+
stem = next;
|
|
230
|
+
}
|
|
231
|
+
return stem + ext;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A media library item as EmDash's media API lists it. */
|
|
235
|
+
export interface MediaFile {
|
|
236
|
+
id: string;
|
|
237
|
+
filename: string;
|
|
238
|
+
url: string;
|
|
239
|
+
storageKey?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SiteUrlPlan {
|
|
243
|
+
url: string;
|
|
244
|
+
kind: SiteUrlKind;
|
|
245
|
+
/** The path on the old site (no host or query), for a redirect. */
|
|
246
|
+
path: string;
|
|
247
|
+
/** Times it appears in content (0 for an attachment not used in content). */
|
|
248
|
+
count: number;
|
|
249
|
+
/** matched: in the media library; missing: not there yet (import it from the old site); ambiguous: several files fit. */
|
|
250
|
+
status: "matched" | "missing" | "ambiguous";
|
|
251
|
+
/** The media library URL. */
|
|
252
|
+
target?: string;
|
|
253
|
+
/** How it was matched or why not. */
|
|
254
|
+
note?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Map old-site URLs to media library files. `attachments` are the export's
|
|
259
|
+
* attachment URLs (`wp:attachment_url`): an uploads URL whose original is an
|
|
260
|
+
* attachment maps to the media file imported from it (by file name); one that
|
|
261
|
+
* wasn't an attachment is "missing" (import it from the old site) unless
|
|
262
|
+
* `known` has it (files imported in this session). Without attachments,
|
|
263
|
+
* uploads URLs match by original file name when exactly one media file has it.
|
|
264
|
+
* Theme and plugin files are matched only through `known`.
|
|
265
|
+
*/
|
|
266
|
+
export function planSiteUrls(
|
|
267
|
+
found: Iterable<[string, number]>,
|
|
268
|
+
media: MediaFile[],
|
|
269
|
+
attachments: Iterable<string> | null,
|
|
270
|
+
known: Record<string, string> = {},
|
|
271
|
+
): SiteUrlPlan[] {
|
|
272
|
+
const byName = new Map<string, MediaFile[]>();
|
|
273
|
+
const byOriginal = new Map<string, MediaFile[]>();
|
|
274
|
+
const add = (map: Map<string, MediaFile[]>, key: string, file: MediaFile) => {
|
|
275
|
+
const list = map.get(key);
|
|
276
|
+
if (list) list.push(file);
|
|
277
|
+
else map.set(key, [file]);
|
|
278
|
+
};
|
|
279
|
+
for (const file of media) {
|
|
280
|
+
add(byName, file.filename.toLowerCase(), file);
|
|
281
|
+
add(byOriginal, originalName(file.filename), file);
|
|
282
|
+
}
|
|
283
|
+
// "2020/05/photo.jpg" (original) → the attachment's own file name ("photo-scaled.jpg").
|
|
284
|
+
const attachmentFile = new Map<string, string>();
|
|
285
|
+
if (attachments) {
|
|
286
|
+
for (const url of attachments) {
|
|
287
|
+
const p = siteUrlParts(url);
|
|
288
|
+
if (p?.kind === "uploads") attachmentFile.set(`${p.dir}/${originalName(p.name)}`, p.name.toLowerCase());
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const pick = (list: MediaFile[] | undefined): MediaFile | "ambiguous" | null => {
|
|
292
|
+
if (!list?.length) return null;
|
|
293
|
+
const urls = new Set(list.map((f) => f.url));
|
|
294
|
+
return urls.size === 1 ? (list[0] as MediaFile) : "ambiguous";
|
|
295
|
+
};
|
|
296
|
+
const plans: SiteUrlPlan[] = [];
|
|
297
|
+
for (const [url, count] of found) {
|
|
298
|
+
const parts = siteUrlParts(url);
|
|
299
|
+
if (!parts) continue;
|
|
300
|
+
const plan: SiteUrlPlan = { url, kind: parts.kind, path: parts.path, count, status: "missing" };
|
|
301
|
+
const knownTarget = known[url] ?? known[parts.path];
|
|
302
|
+
if (knownTarget) {
|
|
303
|
+
plan.status = "matched";
|
|
304
|
+
plan.target = knownTarget;
|
|
305
|
+
plan.note = "Imported";
|
|
306
|
+
} else if (parts.kind !== "uploads") {
|
|
307
|
+
plan.note = parts.kind === "themes" ? "Theme file" : "Plugin file";
|
|
308
|
+
} else {
|
|
309
|
+
const original = originalName(parts.name);
|
|
310
|
+
let hit: MediaFile | "ambiguous" | null = null;
|
|
311
|
+
if (attachments) {
|
|
312
|
+
const file = attachmentFile.get(`${parts.dir}/${original}`);
|
|
313
|
+
if (file) {
|
|
314
|
+
hit = pick(byName.get(file)) ?? pick(byOriginal.get(original));
|
|
315
|
+
plan.note = file === parts.name.toLowerCase() ? "Attachment" : `Size of ${file}`;
|
|
316
|
+
} else {
|
|
317
|
+
plan.note = "Not a media-library attachment";
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
hit = pick(byOriginal.get(original));
|
|
321
|
+
plan.note = "By file name";
|
|
322
|
+
}
|
|
323
|
+
if (hit === "ambiguous") {
|
|
324
|
+
plan.status = "ambiguous";
|
|
325
|
+
plan.note = `Several media files are named ${original}`;
|
|
326
|
+
} else if (hit) {
|
|
327
|
+
plan.status = "matched";
|
|
328
|
+
plan.target = hit.url;
|
|
329
|
+
} else if (plan.note !== "Not a media-library attachment") {
|
|
330
|
+
plan.note = `${plan.note}: not in the media library`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
plans.push(plan);
|
|
334
|
+
}
|
|
335
|
+
return plans.sort((a, b) => a.path.localeCompare(b.path) || a.url.localeCompare(b.url));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** URL → media URL for every matched plan, to rewrite content with. */
|
|
339
|
+
export function rewriteMap(plans: SiteUrlPlan[]): Record<string, string> {
|
|
340
|
+
const map: Record<string, string> = {};
|
|
341
|
+
for (const p of plans) if (p.status === "matched" && p.target && p.target !== p.url) map[p.url] = p.target;
|
|
342
|
+
return map;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Redirect rules (Redirects module import format) from each old path to its media file. */
|
|
346
|
+
export function uploadRedirects(plans: SiteUrlPlan[]): Array<{ source: string; target: string; type: number; isRegex: boolean; note: string }> {
|
|
347
|
+
const out = new Map<string, { source: string; target: string; type: number; isRegex: boolean; note: string }>();
|
|
348
|
+
for (const p of plans) {
|
|
349
|
+
if (p.status !== "matched" || !p.target || out.has(p.path)) continue;
|
|
350
|
+
if (!/^(?:\/[\w.~-]+)*\/wp-content\/[^\s?#]+$/i.test(p.path)) continue;
|
|
351
|
+
out.set(p.path, { source: p.path, target: p.target, type: 301, isRegex: false, note: "WordPress media" });
|
|
352
|
+
}
|
|
353
|
+
return [...out.values()];
|
|
354
|
+
}
|