@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
package/src/links/pt.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links in EmDash content: find them in Portable Text (link marks, linked
|
|
3
|
+
* images, buttons, embeds, iframes, files) and URL fields, and rewrite them
|
|
4
|
+
* without touching anything else. Pure functions; no I/O.
|
|
5
|
+
*
|
|
6
|
+
* Portable Text shapes (emdash/src/content/converters/types.ts):
|
|
7
|
+
* - text block: { _type: "block", children: span[], markDefs: [{ _type: "link", _key, href, blank? }] }
|
|
8
|
+
* spans reference a link through `marks: [markDef._key]`
|
|
9
|
+
* - table: markDefs on the table and on each tableCell, spans under `content`
|
|
10
|
+
* - image: `link` is { href, blank? } or (WordPress imports) a bare string
|
|
11
|
+
* - button: { _type: "button", url }, embed: { url }, iframe: { src }, file: { url }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type LinkKind = "text" | "image" | "button" | "embed" | "iframe" | "file" | "field" | "block";
|
|
15
|
+
|
|
16
|
+
export interface FoundLink {
|
|
17
|
+
href: string;
|
|
18
|
+
kind: LinkKind;
|
|
19
|
+
/** Linked text for text links (empty otherwise). */
|
|
20
|
+
anchor: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Json = unknown;
|
|
24
|
+
type Obj = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
const isObj = (v: Json): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27
|
+
|
|
28
|
+
const SKIP_SCHEME = /^(?:mailto:|tel:|sms:|javascript:|data:|about:|blob:|file:|#)/i;
|
|
29
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
30
|
+
|
|
31
|
+
/** Whether an href points somewhere we can track (http(s), protocol-relative or relative). */
|
|
32
|
+
export function isTrackable(href: unknown): href is string {
|
|
33
|
+
if (typeof href !== "string") return false;
|
|
34
|
+
const h = href.trim();
|
|
35
|
+
if (!h || h.length > 2048 || SKIP_SCHEME.test(h)) return false;
|
|
36
|
+
if (HAS_SCHEME.test(h)) return /^https?:/i.test(h);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Keys that hold a URL on non-text blocks, by block type. `asset` (media references) is never followed. */
|
|
41
|
+
const URL_KEYS = ["href", "url", "src", "link"] as const;
|
|
42
|
+
|
|
43
|
+
function blockKind(type: unknown, key: string): LinkKind {
|
|
44
|
+
if (key === "link") return "image";
|
|
45
|
+
switch (type) {
|
|
46
|
+
case "button":
|
|
47
|
+
return "button";
|
|
48
|
+
case "iframe":
|
|
49
|
+
return "iframe";
|
|
50
|
+
case "file":
|
|
51
|
+
return "file";
|
|
52
|
+
case "embed":
|
|
53
|
+
return "embed";
|
|
54
|
+
default:
|
|
55
|
+
return key === "src" || key === "url" ? "embed" : "block";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function linkHref(value: unknown): string | undefined {
|
|
60
|
+
if (typeof value === "string") return value;
|
|
61
|
+
if (isObj(value) && typeof value.href === "string") return value.href;
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Text of the spans under `node` that carry `markKey`. */
|
|
66
|
+
function anchorText(node: Json, markKey: string): string {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
const walk = (v: Json) => {
|
|
69
|
+
if (Array.isArray(v)) {
|
|
70
|
+
for (const item of v) walk(item);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!isObj(v)) return;
|
|
74
|
+
if (v._type === "span") {
|
|
75
|
+
if (Array.isArray(v.marks) && v.marks.includes(markKey) && typeof v.text === "string") parts.push(v.text);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const [k, child] of Object.entries(v)) if (k !== "markDefs" && k !== "asset") walk(child);
|
|
79
|
+
};
|
|
80
|
+
walk(node);
|
|
81
|
+
return parts.join("").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every trackable link in a Portable Text value (or any nested block structure). */
|
|
85
|
+
export function extractLinks(value: Json): FoundLink[] {
|
|
86
|
+
const out: FoundLink[] = [];
|
|
87
|
+
const walk = (v: Json) => {
|
|
88
|
+
if (Array.isArray(v)) {
|
|
89
|
+
for (const item of v) walk(item);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!isObj(v)) return;
|
|
93
|
+
if (Array.isArray(v.markDefs)) {
|
|
94
|
+
for (const def of v.markDefs) {
|
|
95
|
+
if (isObj(def) && def._type === "link" && isTrackable(def.href)) {
|
|
96
|
+
out.push({ href: def.href.trim(), kind: "text", anchor: anchorText(v, String(def._key ?? "")) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (v._type !== "span" && v._type !== "block") {
|
|
101
|
+
for (const key of URL_KEYS) {
|
|
102
|
+
const href = key === "link" ? linkHref(v[key]) : typeof v[key] === "string" ? (v[key] as string) : undefined;
|
|
103
|
+
if (isTrackable(href)) out.push({ href: href.trim(), kind: blockKind(v._type, key), anchor: "" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const [k, child] of Object.entries(v)) {
|
|
107
|
+
if (k === "markDefs" || k === "asset" || k === "link") continue;
|
|
108
|
+
if (typeof child === "object" && child !== null) walk(child);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(value);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Links in an entry's data, given the fields to look at and their types. */
|
|
116
|
+
export function extractEntryLinks(data: Obj, fields: Array<{ slug: string; type: string }>): FoundLink[] {
|
|
117
|
+
const out: FoundLink[] = [];
|
|
118
|
+
for (const field of fields) {
|
|
119
|
+
const value = data[field.slug];
|
|
120
|
+
if (value == null) continue;
|
|
121
|
+
if (field.type === "url") {
|
|
122
|
+
if (isTrackable(value)) out.push({ href: value.trim(), kind: "field", anchor: "" });
|
|
123
|
+
} else {
|
|
124
|
+
out.push(...extractLinks(value));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type LinkEdit = { type: "replace"; to: string } | { type: "unlink" };
|
|
131
|
+
|
|
132
|
+
export interface TransformResult<T> {
|
|
133
|
+
value: T;
|
|
134
|
+
/** Links changed (replaced or unlinked). */
|
|
135
|
+
changed: number;
|
|
136
|
+
/** Matching links that couldn't be unlinked (an embed's or iframe's own URL). */
|
|
137
|
+
skipped: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Remove `keys` from the marks of every span under `node`. Returns the same object when nothing changed. */
|
|
141
|
+
function stripMarks(node: Json, keys: Set<string>): Json {
|
|
142
|
+
if (Array.isArray(node)) {
|
|
143
|
+
let changed = false;
|
|
144
|
+
const next = node.map((item) => {
|
|
145
|
+
const n = stripMarks(item, keys);
|
|
146
|
+
if (n !== item) changed = true;
|
|
147
|
+
return n;
|
|
148
|
+
});
|
|
149
|
+
return changed ? next : node;
|
|
150
|
+
}
|
|
151
|
+
if (!isObj(node)) return node;
|
|
152
|
+
if (node._type === "span") {
|
|
153
|
+
if (!Array.isArray(node.marks) || !node.marks.some((m) => keys.has(m as string))) return node;
|
|
154
|
+
return { ...node, marks: node.marks.filter((m) => !keys.has(m as string)) };
|
|
155
|
+
}
|
|
156
|
+
let next: Obj | null = null;
|
|
157
|
+
for (const [k, child] of Object.entries(node)) {
|
|
158
|
+
if (k === "markDefs" || k === "asset" || typeof child !== "object" || child === null) continue;
|
|
159
|
+
const n = stripMarks(child, keys);
|
|
160
|
+
if (n !== child) {
|
|
161
|
+
next ??= { ...node };
|
|
162
|
+
next[k] = n;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return next ?? node;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Replace or unlink every link whose href satisfies `match`. Never mutates
|
|
170
|
+
* the input: changed objects are copied, unchanged subtrees are shared, and
|
|
171
|
+
* other marks, keys and fields are kept as they were.
|
|
172
|
+
*/
|
|
173
|
+
export function transformLinks<T>(value: T, match: (href: string) => boolean, edit: LinkEdit): TransformResult<T> {
|
|
174
|
+
let changed = 0;
|
|
175
|
+
let skipped = 0;
|
|
176
|
+
const hit = (href: unknown): boolean => typeof href === "string" && match(href.trim());
|
|
177
|
+
|
|
178
|
+
const walk = (v: Json): Json => {
|
|
179
|
+
if (Array.isArray(v)) {
|
|
180
|
+
let any = false;
|
|
181
|
+
const next = v.map((item) => {
|
|
182
|
+
const n = walk(item);
|
|
183
|
+
if (n !== item) any = true;
|
|
184
|
+
return n;
|
|
185
|
+
});
|
|
186
|
+
return any ? next : v;
|
|
187
|
+
}
|
|
188
|
+
if (!isObj(v)) return v;
|
|
189
|
+
|
|
190
|
+
let next: Obj | null = null;
|
|
191
|
+
const set = (k: string, val: unknown) => {
|
|
192
|
+
next ??= { ...v };
|
|
193
|
+
next[k] = val;
|
|
194
|
+
};
|
|
195
|
+
const del = (k: string) => {
|
|
196
|
+
next ??= { ...v };
|
|
197
|
+
delete next[k];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Children first, so mark stripping below sees the rewritten tree.
|
|
201
|
+
for (const [k, child] of Object.entries(v)) {
|
|
202
|
+
if (k === "markDefs" || k === "asset" || k === "link" || typeof child !== "object" || child === null) continue;
|
|
203
|
+
const n = walk(child);
|
|
204
|
+
if (n !== child) set(k, n);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (Array.isArray(v.markDefs)) {
|
|
208
|
+
const removed = new Set<string>();
|
|
209
|
+
let defsChanged = false;
|
|
210
|
+
const defs: unknown[] = [];
|
|
211
|
+
for (const def of v.markDefs) {
|
|
212
|
+
if (isObj(def) && def._type === "link" && hit(def.href)) {
|
|
213
|
+
changed++;
|
|
214
|
+
defsChanged = true;
|
|
215
|
+
if (edit.type === "replace") defs.push({ ...def, href: edit.to });
|
|
216
|
+
else removed.add(String(def._key ?? ""));
|
|
217
|
+
} else {
|
|
218
|
+
defs.push(def);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (defsChanged) {
|
|
222
|
+
set("markDefs", defs);
|
|
223
|
+
if (removed.size) {
|
|
224
|
+
const current: Obj = next ?? v;
|
|
225
|
+
const stripped = stripMarks({ ...current, markDefs: [] }, removed) as Obj;
|
|
226
|
+
for (const [k, child] of Object.entries(stripped)) if (k !== "markDefs" && child !== current[k]) set(k, child);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (v._type !== "span" && v._type !== "block") {
|
|
232
|
+
for (const key of URL_KEYS) {
|
|
233
|
+
const raw = v[key];
|
|
234
|
+
const href = key === "link" ? linkHref(raw) : typeof raw === "string" ? raw : undefined;
|
|
235
|
+
if (href === undefined || !hit(href)) continue;
|
|
236
|
+
if (edit.type === "replace") {
|
|
237
|
+
changed++;
|
|
238
|
+
set(key, key === "link" && isObj(raw) ? { ...raw, href: edit.to } : edit.to);
|
|
239
|
+
} else if ((key === "link" && v._type === "image") || (key === "url" && v._type === "button")) {
|
|
240
|
+
// Only shapes known to be links: a linked image keeps its image, a button keeps its label.
|
|
241
|
+
changed++;
|
|
242
|
+
del(key);
|
|
243
|
+
} else {
|
|
244
|
+
skipped++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return next ?? v;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return { value: walk(value) as T, changed, skipped };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Apply an edit to the given fields of an entry. Returns only the fields that changed. */
|
|
255
|
+
export function transformEntry(
|
|
256
|
+
data: Obj,
|
|
257
|
+
fields: Array<{ slug: string; type: string }>,
|
|
258
|
+
match: (href: string) => boolean,
|
|
259
|
+
edit: LinkEdit,
|
|
260
|
+
): { patch: Obj; changed: number; skipped: number } {
|
|
261
|
+
const patch: Obj = {};
|
|
262
|
+
let changed = 0;
|
|
263
|
+
let skipped = 0;
|
|
264
|
+
for (const field of fields) {
|
|
265
|
+
const value = data[field.slug];
|
|
266
|
+
if (value == null) continue;
|
|
267
|
+
if (field.type === "url") {
|
|
268
|
+
if (typeof value === "string" && isTrackable(value) && match(value.trim())) {
|
|
269
|
+
if (edit.type === "replace") {
|
|
270
|
+
patch[field.slug] = edit.to;
|
|
271
|
+
changed++;
|
|
272
|
+
} else skipped++;
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const result = transformLinks(value, match, edit);
|
|
277
|
+
changed += result.changed;
|
|
278
|
+
skipped += result.skipped;
|
|
279
|
+
if (result.value !== value) patch[field.slug] = result.value;
|
|
280
|
+
}
|
|
281
|
+
return { patch, changed, skipped };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── URLs ─────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** Resolve an href against the site. Returns null when it can't be checked. */
|
|
287
|
+
export function resolveHref(href: string, siteUrl: string): URL | null {
|
|
288
|
+
try {
|
|
289
|
+
const url = new URL(href.trim(), siteUrl);
|
|
290
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
291
|
+
url.hash = "";
|
|
292
|
+
return url;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Whether a replacement URL is acceptable: absolute http(s), a site path,
|
|
300
|
+
* a fragment, mailto: or tel:. Protocol-relative forms ("//host", "/\\host")
|
|
301
|
+
* and backslashes in the authority are refused, since browsers treat them as
|
|
302
|
+
* links to another host.
|
|
303
|
+
*/
|
|
304
|
+
export function isAllowedTarget(value: string): boolean {
|
|
305
|
+
const v = value.trim();
|
|
306
|
+
if (!v || v.length > 2048 || /[\u0000-\u001f]/.test(v)) return false;
|
|
307
|
+
if (!/^(https?:\/\/|\/|#|mailto:|tel:)/i.test(v)) return false;
|
|
308
|
+
if (/^\/[/\\]/.test(v) || /^https?:\/\/[^/?#]*\\/i.test(v) || /^https?:[/\\]{3}/i.test(v)) return false;
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Host without a leading "www.", lowercased. */
|
|
313
|
+
export function bareHost(host: string): string {
|
|
314
|
+
return host.toLowerCase().replace(/^www\./, "");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function isInternal(href: string, siteUrl: string): boolean {
|
|
318
|
+
const url = resolveHref(href, siteUrl);
|
|
319
|
+
if (!url) return false;
|
|
320
|
+
try {
|
|
321
|
+
return bareHost(url.hostname) === bareHost(new URL(siteUrl).hostname);
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|