@coywolf/emdash 0.0.0-stage → 0.35.13
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 +201 -2
- package/package.json +70 -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.test.ts +187 -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/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -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.test.mjs +287 -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.test.mjs +103 -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.test.ts +152 -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.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -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.test.ts +30 -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.test.ts +64 -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/headings.test.ts +159 -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.test.mjs +224 -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.test.mjs +670 -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.test.ts +50 -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.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -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.test.mjs +522 -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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save-time stamping (content:beforeSave). EmDash's Portable Text renderer
|
|
3
|
+
* emits headings without ids, and a block component only sees its own node,
|
|
4
|
+
* so everything the site needs is written into the content when it's saved:
|
|
5
|
+
*
|
|
6
|
+
* - each H2–H6 heading block gets an `anchor` field (e.g. "jump-pricing"),
|
|
7
|
+
* unique within its field and kept across edits (matched to the previous
|
|
8
|
+
* save by block `_key`, then by text), because the editor drops unknown
|
|
9
|
+
* fields on heading blocks;
|
|
10
|
+
* - each `coywolf-toc` block gets `_headings`, the field's heading list.
|
|
11
|
+
*
|
|
12
|
+
* (Breadcrumbs blocks are stamped by the Breadcrumb Nav module, src/breadcrumbs/stamp.ts.)
|
|
13
|
+
*
|
|
14
|
+
* Pure: returns new objects and never mutates its input. Running it twice
|
|
15
|
+
* gives the same result (`changed: false` the second time).
|
|
16
|
+
*/
|
|
17
|
+
import { blockText, headingLevel, uniqueSlug, validAnchor } from "./slug.js";
|
|
18
|
+
import type { TocHeading } from "./toc.js";
|
|
19
|
+
|
|
20
|
+
export const TOC_BLOCK = "coywolf-toc";
|
|
21
|
+
export const ANCHOR_LEVELS = [2, 3, 4, 5, 6];
|
|
22
|
+
|
|
23
|
+
export interface StampOptions {
|
|
24
|
+
/** Stamp heading anchors (and TOC heading lists, when `toc`). */
|
|
25
|
+
anchors: boolean;
|
|
26
|
+
toc: boolean;
|
|
27
|
+
prefix: string;
|
|
28
|
+
/** The entry's stored data before this save, to keep anchors stable. */
|
|
29
|
+
previous?: Record<string, unknown> | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Block = Record<string, unknown> & { _type: string; _key?: string };
|
|
33
|
+
|
|
34
|
+
/** An array that looks like Portable Text: every item is an object with a string `_type`. */
|
|
35
|
+
function isPortableText(value: unknown): value is Block[] {
|
|
36
|
+
return (
|
|
37
|
+
Array.isArray(value) &&
|
|
38
|
+
value.length > 0 &&
|
|
39
|
+
value.every((b) => b && typeof b === "object" && typeof (b as { _type?: unknown })._type === "string")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
44
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Previous anchors of one field, by block key and by heading text. */
|
|
48
|
+
function previousAnchors(previous: unknown) {
|
|
49
|
+
const byKey = new Map<string, string>();
|
|
50
|
+
const byText = new Map<string, string>();
|
|
51
|
+
if (isPortableText(previous)) {
|
|
52
|
+
for (const block of previous) {
|
|
53
|
+
if (!headingLevel(block) || !validAnchor(block.anchor)) continue;
|
|
54
|
+
if (typeof block._key === "string") byKey.set(block._key, block.anchor);
|
|
55
|
+
const text = blockText(block);
|
|
56
|
+
if (text && !byText.has(text)) byText.set(text, block.anchor);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { byKey, byText };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stamp one Portable Text field. Returns the new array, or null when nothing changed. */
|
|
63
|
+
export function stampField(blocks: Block[], options: StampOptions, previousField: unknown): Block[] | null {
|
|
64
|
+
const out = blocks.slice();
|
|
65
|
+
let changed = false;
|
|
66
|
+
const set = (i: number, patch: Record<string, unknown>, remove?: string) => {
|
|
67
|
+
const next: Block = { ...out[i], ...patch };
|
|
68
|
+
if (remove) delete next[remove];
|
|
69
|
+
out[i] = next;
|
|
70
|
+
changed = true;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const headings: TocHeading[] = [];
|
|
74
|
+
if (options.anchors) {
|
|
75
|
+
const { byKey, byText } = previousAnchors(previousField);
|
|
76
|
+
const taken = new Set<string>();
|
|
77
|
+
const pending: { index: number; level: number; text: string; kept: string | null; byText: string | null }[] = [];
|
|
78
|
+
|
|
79
|
+
blocks.forEach((block, index) => {
|
|
80
|
+
const level = headingLevel(block);
|
|
81
|
+
if (!level || !ANCHOR_LEVELS.includes(level)) return;
|
|
82
|
+
const text = blockText(block);
|
|
83
|
+
if (!text) {
|
|
84
|
+
if ("anchor" in block) set(index, {}, "anchor");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const kept = (validAnchor(block.anchor) ? block.anchor : null) ?? (typeof block._key === "string" ? byKey.get(block._key) : undefined) ?? null;
|
|
88
|
+
pending.push({ index, level, text, kept, byText: byText.get(text) ?? null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Claim anchors in priority order (first claim wins): the block's own or its previous
|
|
92
|
+
// anchor (by _key), then a previous anchor with the same text, then a new slug.
|
|
93
|
+
const ids = new Map<number, string>();
|
|
94
|
+
const claim = (pick: (p: (typeof pending)[number]) => string | null) => {
|
|
95
|
+
for (const p of pending) {
|
|
96
|
+
const id = ids.has(p.index) ? null : pick(p);
|
|
97
|
+
if (id && !taken.has(id)) {
|
|
98
|
+
taken.add(id);
|
|
99
|
+
ids.set(p.index, id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
claim((p) => p.kept);
|
|
104
|
+
claim((p) => p.byText);
|
|
105
|
+
for (const p of pending) if (!ids.has(p.index)) ids.set(p.index, uniqueSlug(p.text, taken, options.prefix));
|
|
106
|
+
|
|
107
|
+
for (const p of pending) {
|
|
108
|
+
const id = ids.get(p.index) as string;
|
|
109
|
+
if (blocks[p.index].anchor !== id) set(p.index, { anchor: id });
|
|
110
|
+
headings.push({ level: p.level, id, text: p.text });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.toc && options.anchors) {
|
|
115
|
+
blocks.forEach((block, index) => {
|
|
116
|
+
if (block._type === TOC_BLOCK && !sameJson(block._headings, headings)) set(index, { _headings: headings });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return changed ? out : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stamp every Portable Text field of an entry. Returns the new content, or null when nothing changed. */
|
|
124
|
+
export function stampContent(content: Record<string, unknown>, options: StampOptions): Record<string, unknown> | null {
|
|
125
|
+
let result: Record<string, unknown> | null = null;
|
|
126
|
+
for (const [field, value] of Object.entries(content)) {
|
|
127
|
+
if (!isPortableText(value)) continue;
|
|
128
|
+
const stamped = stampField(value, options, options.previous?.[field]);
|
|
129
|
+
if (stamped) {
|
|
130
|
+
result ??= { ...content };
|
|
131
|
+
result[field] = stamped;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether any field has a heading without a valid anchor (so the previous version is worth reading). */
|
|
138
|
+
export function needsPrevious(content: Record<string, unknown>): boolean {
|
|
139
|
+
return Object.values(content).some(
|
|
140
|
+
(value) => isPortableText(value) && value.some((block) => headingLevel(block) && !validAnchor(block.anchor)),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test-only resolve hook: source files import "./x.js" (bundler style); under
|
|
2
|
+
// `node --test` with type stripping, map those to the sibling .ts file.
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
|
|
5
|
+
registerHooks({
|
|
6
|
+
resolve(specifier, context, next) {
|
|
7
|
+
try {
|
|
8
|
+
return next(specifier, context);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js")) return next(specifier.slice(0, -3) + ".ts", context);
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table of contents tree: the flat heading list stamped into a TOC block,
|
|
3
|
+
* filtered to the block's levels and nested by level. Tolerant of skipped
|
|
4
|
+
* levels (an H4 straight after an H2 nests under the H2).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TocHeading {
|
|
8
|
+
level: number;
|
|
9
|
+
id: string;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TocNode extends TocHeading {
|
|
14
|
+
children: TocNode[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildTocTree(headings: readonly TocHeading[], levels: readonly number[]): TocNode[] {
|
|
18
|
+
const wanted = new Set(levels);
|
|
19
|
+
const root: TocNode[] = [];
|
|
20
|
+
const stack: TocNode[] = [];
|
|
21
|
+
for (const heading of headings) {
|
|
22
|
+
if (!wanted.has(heading.level)) continue;
|
|
23
|
+
const node: TocNode = { level: heading.level, id: heading.id, text: heading.text, children: [] };
|
|
24
|
+
while (stack.length && stack[stack.length - 1].level >= node.level) stack.pop();
|
|
25
|
+
(stack.length ? stack[stack.length - 1].children : root).push(node);
|
|
26
|
+
stack.push(node);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Count every node in a tree. */
|
|
32
|
+
export function countToc(nodes: readonly TocNode[]): number {
|
|
33
|
+
return nodes.reduce((n, node) => n + 1 + countToc(node.children), 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Clean the heading list stored on a TOC block (it's content data, so treat it as untrusted). */
|
|
37
|
+
export function storedHeadings(value: unknown): TocHeading[] {
|
|
38
|
+
if (!Array.isArray(value)) return [];
|
|
39
|
+
const out: TocHeading[] = [];
|
|
40
|
+
for (const item of value) {
|
|
41
|
+
if (!item || typeof item !== "object") continue;
|
|
42
|
+
const { level, id, text } = item as Record<string, unknown>;
|
|
43
|
+
if (typeof level !== "number" || level < 1 || level > 6) continue;
|
|
44
|
+
if (typeof id !== "string" || !id || typeof text !== "string" || !text) continue;
|
|
45
|
+
out.push({ level, id, text });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media host setup and checks through the Cloudflare API, plus the live check
|
|
3
|
+
* of a media host. The API token is only ever sent to api.cloudflare.com: it
|
|
4
|
+
* is never logged, returned, or included in error messages.
|
|
5
|
+
*/
|
|
6
|
+
import { type CustomDomain, type RulesetRule, type SetupPlan, type SetupState, hostnameOf, planMediaHostSetup, zoneCandidates } from "./setup.js";
|
|
7
|
+
|
|
8
|
+
export const CLOUDFLARE_API_HOST = "api.cloudflare.com";
|
|
9
|
+
const API = `https://${CLOUDFLARE_API_HOST}/client/v4`;
|
|
10
|
+
|
|
11
|
+
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
export interface CloudflareConfig {
|
|
14
|
+
token: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
fetch?: Fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CloudflareApiError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
constructor(message: string, status: number) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What each step of the setup needs, for error messages when the token lacks it. */
|
|
28
|
+
const PERMISSION_HINT: Record<string, string> = {
|
|
29
|
+
zone: "Zone → Zone → Read",
|
|
30
|
+
transformations: "Zone → Zone Settings → Edit",
|
|
31
|
+
domain: "Account → Workers R2 Storage → Edit and Zone → DNS → Edit",
|
|
32
|
+
rules: "Zone → Transform Rules → Edit (and Account → Account Rulesets → Read)",
|
|
33
|
+
purge: "Zone → Cache Purge → Purge",
|
|
34
|
+
cacheRules: "Zone → Cache Rules → Edit",
|
|
35
|
+
tieredCache: "Zone → Zone Settings → Edit",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function cf<T>(config: CloudflareConfig, path: string, init: RequestInit = {}, step?: string): Promise<{ status: number; result: T | null }> {
|
|
39
|
+
const doFetch = config.fetch ?? fetch;
|
|
40
|
+
const res = await doFetch(`${API}${path}`, {
|
|
41
|
+
...init,
|
|
42
|
+
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
|
|
43
|
+
});
|
|
44
|
+
const body = (await res.json().catch(() => ({}))) as { success?: boolean; result?: T; errors?: Array<{ code?: number; message?: string }> };
|
|
45
|
+
if (!res.ok || body.success === false) {
|
|
46
|
+
const detail = body.errors?.map((e) => e.message).filter(Boolean).join("; ") || res.statusText;
|
|
47
|
+
const hint = (res.status === 401 || res.status === 403) && step ? ` The API token needs ${PERMISSION_HINT[step]}.` : "";
|
|
48
|
+
throw new CloudflareApiError(`Cloudflare API ${res.status}: ${detail}.${hint}`, res.status);
|
|
49
|
+
}
|
|
50
|
+
return { status: res.status, result: body.result ?? null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear everything Cloudflare's zone cache holds for one host name (the media
|
|
55
|
+
* host: originals and resized copies). Purge by hostname works on every plan.
|
|
56
|
+
*/
|
|
57
|
+
export async function purgeHost(config: CloudflareConfig, hostname: string): Promise<void> {
|
|
58
|
+
const zone = await findZone(config, hostname);
|
|
59
|
+
await cf(config, `/zones/${zone.id}/purge_cache`, { method: "POST", body: JSON.stringify({ hosts: [hostname] }) }, "purge");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Description that marks the media host's cache rule as the pack's own. */
|
|
63
|
+
export const MEDIA_CACHE_RULE = "Coywolf Pack: media host";
|
|
64
|
+
const YEAR = 31536000;
|
|
65
|
+
|
|
66
|
+
interface CacheRule {
|
|
67
|
+
id?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
expression?: string;
|
|
70
|
+
action?: string;
|
|
71
|
+
action_parameters?: { cache?: boolean; edge_ttl?: { default?: number }; browser_ttl?: { default?: number } };
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mediaCacheRule(hostname: string): CacheRule {
|
|
76
|
+
return {
|
|
77
|
+
description: `${MEDIA_CACHE_RULE} (file names never change)`,
|
|
78
|
+
expression: `(http.host eq "${hostname}")`,
|
|
79
|
+
action: "set_cache_settings",
|
|
80
|
+
action_parameters: { cache: true, edge_ttl: { mode: "override_origin", default: YEAR }, browser_ttl: { mode: "override_origin", default: YEAR } } as CacheRule["action_parameters"],
|
|
81
|
+
enabled: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The media host's Cache Rule, if any: a zone cache rule for exactly this host
|
|
87
|
+
* name that caches it (made by the pack or by hand).
|
|
88
|
+
*/
|
|
89
|
+
export async function readMediaCacheRule(config: CloudflareConfig, hostname: string): Promise<{ zone: string; rule: { edgeTtl?: number; browserTtl?: number; enabled: boolean } | null }> {
|
|
90
|
+
const zone = await findZone(config, hostname);
|
|
91
|
+
let rules: CacheRule[] = [];
|
|
92
|
+
try {
|
|
93
|
+
const { result } = await cf<{ rules?: CacheRule[] }>(config, `/zones/${zone.id}/rulesets/phases/http_request_cache_settings/entrypoint`, {}, "cacheRules");
|
|
94
|
+
rules = result?.rules ?? [];
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!(error instanceof CloudflareApiError && error.status === 404)) throw error;
|
|
97
|
+
}
|
|
98
|
+
const rule = rules.find((r) => r.action === "set_cache_settings" && r.action_parameters?.cache === true && r.expression?.includes(`"${hostname}"`));
|
|
99
|
+
return {
|
|
100
|
+
zone: zone.name,
|
|
101
|
+
rule: rule ? { edgeTtl: rule.action_parameters?.edge_ttl?.default, browserTtl: rule.action_parameters?.browser_ttl?.default, enabled: rule.enabled !== false } : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Cache the media host at the edge and in browsers for a year (its file names
|
|
107
|
+
* are unique, so a changed file always has a new name). Adds the rule to the
|
|
108
|
+
* zone's cache rules, or updates the pack's earlier one; other rules are kept.
|
|
109
|
+
*/
|
|
110
|
+
export async function applyMediaCacheRule(config: CloudflareConfig, hostname: string): Promise<string> {
|
|
111
|
+
const zone = await findZone(config, hostname);
|
|
112
|
+
const phase = `/zones/${zone.id}/rulesets/phases/http_request_cache_settings/entrypoint`;
|
|
113
|
+
let ruleset: { id: string; rules?: CacheRule[] } | null = null;
|
|
114
|
+
try {
|
|
115
|
+
ruleset = (await cf<{ id: string; rules?: CacheRule[] }>(config, phase, {}, "cacheRules")).result;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!(error instanceof CloudflareApiError && error.status === 404)) throw error;
|
|
118
|
+
}
|
|
119
|
+
const rule = mediaCacheRule(hostname);
|
|
120
|
+
if (!ruleset) {
|
|
121
|
+
await cf(config, phase, { method: "PUT", body: JSON.stringify({ rules: [rule] }) }, "cacheRules");
|
|
122
|
+
return zone.name;
|
|
123
|
+
}
|
|
124
|
+
const existing = ruleset.rules?.find((r) => r.description?.startsWith(MEDIA_CACHE_RULE) && r.expression?.includes(`"${hostname}"`));
|
|
125
|
+
if (existing?.id) {
|
|
126
|
+
await cf(config, `/zones/${zone.id}/rulesets/${ruleset.id}/rules/${existing.id}`, { method: "PATCH", body: JSON.stringify(rule) }, "cacheRules");
|
|
127
|
+
} else {
|
|
128
|
+
await cf(config, `/zones/${zone.id}/rulesets/${ruleset.id}/rules`, { method: "POST", body: JSON.stringify(rule) }, "cacheRules");
|
|
129
|
+
}
|
|
130
|
+
return zone.name;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The zone that holds a host name, looked up in the account (most specific name first). */
|
|
134
|
+
export async function findZone(config: CloudflareConfig, hostname: string): Promise<{ id: string; name: string; plan?: string }> {
|
|
135
|
+
for (const name of zoneCandidates(hostname)) {
|
|
136
|
+
const { result } = await cf<Array<{ id: string; name: string; plan?: { name?: string } }>>(
|
|
137
|
+
config,
|
|
138
|
+
`/zones?name=${encodeURIComponent(name)}&account.id=${encodeURIComponent(config.accountId)}`,
|
|
139
|
+
{},
|
|
140
|
+
"zone",
|
|
141
|
+
);
|
|
142
|
+
const zone = result?.find((z) => z.name === name);
|
|
143
|
+
if (zone) return { id: zone.id, name: zone.name, plan: zone.plan?.name };
|
|
144
|
+
}
|
|
145
|
+
throw new CloudflareApiError(
|
|
146
|
+
`No zone for ${hostname} in this Cloudflare account. Add the site's domain to the account, or check the account ID. The API token needs ${PERMISSION_HINT.zone}.`,
|
|
147
|
+
404,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readRules(config: CloudflareConfig, zoneId: string): Promise<RulesetRule[] | null> {
|
|
152
|
+
try {
|
|
153
|
+
const { result } = await cf<{ rules?: RulesetRule[] }>(config, `/zones/${zoneId}/rulesets/phases/http_request_transform/entrypoint`, {}, "rules");
|
|
154
|
+
return result?.rules ?? [];
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error instanceof CloudflareApiError && error.status === 404) return null;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface SetupTarget {
|
|
162
|
+
host: string;
|
|
163
|
+
bucket: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SetupContext {
|
|
167
|
+
zone: { id: string; name: string; plan?: string };
|
|
168
|
+
state: SetupState;
|
|
169
|
+
plan: SetupPlan;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Read the current state and plan the setup (changes nothing). */
|
|
173
|
+
export async function planSetup(config: CloudflareConfig, target: SetupTarget): Promise<SetupContext> {
|
|
174
|
+
const hostname = hostnameOf(target.host);
|
|
175
|
+
const zone = await findZone(config, hostname);
|
|
176
|
+
const [transformations, domains, rules, tieredCache] = await Promise.all([
|
|
177
|
+
cf<{ value?: string }>(config, `/zones/${zone.id}/settings/transformations`, {}, "transformations").then((r) => r.result?.value ?? null),
|
|
178
|
+
cf<{ domains?: CustomDomain[] }>(config, `/accounts/${config.accountId}/r2/buckets/${encodeURIComponent(target.bucket)}/domains/custom`, {}, "domain").then(
|
|
179
|
+
(r) => r.result?.domains ?? [],
|
|
180
|
+
),
|
|
181
|
+
readRules(config, zone.id),
|
|
182
|
+
// Read only to show the step; a token without the permission still gets the rest of the setup.
|
|
183
|
+
cf<{ value?: string }>(config, `/zones/${zone.id}/cache/tiered_cache_smart_topology_enable`, {}, "tieredCache")
|
|
184
|
+
.then((r) => r.result?.value ?? null)
|
|
185
|
+
.catch(() => null),
|
|
186
|
+
]);
|
|
187
|
+
const state: SetupState = { transformations, domains, rules, tieredCache };
|
|
188
|
+
return { zone, state, plan: planMediaHostSetup(state, { host: hostname, zoneName: zone.name, bucket: target.bucket }) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface StepResult {
|
|
192
|
+
id: string;
|
|
193
|
+
ok: boolean;
|
|
194
|
+
message: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Apply the steps that need it, in order; stops at the first failure. */
|
|
198
|
+
export async function applySetup(config: CloudflareConfig, target: SetupTarget): Promise<{ zone: string; results: StepResult[] }> {
|
|
199
|
+
const { zone, plan } = await planSetup(config, target);
|
|
200
|
+
const bucket = encodeURIComponent(target.bucket);
|
|
201
|
+
const results: StepResult[] = [];
|
|
202
|
+
for (const step of plan.steps) {
|
|
203
|
+
if (step.action === "none") {
|
|
204
|
+
results.push({ id: step.id, ok: true, message: step.label });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
if (step.id === "transformations") {
|
|
209
|
+
await cf(config, `/zones/${zone.id}/settings/transformations`, { method: "PATCH", body: JSON.stringify({ value: "on" }) }, "transformations");
|
|
210
|
+
results.push({ id: step.id, ok: true, message: `Turned on Image Transformations for ${zone.name}.` });
|
|
211
|
+
} else if (step.id === "domain" && step.action === "add") {
|
|
212
|
+
await cf(
|
|
213
|
+
config,
|
|
214
|
+
`/accounts/${config.accountId}/r2/buckets/${bucket}/domains/custom`,
|
|
215
|
+
{ method: "POST", body: JSON.stringify({ domain: plan.host, zoneId: zone.id, enabled: true, minTLS: "1.2" }) },
|
|
216
|
+
"domain",
|
|
217
|
+
);
|
|
218
|
+
results.push({ id: step.id, ok: true, message: `Connected ${plan.host} to ${target.bucket}. Its certificate can take a few minutes.` });
|
|
219
|
+
} else if (step.id === "domain") {
|
|
220
|
+
await cf(
|
|
221
|
+
config,
|
|
222
|
+
`/accounts/${config.accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(plan.host)}`,
|
|
223
|
+
{ method: "PUT", body: JSON.stringify({ enabled: true, minTLS: "1.2" }) },
|
|
224
|
+
"domain",
|
|
225
|
+
);
|
|
226
|
+
results.push({ id: step.id, ok: true, message: `Turned on ${plan.host} for ${target.bucket}.` });
|
|
227
|
+
} else if (step.id === "rules") {
|
|
228
|
+
await cf(
|
|
229
|
+
config,
|
|
230
|
+
`/zones/${zone.id}/rulesets/phases/http_request_transform/entrypoint`,
|
|
231
|
+
{ method: "PUT", body: JSON.stringify({ rules: plan.rules }) },
|
|
232
|
+
"rules",
|
|
233
|
+
);
|
|
234
|
+
results.push({ id: step.id, ok: true, message: `Saved the URL rewrite rules for ${plan.host}.` });
|
|
235
|
+
} else if (step.id === "tieredCache") {
|
|
236
|
+
await cf(config, `/zones/${zone.id}/cache/tiered_cache_smart_topology_enable`, { method: "PATCH", body: JSON.stringify({ value: "on" }) }, "tieredCache");
|
|
237
|
+
results.push({ id: step.id, ok: true, message: `Turned on Smart Tiered Cache for ${zone.name}.` });
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
results.push({ id: step.id, ok: false, message: error instanceof Error ? error.message : "Failed." });
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { zone: zone.name, results };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Live check of a media host ───────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export interface CheckItem {
|
|
250
|
+
id: "reachable" | "original" | "resize" | "cached";
|
|
251
|
+
ok: boolean;
|
|
252
|
+
label: string;
|
|
253
|
+
detail: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Pure: turn the two fetches' outcomes into check items (exported for tests). */
|
|
257
|
+
export function checkItems(input: {
|
|
258
|
+
file: string;
|
|
259
|
+
original: { status: number; type: string } | { error: string };
|
|
260
|
+
resized: { status: number; type: string; cfResized: string | null; cache: string | null } | { error: string };
|
|
261
|
+
}): CheckItem[] {
|
|
262
|
+
const items: CheckItem[] = [];
|
|
263
|
+
const o = input.original;
|
|
264
|
+
const r = input.resized;
|
|
265
|
+
const reached = !("error" in o) || !("error" in r);
|
|
266
|
+
items.push({
|
|
267
|
+
id: "reachable",
|
|
268
|
+
ok: reached,
|
|
269
|
+
label: "Host reachable",
|
|
270
|
+
detail: reached ? "The media host answered." : `Couldn't connect: ${"error" in o ? o.error : ""}. Check the custom domain on the bucket and its DNS.`,
|
|
271
|
+
});
|
|
272
|
+
const originalOk = !("error" in o) && o.status === 200 && o.type.startsWith("image/");
|
|
273
|
+
items.push({
|
|
274
|
+
id: "original",
|
|
275
|
+
ok: originalOk,
|
|
276
|
+
label: "Original served",
|
|
277
|
+
detail:
|
|
278
|
+
"error" in o
|
|
279
|
+
? "No answer."
|
|
280
|
+
: originalOk
|
|
281
|
+
? `${input.file} (${o.type}).`
|
|
282
|
+
: o.status === 404
|
|
283
|
+
? `${input.file} wasn't found (404). Is the custom domain on the bucket bound as MEDIA?`
|
|
284
|
+
: `Answered ${o.status}${o.type ? ` (${o.type})` : ""}.`,
|
|
285
|
+
});
|
|
286
|
+
const resizeOk = !("error" in r) && r.status === 200 && (Boolean(r.cfResized) || r.type.startsWith("image/"));
|
|
287
|
+
items.push({
|
|
288
|
+
id: "resize",
|
|
289
|
+
ok: resizeOk,
|
|
290
|
+
label: "Resize works",
|
|
291
|
+
detail:
|
|
292
|
+
"error" in r
|
|
293
|
+
? "No answer."
|
|
294
|
+
: resizeOk
|
|
295
|
+
? `/s/64x64/ answered ${r.type || "an image"}${r.cfResized ? " (resized by Cloudflare)" : ""}.`
|
|
296
|
+
: r.status === 404
|
|
297
|
+
? originalOk
|
|
298
|
+
? "/s/64x64/ wasn't found (404) from inside the Worker. A check from the Worker isn't conclusive for a media host on the site's own zone, so this alone doesn't mean the URL rewrite rules are missing: the Clean Image URLs page checks again from your browser."
|
|
299
|
+
: "/s/64x64/ wasn't found (404): the URL rewrite rules are missing, or don't match this host."
|
|
300
|
+
: `/s/64x64/ answered ${r.status}. Are Image Transformations on for the zone?`,
|
|
301
|
+
});
|
|
302
|
+
const cache = "error" in r ? null : (r.cache ?? "").toUpperCase();
|
|
303
|
+
const cached = cache === "HIT" || cache === "REVALIDATED" || cache === "STALE" || cache === "UPDATING";
|
|
304
|
+
items.push({
|
|
305
|
+
id: "cached",
|
|
306
|
+
ok: cached,
|
|
307
|
+
label: "Cached at the edge",
|
|
308
|
+
detail: cache ? `Second request: cf-cache-status ${cache}.` : "No cf-cache-status header (normal for the first few requests after setup).",
|
|
309
|
+
});
|
|
310
|
+
return items;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Fetch a file and a 64×64 copy from the media host (the copy twice, to see the cache). */
|
|
314
|
+
export async function checkMediaHost(host: string, file: string, doFetch: Fetch = fetch): Promise<CheckItem[]> {
|
|
315
|
+
const accept = "image/avif,image/webp,image/*,*/*;q=0.8";
|
|
316
|
+
const get = async (url: string) => {
|
|
317
|
+
try {
|
|
318
|
+
const res = await doFetch(url, { headers: { Accept: accept }, redirect: "follow" });
|
|
319
|
+
await res.body?.cancel().catch(() => undefined);
|
|
320
|
+
return { status: res.status, type: (res.headers.get("content-type") ?? "").split(";")[0].trim(), cfResized: res.headers.get("cf-resized"), cache: res.headers.get("cf-cache-status") };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return { error: error instanceof Error ? error.message : "network error" };
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const original = await get(`${host}/${file}`);
|
|
326
|
+
const first = await get(`${host}/s/64x64/${file}`);
|
|
327
|
+
const resized = "error" in first || first.status !== 200 ? first : await get(`${host}/s/64x64/${file}`);
|
|
328
|
+
return checkItems({ file, original, resized });
|
|
329
|
+
}
|