@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,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup storage layout (shared with .github/workflows/backup.yml):
|
|
3
|
+
*
|
|
4
|
+
* d1/<stamp>/<file>.sql.gz database dump
|
|
5
|
+
* d1/<stamp>/manifest.json what the dump is and where it came from
|
|
6
|
+
* media/current/<key> mirror of the media bucket
|
|
7
|
+
* media/changed/<stamp>/<key> media replaced or deleted since the previous run
|
|
8
|
+
* uploads/current/<key> mirror of the private form uploads bucket (when bound)
|
|
9
|
+
* uploads/changed/<stamp>/<key> uploads replaced or deleted since the previous run
|
|
10
|
+
*
|
|
11
|
+
* The backup bucket must stay private: uploads/ holds files people sent
|
|
12
|
+
* through forms, which aren't public on the site.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Manifest {
|
|
16
|
+
stamp: string;
|
|
17
|
+
database?: string;
|
|
18
|
+
file: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
/** "github-actions" (external nightly job), "scheduled" (plugin), or "admin" (Back up now). */
|
|
22
|
+
source?: string;
|
|
23
|
+
timeTravelBookmark?: string;
|
|
24
|
+
gitSha?: string;
|
|
25
|
+
tables?: number;
|
|
26
|
+
rows?: number;
|
|
27
|
+
media?: MirrorResult;
|
|
28
|
+
/** Private form uploads, when the site has an uploads bucket. */
|
|
29
|
+
uploads?: MirrorResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MirrorResult {
|
|
33
|
+
copied: number;
|
|
34
|
+
preserved: number;
|
|
35
|
+
total: number;
|
|
36
|
+
pending?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Folder in the backup bucket for each mirrored bucket. */
|
|
40
|
+
export type MirrorPrefix = "media" | "uploads";
|
|
41
|
+
|
|
42
|
+
export interface BackupEntry extends Manifest {
|
|
43
|
+
createdAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const STAMP = /^\d{4}-\d{2}-\d{2}T\d{4}Z$/;
|
|
47
|
+
export const FILE = /^[a-z0-9._-]+$/i;
|
|
48
|
+
|
|
49
|
+
/** 2026-10-03T1802Z — sortable, safe in object keys and URLs. */
|
|
50
|
+
export function makeStamp(date = new Date()): string {
|
|
51
|
+
const iso = date.toISOString();
|
|
52
|
+
return `${iso.slice(0, 13)}${iso.slice(14, 16)}Z`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stampToDate(stamp: string): Date {
|
|
56
|
+
return new Date(`${stamp.slice(0, 13)}:${stamp.slice(13, 15)}:00Z`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sha256(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
60
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
61
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** R2's smallest multipart part. Every part but the last must be the same size. */
|
|
65
|
+
export const PART_BYTES = 5 * 1024 * 1024;
|
|
66
|
+
/** Text handed to the gzip stream at a time. */
|
|
67
|
+
const FEED_BYTES = 64 * 1024;
|
|
68
|
+
const utf8 = new TextEncoder();
|
|
69
|
+
|
|
70
|
+
/** SHA-256 over a stream of chunks: Workers' DigestStream, else (Node tests) the chunks kept and hashed at the end. */
|
|
71
|
+
function digester(): { update(chunk: Uint8Array<ArrayBuffer>): Promise<void>; digest(): Promise<string> } {
|
|
72
|
+
const hex = (digest: ArrayBuffer) => [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
73
|
+
const Stream = (crypto as unknown as { DigestStream?: new (algorithm: string) => WritableStream<Uint8Array> & { digest: Promise<ArrayBuffer> } }).DigestStream;
|
|
74
|
+
if (Stream) {
|
|
75
|
+
const stream = new Stream("SHA-256");
|
|
76
|
+
const writer = stream.getWriter();
|
|
77
|
+
return {
|
|
78
|
+
update: (chunk) => writer.write(chunk),
|
|
79
|
+
digest: async () => {
|
|
80
|
+
await writer.close();
|
|
81
|
+
return hex(await stream.digest);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
86
|
+
return {
|
|
87
|
+
update: async (chunk) => {
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
},
|
|
90
|
+
digest: () => sha256(concat(chunks, chunks.reduce((n, c) => n + c.byteLength, 0))),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function concat(chunks: Uint8Array<ArrayBuffer>[], size: number): Uint8Array<ArrayBuffer> {
|
|
95
|
+
const out = new Uint8Array(size);
|
|
96
|
+
let at = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
out.set(chunk, at);
|
|
99
|
+
at += chunk.byteLength;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Gzip the dump's statements (one per line) straight into the backup bucket:
|
|
106
|
+
* one put when it's under a part, else an R2 multipart upload in PART_BYTES
|
|
107
|
+
* parts. Memory stays around one part plus a page of rows, however big the
|
|
108
|
+
* database. Returns the stored size and SHA-256 of the gzip file.
|
|
109
|
+
*/
|
|
110
|
+
export async function writeDumpStream(
|
|
111
|
+
bucket: R2Bucket,
|
|
112
|
+
stamp: string,
|
|
113
|
+
file: string,
|
|
114
|
+
lines: AsyncIterable<string>,
|
|
115
|
+
partBytes = PART_BYTES,
|
|
116
|
+
): Promise<{ bytes: number; sha256: string }> {
|
|
117
|
+
const key = `d1/${stamp}/${file}`;
|
|
118
|
+
const httpMetadata = { contentType: "application/gzip" };
|
|
119
|
+
const gzip = new CompressionStream("gzip");
|
|
120
|
+
const writer = gzip.writable.getWriter();
|
|
121
|
+
// Feed the compressor while the loop below drains it (each waits on the other).
|
|
122
|
+
/** An error from the dump itself (a failed query), which explains more than the stream errors it causes. */
|
|
123
|
+
let dumpError: { error: unknown } | null = null;
|
|
124
|
+
const feed = (async () => {
|
|
125
|
+
let batch = "";
|
|
126
|
+
const iterator = lines[Symbol.asyncIterator]();
|
|
127
|
+
for (;;) {
|
|
128
|
+
let next: IteratorResult<string>;
|
|
129
|
+
try {
|
|
130
|
+
next = await iterator.next();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
dumpError = { error };
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
if (next.done) break;
|
|
136
|
+
batch += `${next.value}\n`;
|
|
137
|
+
if (batch.length >= FEED_BYTES) {
|
|
138
|
+
await writer.write(utf8.encode(batch));
|
|
139
|
+
batch = "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (batch) await writer.write(utf8.encode(batch));
|
|
143
|
+
await writer.close();
|
|
144
|
+
})();
|
|
145
|
+
feed.catch((error) => writer.abort(error).catch(() => undefined));
|
|
146
|
+
|
|
147
|
+
const hash = digester();
|
|
148
|
+
let pending: Uint8Array<ArrayBuffer>[] = [];
|
|
149
|
+
let pendingBytes = 0;
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
/** The first `size` pending bytes as one buffer; the rest stay pending. */
|
|
152
|
+
const take = (size: number): Uint8Array<ArrayBuffer> => {
|
|
153
|
+
const all = concat(pending, pendingBytes);
|
|
154
|
+
pending = size < pendingBytes ? [all.slice(size)] : [];
|
|
155
|
+
pendingBytes -= size;
|
|
156
|
+
return size < all.byteLength ? all.slice(0, size) : all;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let upload: R2MultipartUpload | null = null;
|
|
160
|
+
const parts: R2UploadedPart[] = [];
|
|
161
|
+
const reader = gzip.readable.getReader();
|
|
162
|
+
try {
|
|
163
|
+
for (;;) {
|
|
164
|
+
const { value, done } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
const chunk = value as Uint8Array<ArrayBuffer>;
|
|
167
|
+
bytes += chunk.byteLength;
|
|
168
|
+
await hash.update(chunk);
|
|
169
|
+
pending.push(chunk);
|
|
170
|
+
pendingBytes += chunk.byteLength;
|
|
171
|
+
while (pendingBytes >= partBytes) {
|
|
172
|
+
upload ??= await bucket.createMultipartUpload(key, { httpMetadata });
|
|
173
|
+
parts.push(await upload.uploadPart(parts.length + 1, take(partBytes)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await feed;
|
|
177
|
+
const rest = take(pendingBytes);
|
|
178
|
+
if (upload) {
|
|
179
|
+
if (rest.byteLength) parts.push(await upload.uploadPart(parts.length + 1, rest));
|
|
180
|
+
await upload.complete(parts);
|
|
181
|
+
} else {
|
|
182
|
+
await bucket.put(key, rest, { httpMetadata });
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await upload?.abort().catch(() => undefined);
|
|
186
|
+
// Unblock the feed (it may be waiting for room in the stream) and let it finish.
|
|
187
|
+
await reader.cancel(error).catch(() => undefined);
|
|
188
|
+
await feed.catch(() => undefined);
|
|
189
|
+
throw dumpError ? (dumpError as { error: unknown }).error : error;
|
|
190
|
+
}
|
|
191
|
+
return { bytes, sha256: await hash.digest() };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Write (or rewrite) a backup's manifest for a dump already stored. */
|
|
195
|
+
export async function writeManifest(
|
|
196
|
+
bucket: R2Bucket,
|
|
197
|
+
stamp: string,
|
|
198
|
+
file: string,
|
|
199
|
+
written: { bytes: number; sha256: string },
|
|
200
|
+
extra: Partial<Manifest>,
|
|
201
|
+
): Promise<Manifest> {
|
|
202
|
+
const manifest: Manifest = { ...extra, stamp, file, bytes: written.bytes, sha256: written.sha256 };
|
|
203
|
+
await bucket.put(`d1/${stamp}/manifest.json`, JSON.stringify(manifest, null, 1), {
|
|
204
|
+
httpMetadata: { contentType: "application/json" },
|
|
205
|
+
});
|
|
206
|
+
return manifest;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/** Every backup with a manifest, newest first. */
|
|
211
|
+
export async function listBackups(bucket: R2Bucket): Promise<BackupEntry[]> {
|
|
212
|
+
const stamps: string[] = [];
|
|
213
|
+
let cursor: string | undefined;
|
|
214
|
+
do {
|
|
215
|
+
const page = await bucket.list({ prefix: "d1/", delimiter: "/", cursor });
|
|
216
|
+
for (const prefix of page.delimitedPrefixes) {
|
|
217
|
+
const stamp = prefix.slice(3, -1);
|
|
218
|
+
if (STAMP.test(stamp)) stamps.push(stamp);
|
|
219
|
+
}
|
|
220
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
221
|
+
} while (cursor);
|
|
222
|
+
|
|
223
|
+
const entries = await Promise.all(
|
|
224
|
+
stamps.map(async (stamp) => {
|
|
225
|
+
const object = await bucket.get(`d1/${stamp}/manifest.json`);
|
|
226
|
+
if (!object) return null;
|
|
227
|
+
try {
|
|
228
|
+
const manifest = (await object.json()) as Manifest;
|
|
229
|
+
return { ...manifest, stamp, createdAt: stampToDate(stamp).toISOString() };
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
return entries.filter((e): e is BackupEntry => e !== null).sort((a, b) => b.stamp.localeCompare(a.stamp));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Mirror the media bucket into media/current/, moving anything replaced or
|
|
240
|
+
* deleted since the last run to media/changed/<stamp>/ (same as the nightly
|
|
241
|
+
* job's `rclone sync --backup-dir`). Compares by ETag.
|
|
242
|
+
*
|
|
243
|
+
* Copies at most `maxChanges` files per run so a large first mirror stays under
|
|
244
|
+
* the Workers subrequest limit; the rest are reported as `pending` and copied
|
|
245
|
+
* on the next run.
|
|
246
|
+
*/
|
|
247
|
+
export async function mirrorMedia(media: R2Bucket, backups: R2Bucket, stamp: string, maxChanges = 300): Promise<MirrorResult> {
|
|
248
|
+
return mirrorBucket(media, backups, stamp, "media", maxChanges);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** mirrorMedia for any bucket, into <prefix>/current/ and <prefix>/changed/<stamp>/. */
|
|
252
|
+
export async function mirrorBucket(
|
|
253
|
+
media: R2Bucket,
|
|
254
|
+
backups: R2Bucket,
|
|
255
|
+
stamp: string,
|
|
256
|
+
prefix: MirrorPrefix,
|
|
257
|
+
maxChanges = 300,
|
|
258
|
+
): Promise<MirrorResult> {
|
|
259
|
+
const current = `${prefix}/current/`;
|
|
260
|
+
const source = new Map<string, string>();
|
|
261
|
+
let cursor: string | undefined;
|
|
262
|
+
do {
|
|
263
|
+
const page = await media.list({ cursor });
|
|
264
|
+
for (const object of page.objects) source.set(object.key, object.etag);
|
|
265
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
266
|
+
} while (cursor);
|
|
267
|
+
|
|
268
|
+
const mirror = new Map<string, string>();
|
|
269
|
+
do {
|
|
270
|
+
const page = await backups.list({ prefix: current, cursor });
|
|
271
|
+
for (const object of page.objects) mirror.set(object.key.slice(current.length), object.etag);
|
|
272
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
273
|
+
} while (cursor);
|
|
274
|
+
|
|
275
|
+
let copied = 0;
|
|
276
|
+
let preserved = 0;
|
|
277
|
+
const keep = async (key: string) => {
|
|
278
|
+
const old = await backups.get(`${current}${key}`);
|
|
279
|
+
if (!old) return;
|
|
280
|
+
await backups.put(`${prefix}/changed/${stamp}/${key}`, old.body, { httpMetadata: old.httpMetadata });
|
|
281
|
+
preserved++;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Decide the changes first, then make them a few files at a time.
|
|
285
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
286
|
+
let pending = 0;
|
|
287
|
+
for (const [key, etag] of source) {
|
|
288
|
+
if (mirror.get(key) === etag) continue;
|
|
289
|
+
if (tasks.length >= maxChanges) {
|
|
290
|
+
pending++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
tasks.push(async () => {
|
|
294
|
+
if (mirror.has(key)) await keep(key);
|
|
295
|
+
const object = await media.get(key);
|
|
296
|
+
if (!object) return;
|
|
297
|
+
await backups.put(`${current}${key}`, object.body, { httpMetadata: object.httpMetadata });
|
|
298
|
+
copied++;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
for (const key of mirror.keys()) {
|
|
302
|
+
if (source.has(key)) continue;
|
|
303
|
+
if (tasks.length >= maxChanges) {
|
|
304
|
+
pending++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
tasks.push(async () => {
|
|
308
|
+
await keep(key);
|
|
309
|
+
await backups.delete(`${current}${key}`);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
await inPool(tasks, MIRROR_CONCURRENCY);
|
|
313
|
+
return { copied, preserved, total: source.size, pending };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Files copied at once when mirroring (each copy is a get and a put, streamed). */
|
|
317
|
+
export const MIRROR_CONCURRENCY = 8;
|
|
318
|
+
|
|
319
|
+
/** Run tasks with at most `limit` in flight; the first failure is thrown once the others settle. */
|
|
320
|
+
async function inPool(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
|
|
321
|
+
let next = 0;
|
|
322
|
+
let failure: { error: unknown } | null = null;
|
|
323
|
+
const worker = async () => {
|
|
324
|
+
while (next < tasks.length && !failure) {
|
|
325
|
+
const task = tasks[next++];
|
|
326
|
+
try {
|
|
327
|
+
await task();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
failure ??= { error };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
|
|
334
|
+
if (failure) throw (failure as { error: unknown }).error;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Copy media files that are in the backup mirror but missing from the media
|
|
339
|
+
* bucket. Never overwrites. Copies at most `max` files per call so one request
|
|
340
|
+
* stays well inside Worker limits; `pending` is what's left for the next call
|
|
341
|
+
* (a coywolf.com restore trial copied 2,021 files in one request and took
|
|
342
|
+
* over five minutes).
|
|
343
|
+
*/
|
|
344
|
+
export async function restoreMissingMedia(
|
|
345
|
+
media: R2Bucket,
|
|
346
|
+
backups: R2Bucket,
|
|
347
|
+
max = 200,
|
|
348
|
+
prefix: MirrorPrefix = "media",
|
|
349
|
+
): Promise<{ restored: number; checked: number; pending: number }> {
|
|
350
|
+
const current = `${prefix}/current/`;
|
|
351
|
+
const present = new Set<string>();
|
|
352
|
+
let cursor: string | undefined;
|
|
353
|
+
do {
|
|
354
|
+
const page = await media.list({ cursor });
|
|
355
|
+
for (const o of page.objects) present.add(o.key);
|
|
356
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
357
|
+
} while (cursor);
|
|
358
|
+
|
|
359
|
+
let restored = 0;
|
|
360
|
+
let checked = 0;
|
|
361
|
+
let pending = 0;
|
|
362
|
+
do {
|
|
363
|
+
const page = await backups.list({ prefix: current, cursor });
|
|
364
|
+
for (const o of page.objects) {
|
|
365
|
+
checked++;
|
|
366
|
+
const key = o.key.slice(current.length);
|
|
367
|
+
if (present.has(key)) continue;
|
|
368
|
+
if (restored >= max) {
|
|
369
|
+
pending++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const object = await backups.get(o.key);
|
|
373
|
+
if (!object) continue;
|
|
374
|
+
await media.put(key, object.body, { httpMetadata: object.httpMetadata });
|
|
375
|
+
restored++;
|
|
376
|
+
}
|
|
377
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
378
|
+
} while (cursor);
|
|
379
|
+
return { restored, checked, pending };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Delete database backups and replaced media and upload copies older than `days`. Returns the number of stamps pruned. */
|
|
383
|
+
export async function pruneBackups(bucket: R2Bucket, days: number): Promise<number> {
|
|
384
|
+
const cutoff = makeStamp(new Date(Date.now() - days * 86_400_000));
|
|
385
|
+
let pruned = 0;
|
|
386
|
+
for (const prefix of ["d1/", "media/changed/", "uploads/changed/"]) {
|
|
387
|
+
let cursor: string | undefined;
|
|
388
|
+
do {
|
|
389
|
+
const page = await bucket.list({ prefix, delimiter: "/", cursor });
|
|
390
|
+
for (const dir of page.delimitedPrefixes) {
|
|
391
|
+
const stamp = dir.slice(prefix.length, -1);
|
|
392
|
+
if (!STAMP.test(stamp) || stamp >= cutoff) continue;
|
|
393
|
+
await deletePrefix(bucket, dir);
|
|
394
|
+
if (prefix === "d1/") pruned++;
|
|
395
|
+
}
|
|
396
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
397
|
+
} while (cursor);
|
|
398
|
+
}
|
|
399
|
+
return pruned;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function deletePrefix(bucket: R2Bucket, prefix: string) {
|
|
403
|
+
let cursor: string | undefined;
|
|
404
|
+
do {
|
|
405
|
+
const page = await bucket.list({ prefix, cursor });
|
|
406
|
+
if (page.objects.length) await bucket.delete(page.objects.map((o) => o.key));
|
|
407
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
408
|
+
} while (cursor);
|
|
409
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Nav module: an accessible breadcrumb trail as a Portable Text
|
|
3
|
+
* block (`coywolf-breadcrumbs`) and as a theme component (`Breadcrumbs` from
|
|
4
|
+
* "@coywolf/emdash/astro"). Until 0.6.0 this was the `headings.breadcrumbs`
|
|
5
|
+
* sub-feature of Headings & TOC; the feature switch (FeatureDef.replaces) and
|
|
6
|
+
* the settings (./settings.ts) fall back to the old stored values, and the
|
|
7
|
+
* block type, component and stored data are unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import type { PluginContext } from "emdash";
|
|
10
|
+
import { definePluginRoute } from "emdash";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { registerFeatures } from "../core/features.js";
|
|
14
|
+
import type { FeatureDef, PackModule } from "../core/module.js";
|
|
15
|
+
import { parseInput } from "../shared.js";
|
|
16
|
+
import { SETTINGS_KEY, ctxBreadcrumbsSettings, invalidateBreadcrumbsSettings, normalizeSettings } from "./settings.js";
|
|
17
|
+
import { BREADCRUMBS_BLOCK, stampBreadcrumbs } from "./stamp.js";
|
|
18
|
+
import { capturePage } from "./trail.js";
|
|
19
|
+
|
|
20
|
+
/** No build-time options yet: defaults live on the Breadcrumb Nav page. */
|
|
21
|
+
export type BreadcrumbsOptions = Record<string, never>;
|
|
22
|
+
|
|
23
|
+
export const FEATURES: FeatureDef[] = [
|
|
24
|
+
{
|
|
25
|
+
id: "breadcrumbs",
|
|
26
|
+
label: "Breadcrumb Nav",
|
|
27
|
+
description: "An accessible breadcrumb trail, as a Breadcrumbs block and as a component for theme layouts.",
|
|
28
|
+
default: false,
|
|
29
|
+
replaces: ["headings.breadcrumbs"],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
registerFeatures(FEATURES);
|
|
33
|
+
|
|
34
|
+
const BLOCKS = [
|
|
35
|
+
{
|
|
36
|
+
type: BREADCRUMBS_BLOCK,
|
|
37
|
+
label: "Breadcrumbs",
|
|
38
|
+
icon: "link",
|
|
39
|
+
category: "Sections",
|
|
40
|
+
description: "A breadcrumb trail for this page",
|
|
41
|
+
fields: [
|
|
42
|
+
{
|
|
43
|
+
type: "select",
|
|
44
|
+
action_id: "separator",
|
|
45
|
+
label: "Separator",
|
|
46
|
+
options: [
|
|
47
|
+
{ label: "Site default", value: "" },
|
|
48
|
+
{ label: "/", value: "slash" },
|
|
49
|
+
{ label: "›", value: "chevron" },
|
|
50
|
+
{ label: "»", value: "guillemet" },
|
|
51
|
+
{ label: "•", value: "bullet" },
|
|
52
|
+
{ label: "→", value: "arrow" },
|
|
53
|
+
{ label: ">", value: "gt" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{ type: "text_input", action_id: "homeLabel", label: "Home label", placeholder: "Site default (Home)" },
|
|
57
|
+
{
|
|
58
|
+
type: "select",
|
|
59
|
+
action_id: "showCurrent",
|
|
60
|
+
label: "Current page",
|
|
61
|
+
options: [
|
|
62
|
+
{ label: "Site default", value: "" },
|
|
63
|
+
{ label: "Show", value: "show" },
|
|
64
|
+
{ label: "Hide", value: "hide" },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const settingsInput = z.object({ settings: z.record(z.string(), z.unknown()) });
|
|
72
|
+
|
|
73
|
+
/** Stamp each Breadcrumbs block with the entry's title (its own hook, so it works with Headings & TOC off). */
|
|
74
|
+
export function beforeSave(event: { content: Record<string, unknown> }): Record<string, unknown> | undefined {
|
|
75
|
+
return stampBreadcrumbs(event.content) ?? undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function breadcrumbsPack(_options: BreadcrumbsOptions): PackModule {
|
|
79
|
+
return {
|
|
80
|
+
id: "breadcrumbs",
|
|
81
|
+
label: "Breadcrumb Nav",
|
|
82
|
+
features: FEATURES,
|
|
83
|
+
capabilities: ["content:write"],
|
|
84
|
+
portableTextBlocks: BLOCKS,
|
|
85
|
+
adminPages: [{ path: "/breadcrumbs", label: "Breadcrumb Nav", icon: "path" }],
|
|
86
|
+
hooks: {
|
|
87
|
+
"content:beforeSave": beforeSave,
|
|
88
|
+
// Remember the theme's breadcrumb trail for the Breadcrumbs block in the body.
|
|
89
|
+
"page:metadata": (event: { page: Parameters<typeof capturePage>[0] }) => {
|
|
90
|
+
capturePage(event.page);
|
|
91
|
+
return [];
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
routes: {
|
|
95
|
+
"breadcrumbs/settings": {
|
|
96
|
+
permission: "plugins:manage" as const,
|
|
97
|
+
handler: async (ctx: PluginContext) => ({ settings: await ctxBreadcrumbsSettings(ctx) }),
|
|
98
|
+
},
|
|
99
|
+
"breadcrumbs/settings/save": definePluginRoute({
|
|
100
|
+
permission: "plugins:manage",
|
|
101
|
+
methods: ["POST"],
|
|
102
|
+
request: { body: "json" },
|
|
103
|
+
handler: async (ctx) => {
|
|
104
|
+
const settings = normalizeSettings(parseInput(settingsInput, ctx.input).settings);
|
|
105
|
+
await ctx.settings.set(SETTINGS_KEY, settings);
|
|
106
|
+
invalidateBreadcrumbsSettings();
|
|
107
|
+
ctx.log.info("Breadcrumb settings saved");
|
|
108
|
+
return { settings };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Nav site defaults: one plugin setting ("breadcrumbs"). Before
|
|
3
|
+
* 0.6.0 they lived inside the Headings & TOC setting ("headings", under
|
|
4
|
+
* `breadcrumbs`), so reads fall back to that sub-object until the Breadcrumb
|
|
5
|
+
* Nav page saves its own. Hooks and routes read through the plugin context;
|
|
6
|
+
* Astro components read the option rows from D1 in the feature switches'
|
|
7
|
+
* query and per-isolate cache.
|
|
8
|
+
*/
|
|
9
|
+
import { invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
|
|
10
|
+
|
|
11
|
+
export const SETTINGS_KEY = "breadcrumbs";
|
|
12
|
+
/** The setting that held these values before Breadcrumb Nav was its own module. */
|
|
13
|
+
export const LEGACY_SETTINGS_KEY = "headings";
|
|
14
|
+
|
|
15
|
+
export const SEPARATORS = {
|
|
16
|
+
slash: "/",
|
|
17
|
+
chevron: "›",
|
|
18
|
+
guillemet: "»",
|
|
19
|
+
bullet: "•",
|
|
20
|
+
arrow: "→",
|
|
21
|
+
gt: ">",
|
|
22
|
+
} as const;
|
|
23
|
+
export type SeparatorPreset = keyof typeof SEPARATORS;
|
|
24
|
+
|
|
25
|
+
export interface BreadcrumbsSettings {
|
|
26
|
+
separator: SeparatorPreset;
|
|
27
|
+
/** Overrides the preset when not empty (max 8 characters). */
|
|
28
|
+
customSeparator: string;
|
|
29
|
+
homeLabel: string;
|
|
30
|
+
showHome: boolean;
|
|
31
|
+
showCurrent: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_SETTINGS: BreadcrumbsSettings = {
|
|
35
|
+
separator: "slash",
|
|
36
|
+
customSeparator: "",
|
|
37
|
+
homeLabel: "Home",
|
|
38
|
+
showHome: true,
|
|
39
|
+
showCurrent: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const asObject = (value: unknown) => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null);
|
|
43
|
+
const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback);
|
|
44
|
+
const text = (value: unknown, max: number, fallback: string) =>
|
|
45
|
+
typeof value === "string" ? value.replace(/[\u0000-\u001f<>]/g, "").trim().slice(0, max) : fallback;
|
|
46
|
+
|
|
47
|
+
/** Fill in and clamp stored settings. Never throws. */
|
|
48
|
+
export function normalizeSettings(raw: unknown): BreadcrumbsSettings {
|
|
49
|
+
const r = asObject(raw) ?? {};
|
|
50
|
+
const d = DEFAULT_SETTINGS;
|
|
51
|
+
return {
|
|
52
|
+
separator: Object.hasOwn(SEPARATORS, r.separator as string) ? (r.separator as SeparatorPreset) : d.separator,
|
|
53
|
+
customSeparator: [...text(r.customSeparator, 32, "")].slice(0, 8).join(""),
|
|
54
|
+
homeLabel: text(r.homeLabel, 60, d.homeLabel) || d.homeLabel,
|
|
55
|
+
showHome: bool(r.showHome, d.showHome),
|
|
56
|
+
showCurrent: bool(r.showCurrent, d.showCurrent),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The effective settings from the stored "breadcrumbs" setting, else the
|
|
62
|
+
* `breadcrumbs` sub-object of the legacy "headings" setting, else defaults.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSettings(stored: unknown, legacyHeadings: unknown): BreadcrumbsSettings {
|
|
65
|
+
return normalizeSettings(asObject(stored) ?? asObject(asObject(legacyHeadings)?.breadcrumbs));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SettingsCtx {
|
|
69
|
+
settings: { get<T>(key: string): Promise<T | null | undefined> };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Settings inside the plugin context (hooks, routes). */
|
|
73
|
+
export async function ctxBreadcrumbsSettings(ctx: SettingsCtx): Promise<BreadcrumbsSettings> {
|
|
74
|
+
const stored = await ctx.settings.get<unknown>(SETTINGS_KEY);
|
|
75
|
+
if (asObject(stored)) return normalizeSettings(stored);
|
|
76
|
+
return resolveSettings(null, await ctx.settings.get<unknown>(LEGACY_SETTINGS_KEY));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Outside the plugin context (Astro components) ────────────────
|
|
80
|
+
|
|
81
|
+
registerSiteSetting(SETTINGS_KEY);
|
|
82
|
+
registerSiteSetting(LEGACY_SETTINGS_KEY);
|
|
83
|
+
|
|
84
|
+
/** The last stored values and their resolved settings, so a cache hit doesn't resolve again. */
|
|
85
|
+
let memo: { stored: unknown; legacy: unknown; settings: BreadcrumbsSettings } | null = null;
|
|
86
|
+
|
|
87
|
+
export function invalidateBreadcrumbsSettings(): void {
|
|
88
|
+
invalidateFeatures();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The settings from D1 (both rows, in the feature switches' query and cache). Falls back to defaults if the database can't be read. */
|
|
92
|
+
export async function siteBreadcrumbsSettings(database = "DB"): Promise<BreadcrumbsSettings> {
|
|
93
|
+
const [stored, legacy] = await Promise.all([readSiteSetting(SETTINGS_KEY, database), readSiteSetting(LEGACY_SETTINGS_KEY, database)]);
|
|
94
|
+
if (!stored || !legacy) {
|
|
95
|
+
console.error("coywolf-pack: could not read breadcrumb settings");
|
|
96
|
+
return DEFAULT_SETTINGS;
|
|
97
|
+
}
|
|
98
|
+
if (memo && memo.stored === stored.value && memo.legacy === legacy.value) return memo.settings;
|
|
99
|
+
const settings = resolveSettings(stored.value, legacy.value);
|
|
100
|
+
memo = { stored: stored.value, legacy: legacy.value, settings };
|
|
101
|
+
return settings;
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save-time stamping for Breadcrumbs blocks (content:beforeSave). A block
|
|
3
|
+
* component only sees its own node, so each `coywolf-breadcrumbs` block gets
|
|
4
|
+
* `_title`, the entry's title, for the last crumb of a trail derived from the
|
|
5
|
+
* URL. Pure: returns new objects, never mutates its input, and is idempotent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const BREADCRUMBS_BLOCK = "coywolf-breadcrumbs";
|
|
9
|
+
|
|
10
|
+
type Block = Record<string, unknown> & { _type: string };
|
|
11
|
+
|
|
12
|
+
/** An array that looks like Portable Text: every item is an object with a string `_type`. */
|
|
13
|
+
function isPortableText(value: unknown): value is Block[] {
|
|
14
|
+
return (
|
|
15
|
+
Array.isArray(value) &&
|
|
16
|
+
value.length > 0 &&
|
|
17
|
+
value.every((b) => b && typeof b === "object" && typeof (b as { _type?: unknown })._type === "string")
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stamp the title into every Breadcrumbs block. Returns the new content, or null when nothing changed. */
|
|
22
|
+
export function stampBreadcrumbs(content: Record<string, unknown>): Record<string, unknown> | null {
|
|
23
|
+
const title = typeof content.title === "string" ? content.title.trim() || null : null;
|
|
24
|
+
if (title === null) return null;
|
|
25
|
+
let result: Record<string, unknown> | null = null;
|
|
26
|
+
for (const [field, value] of Object.entries(content)) {
|
|
27
|
+
if (!isPortableText(value)) continue;
|
|
28
|
+
let out: Block[] | null = null;
|
|
29
|
+
value.forEach((block, i) => {
|
|
30
|
+
if (block._type !== BREADCRUMBS_BLOCK || block._title === title) return;
|
|
31
|
+
out ??= value.slice();
|
|
32
|
+
out[i] = { ...block, _title: title };
|
|
33
|
+
});
|
|
34
|
+
if (out) {
|
|
35
|
+
result ??= { ...content };
|
|
36
|
+
result[field] = out;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|