@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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape every Coywolf Pack module implements. src/index.ts composes the
|
|
3
|
+
* modules into one EmDash plugin: routes are merged, hooks run in module
|
|
4
|
+
* order (skipping modules whose feature is off), and admin pages, widgets,
|
|
5
|
+
* Portable Text blocks and storage collections are concatenated.
|
|
6
|
+
*/
|
|
7
|
+
import type { MiddlewareHandler } from "astro";
|
|
8
|
+
import type { PluginContext, PluginHooks } from "emdash";
|
|
9
|
+
|
|
10
|
+
/** A switch on the Features page. */
|
|
11
|
+
export interface FeatureDef {
|
|
12
|
+
/** Stable key, e.g. "codeBlocks" or "schema.breadcrumbs". Stored in the "features" setting. */
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/** Default when the site hasn't chosen. Keep new features off so installing changes nothing. */
|
|
17
|
+
default?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Legacy ids this feature took over (e.g. a sub-feature that became its own
|
|
20
|
+
* module). When the stored map has no value for `id`, the first legacy id
|
|
21
|
+
* found there decides, so existing sites keep their choice.
|
|
22
|
+
*/
|
|
23
|
+
replaces?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdminPageDef {
|
|
27
|
+
path: string;
|
|
28
|
+
label: string;
|
|
29
|
+
icon?: string;
|
|
30
|
+
/** Admin sidebar folder (EmDash 1.2.0+). The pack puts every page in the "Coywolf Pack" folder (see sidebar() in src/index.ts). */
|
|
31
|
+
group?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WidgetDef {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
size: "full" | "half" | "third";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A scheduled job. `schedule` is cron syntax or @hourly/@daily/@weekly. */
|
|
41
|
+
export interface TaskDef {
|
|
42
|
+
name: string;
|
|
43
|
+
schedule: string;
|
|
44
|
+
/** Feature that must be on for the task to run (defaults to the module's main feature). */
|
|
45
|
+
feature?: string;
|
|
46
|
+
handler: (ctx: PluginContext) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: hook handler signatures vary per hook name.
|
|
50
|
+
type AnyHandler = (event: any, ctx: PluginContext) => Promise<unknown> | unknown;
|
|
51
|
+
|
|
52
|
+
export interface PackModule {
|
|
53
|
+
/** Module id; also the main feature id unless `features` says otherwise. */
|
|
54
|
+
id: string;
|
|
55
|
+
label: string;
|
|
56
|
+
/** Feature switches. The first one is the module's main switch; the rest are sub-features. */
|
|
57
|
+
features: FeatureDef[];
|
|
58
|
+
/** Plugin routes, served at /_emdash/api/plugins/coywolf-pack/<name>. Prefix names with the module id. */
|
|
59
|
+
// biome-ignore lint/suspicious/noExplicitAny: route definitions are opaque here.
|
|
60
|
+
routes?: Record<string, any>;
|
|
61
|
+
/**
|
|
62
|
+
* Hook handlers. Return values follow EmDash: content:beforeSave may return
|
|
63
|
+
* modified content (passed to the next module); page:metadata and
|
|
64
|
+
* page:fragments return contributions (concatenated).
|
|
65
|
+
* Each handler runs only when `hookFeature` (or the main feature) is on.
|
|
66
|
+
*/
|
|
67
|
+
hooks?: Partial<Record<keyof PluginHooks, AnyHandler>>;
|
|
68
|
+
/** Feature gating a hook, when it isn't the main feature. Keyed by hook name. */
|
|
69
|
+
hookFeature?: Partial<Record<keyof PluginHooks, string>>;
|
|
70
|
+
tasks?: TaskDef[];
|
|
71
|
+
adminPages?: AdminPageDef[];
|
|
72
|
+
widgets?: WidgetDef[];
|
|
73
|
+
/**
|
|
74
|
+
* Secret settings (API keys and tokens) only, prefixed with the module id;
|
|
75
|
+
* other fields are dropped. Prefer adding secrets to src/core/secrets.ts.
|
|
76
|
+
* Non-secret settings aren't declared: edit them on the module's own admin
|
|
77
|
+
* page (ctx.settings.get/set) and give every read its default in code.
|
|
78
|
+
*/
|
|
79
|
+
// biome-ignore lint/suspicious/noExplicitAny: SettingField shape is EmDash's.
|
|
80
|
+
settingsSchema?: Record<string, any>;
|
|
81
|
+
/** Portable Text block types this module adds to the editor (render components live in src/astro). */
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: PortableTextBlockConfig shape is EmDash's.
|
|
83
|
+
portableTextBlocks?: any[];
|
|
84
|
+
/** Plugin storage collections ({ name: { indexes, uniqueIndexes } }). */
|
|
85
|
+
// biome-ignore lint/suspicious/noExplicitAny: storage config shape is EmDash's.
|
|
86
|
+
storage?: Record<string, any>;
|
|
87
|
+
/** Capabilities this module needs (e.g. "network:request", "email:send"). */
|
|
88
|
+
capabilities?: string[];
|
|
89
|
+
/** Hosts for network:request, if any. */
|
|
90
|
+
allowedHosts?: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Site-level middleware a module contributes (see src/middleware.ts). Runs
|
|
95
|
+
* only when `feature` is on. Return a Response to answer the request, or
|
|
96
|
+
* undefined to pass it on.
|
|
97
|
+
*/
|
|
98
|
+
export interface PackMiddleware {
|
|
99
|
+
module: string;
|
|
100
|
+
feature: string;
|
|
101
|
+
/** Sets its own edge caching (context.cache) on the responses it returns; others' are never edge-cached. */
|
|
102
|
+
ownsCache?: boolean;
|
|
103
|
+
handle: (context: Parameters<MiddlewareHandler>[0], env: Record<string, unknown>, waitUntil: (p: Promise<unknown>) => void) => Promise<Response | undefined> | Response | undefined;
|
|
104
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads that failed while pages were being answered. When the pack can't read
|
|
3
|
+
* what it needs (D1 unreachable for a moment, a query error), it fails closed:
|
|
4
|
+
* the feature switches read as their defaults (new features are off),
|
|
5
|
+
* settings as unset, redirect and robots.txt rules as absent, and a module
|
|
6
|
+
* whose page metadata failed adds none. That's the right answer for the one
|
|
7
|
+
* request, but the page it renders isn't the site's real page (no schema,
|
|
8
|
+
* reviews or videos, wrong links), so it mustn't go into the edge cache, where
|
|
9
|
+
* it would stay for the Page cache lifetime (days).
|
|
10
|
+
*
|
|
11
|
+
* Every such fallback calls noteReadFailure(). The pack middleware reads
|
|
12
|
+
* readFailures() before a request and again once the response is rendered,
|
|
13
|
+
* and keeps the response out of the edge cache when the count moved (see
|
|
14
|
+
* settlePage in src/pageCache/lib.ts).
|
|
15
|
+
*
|
|
16
|
+
* The count is per isolate, not per request: one read is often shared by
|
|
17
|
+
* several requests (concurrent requests wait for the same read of the
|
|
18
|
+
* switches), and the code that notices a failure has no request at hand. A
|
|
19
|
+
* failure while another request of the same isolate rendered keeps that one
|
|
20
|
+
* out of the cache too, which only costs a render. No imports, so tests can
|
|
21
|
+
* load it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let failures = 0;
|
|
25
|
+
let last: string | undefined;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A read failed and a fallback (off, unset, absent) was used instead. `what`
|
|
29
|
+
* names it for the log line of the page left out of the cache: a module that
|
|
30
|
+
* fails on every render (a bug, not a blip) keeps every page out of the cache
|
|
31
|
+
* until it's fixed, and the log says which.
|
|
32
|
+
*/
|
|
33
|
+
export function noteReadFailure(what: string): void {
|
|
34
|
+
failures++;
|
|
35
|
+
last = what;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many reads have failed in this isolate; compare two counts to tell whether one failed in between. */
|
|
39
|
+
export function readFailures(): number {
|
|
40
|
+
return failures;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What failed most recently in this isolate (for the log). */
|
|
44
|
+
export function lastReadFailure(): string | undefined {
|
|
45
|
+
return last;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A plugin route's result that failed on the server, as EmDash's HTTP wiring
|
|
50
|
+
* would answer it (plugins/http-route-dispatch.ts: a missing status is 404 for
|
|
51
|
+
* NOT_FOUND, else 400): a 5xx means a read failed. Not found (a feature that's
|
|
52
|
+
* off, the plugin not enabled), bad input and the like don't count.
|
|
53
|
+
*/
|
|
54
|
+
export function routeFailed(result: { success?: boolean; status?: number; error?: { code?: string } } | null | undefined): boolean {
|
|
55
|
+
if (!result || result.success) return false;
|
|
56
|
+
return (result.status ?? (result.error?.code === "NOT_FOUND" ? 404 : 400)) >= 500;
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped memo: share a read between everything that renders one page
|
|
3
|
+
* (page:metadata hooks, block components, public plugin routes they call), so
|
|
4
|
+
* the same data is read from D1 once per request instead of once per caller.
|
|
5
|
+
*
|
|
6
|
+
* Keyed on EmDash's request context (an AsyncLocalStorage store that EmDash's
|
|
7
|
+
* middleware opens for every request; found through its global symbol, so no
|
|
8
|
+
* import of emdash is needed here). Outside a request (tests, cron, scripts)
|
|
9
|
+
* nothing is shared: each call runs its read.
|
|
10
|
+
*
|
|
11
|
+
* Promises are cached, so callers that start at the same time share one
|
|
12
|
+
* in-flight read; a read that fails is forgotten, so the next caller retries.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ALS_KEY = Symbol.for("emdash:request-context");
|
|
16
|
+
const memos = new WeakMap<object, Map<string, Promise<unknown>>>();
|
|
17
|
+
|
|
18
|
+
/** The current request's EmDash context object (undefined outside a request). */
|
|
19
|
+
export function requestStore(): object | undefined {
|
|
20
|
+
const als = (globalThis as Record<symbol, { getStore?(): unknown } | undefined>)[ALS_KEY];
|
|
21
|
+
const store = als?.getStore?.();
|
|
22
|
+
return store && typeof store === "object" ? store : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function memoFor(create: boolean): Map<string, Promise<unknown>> | undefined {
|
|
26
|
+
const store = requestStore();
|
|
27
|
+
if (!store) return undefined;
|
|
28
|
+
let memo = memos.get(store);
|
|
29
|
+
if (!memo && create) {
|
|
30
|
+
memo = new Map();
|
|
31
|
+
memos.set(store, memo);
|
|
32
|
+
}
|
|
33
|
+
return memo;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `read()` once per request for `key`; later callers in the same request get the same promise. */
|
|
37
|
+
export function requestMemo<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
38
|
+
const memo = memoFor(true);
|
|
39
|
+
if (!memo) return read();
|
|
40
|
+
const hit = memo.get(key);
|
|
41
|
+
if (hit) return hit as Promise<T>;
|
|
42
|
+
const promise = read().catch((error) => {
|
|
43
|
+
if (memo.get(key) === promise) memo.delete(key);
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
memo.set(key, promise);
|
|
47
|
+
return promise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The value already read (or being read) for `key` in this request, if any. */
|
|
51
|
+
export function peekRequestMemo<T>(key: string): Promise<T> | undefined {
|
|
52
|
+
return memoFor(false)?.get(key) as Promise<T> | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Share a read that's already under way (e.g. one batched read for many keys) with later callers in this request. */
|
|
56
|
+
export function seedRequestMemo<T>(key: string, value: Promise<T>): void {
|
|
57
|
+
const memo = memoFor(true);
|
|
58
|
+
if (!memo || memo.has(key)) return;
|
|
59
|
+
const promise = value.catch((error) => {
|
|
60
|
+
if (memo.get(key) === promise) memo.delete(key);
|
|
61
|
+
throw error;
|
|
62
|
+
});
|
|
63
|
+
// The caller handles `value`'s failure; a seeded copy nobody reads must not be an unhandled rejection.
|
|
64
|
+
promise.catch(() => {});
|
|
65
|
+
memo.set(key, promise);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Forget `key` in this request (after a write that changes it). */
|
|
69
|
+
export function forgetRequestMemo(key: string): void {
|
|
70
|
+
memoFor(false)?.delete(key);
|
|
71
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's EmDash `admin.settingsSchema`: API keys and tokens only.
|
|
3
|
+
*
|
|
4
|
+
* EmDash renders this schema as the plugin's generic Settings page and uses
|
|
5
|
+
* the same schema to decide which settings are encrypted: a key declared
|
|
6
|
+
* `type: "secret"` is encrypted on write and decrypted on read, and reading
|
|
7
|
+
* an encrypted value whose key isn't declared secret throws. So every secret
|
|
8
|
+
* stays declared here permanently, whatever the feature switches or module
|
|
9
|
+
* options say, and the keys never change (stored values must keep working).
|
|
10
|
+
*
|
|
11
|
+
* Every other setting is plain JSON, edited on its module's own admin page
|
|
12
|
+
* (ctx.settings.get/set work for keys that aren't in the schema).
|
|
13
|
+
*
|
|
14
|
+
* Kept free of imports so tests can load it directly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface SecretSettingField {
|
|
18
|
+
type: "secret";
|
|
19
|
+
label: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SECRET_SETTINGS = {
|
|
24
|
+
aiApiKey: {
|
|
25
|
+
type: "secret",
|
|
26
|
+
label: "AI Enrichment — API key",
|
|
27
|
+
description:
|
|
28
|
+
"For Anthropic, OpenAI or Gemini (Workers AI needs no key). To get one: (1) sign in to your provider's developer console: platform.claude.com (Anthropic), platform.openai.com (OpenAI) or aistudio.google.com (Gemini); (2) add billing; (3) create an API key and copy it; (4) paste it here and save. Step-by-step guides for each provider, and the provider and model settings, are on the AI Enrichment page.",
|
|
29
|
+
},
|
|
30
|
+
filesR2SecretAccessKey: {
|
|
31
|
+
type: "secret",
|
|
32
|
+
label: "File Downloads — R2 secret access key",
|
|
33
|
+
description:
|
|
34
|
+
"For large uploads. To get one: (1) Cloudflare dashboard → R2 → Manage API tokens → Create Account API token; (2) choose Object Read & Write on the bucket bound as MEDIA; (3) create it and copy the Secret Access Key (shown once); (4) paste it here and save. The account ID, access key ID and bucket name go on the Files page → Settings, which has the full step-by-step guide.",
|
|
35
|
+
},
|
|
36
|
+
videosApiToken: {
|
|
37
|
+
type: "secret",
|
|
38
|
+
label: "Videos — Cloudflare Stream API token",
|
|
39
|
+
description:
|
|
40
|
+
"To get one: (1) Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token; (2) add the permission Account → Stream → Edit and include your account; (3) create it and copy the token (shown once); (4) paste it here and save. Or set the CF_STREAM_TOKEN Worker secret. The account ID and the full step-by-step guide are on the Videos page → Settings.",
|
|
41
|
+
},
|
|
42
|
+
videosWebhookSecret: {
|
|
43
|
+
type: "secret",
|
|
44
|
+
label: "Videos — Stream webhook signing secret",
|
|
45
|
+
description: "Filled in by Subscribe Stream webhook on the Videos page. Leave as is.",
|
|
46
|
+
},
|
|
47
|
+
imagesApiToken: {
|
|
48
|
+
type: "secret",
|
|
49
|
+
label: "Clean image URLs — Cloudflare API token",
|
|
50
|
+
description:
|
|
51
|
+
"Optional. Only for Set up media host on the Clean Image URLs page, which does the Cloudflare setup for you. To get one: (1) Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token; (2) add the permissions Zone → Zone → Read, Zone → Zone Settings → Edit, Zone → Transform Rules → Edit, Zone → DNS → Edit, Account → Workers R2 Storage → Edit and Account → Account Rulesets → Read; (3) include your account and the site's zone; (4) create it, copy the token (shown once), paste it here and save. Or set the IMAGES_API_TOKEN Worker secret.",
|
|
52
|
+
},
|
|
53
|
+
usageApiToken: {
|
|
54
|
+
type: "secret",
|
|
55
|
+
label: "Cloudflare usage — read-only API token",
|
|
56
|
+
description:
|
|
57
|
+
"Optional. Shows this site's D1 and Workers usage, the account's against its plan, and the estimated bill on the Performance page and the dashboard. To get one: (1) Cloudflare dashboard → Manage Account → Account API Tokens → Create Token → Create Custom Token; (2) add the permissions Account → Account Analytics → Read, Workers → Metadata Read-Only (All Workers; finds this site's Worker and database and reads Workers Logs for the cache-warming estimate) and Account → Account Settings → Read (to detect the plan); (3) create it, copy the token (shown once), paste it here and save. Or set the USAGE_API_TOKEN Worker secret (one token works for every site in the account).",
|
|
58
|
+
},
|
|
59
|
+
robotsRadarToken: {
|
|
60
|
+
type: "secret",
|
|
61
|
+
label: "Robots.txt Rules — Cloudflare Radar API token",
|
|
62
|
+
description:
|
|
63
|
+
"Optional. Keeps the crawler list current from Cloudflare Radar each week. To get one: (1) Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token; (2) add the permission Account → Radar → Read and include your account; (3) create it and copy the token (shown once); (4) paste it here and save. Or set the RADAR_API_TOKEN Worker secret. The full step-by-step guide is on the Robots.txt page → Bots.",
|
|
64
|
+
},
|
|
65
|
+
} as const satisfies Record<string, SecretSettingField>;
|
|
66
|
+
|
|
67
|
+
export type SecretSettingKey = keyof typeof SECRET_SETTINGS;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The schema EmDash sees: the pack's secrets plus any secret fields a module
|
|
71
|
+
* declares. Non-secret fields are dropped, so they never appear on the
|
|
72
|
+
* generic Settings page.
|
|
73
|
+
*/
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: SettingField shape is EmDash's.
|
|
75
|
+
export function secretSettingsSchema(moduleSchemas: Array<Record<string, any> | undefined> = []): Record<string, SecretSettingField> {
|
|
76
|
+
const out: Record<string, SecretSettingField> = { ...SECRET_SETTINGS };
|
|
77
|
+
for (const schema of moduleSchemas) {
|
|
78
|
+
for (const [key, field] of Object.entries(schema ?? {})) {
|
|
79
|
+
if (field && field.type === "secret" && !(key in out)) out[key] = field as SecretSettingField;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-by-step guides on EmDash's generic plugin Settings page
|
|
3
|
+
* (/_emdash/admin/plugins-manager/coywolf-pack/settings). EmDash renders that
|
|
4
|
+
* page from the settings schema and shows each field's description as plain
|
|
5
|
+
* text, so the pack middleware adds this small script to admin pages. On that
|
|
6
|
+
* page it swaps each secret's plain-text description for a short line plus the
|
|
7
|
+
* guides in closed <details> (the same ones the module pages show).
|
|
8
|
+
*
|
|
9
|
+
* Progressive enhancement: it looks the fields up by their label, and if
|
|
10
|
+
* EmDash's markup ever changes it does nothing, leaving the plain-text steps
|
|
11
|
+
* from secrets.ts in place.
|
|
12
|
+
*/
|
|
13
|
+
import { GUIDES, SETTINGS_PAGE_GUIDES, guideHtml } from "./guide-content.js";
|
|
14
|
+
import { SECRET_SETTINGS } from "./secrets.js";
|
|
15
|
+
|
|
16
|
+
const PAGE = /\/_emdash\/admin\/plugins-manager\/coywolf-pack\/settings\/?$/;
|
|
17
|
+
|
|
18
|
+
/** Label text → the HTML that replaces that field's description. */
|
|
19
|
+
export function settingsGuideHtml(): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
for (const [key, entry] of Object.entries(SETTINGS_PAGE_GUIDES)) {
|
|
22
|
+
const field = (SECRET_SETTINGS as Record<string, { label: string }>)[key];
|
|
23
|
+
if (!field) continue;
|
|
24
|
+
const guides = entry.guides.map((id) => guideHtml(GUIDES[id])).join("");
|
|
25
|
+
out[field.label] = `<p class="text-sm leading-snug text-kumo-subtle" data-cw-intro>${entry.intro}</p><div class="space-y-2">${guides}</div>`;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Built once per isolate: its content only changes with a deploy. */
|
|
31
|
+
let enhanceScript: string | null = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The inline script. JSON is escaped so it can't close the <script> element.
|
|
35
|
+
* It goes on every admin page, not just the Settings page: the admin is a
|
|
36
|
+
* single-page app, so a visit that starts elsewhere reaches Settings without
|
|
37
|
+
* a page load (the script checks the path itself).
|
|
38
|
+
*/
|
|
39
|
+
export function settingsEnhanceScript(): string {
|
|
40
|
+
enhanceScript ??= buildEnhanceScript();
|
|
41
|
+
return enhanceScript;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildEnhanceScript(): string {
|
|
45
|
+
const data = JSON.stringify(settingsGuideHtml()).replace(/</g, "\\u003c").replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
|
|
46
|
+
return `(function(){var D=${data};var P=${PAGE.toString()};var q=0;
|
|
47
|
+
function run(){q=0;if(!P.test(location.pathname))return;var f=document.getElementById("plugin-settings-form");if(!f)return;
|
|
48
|
+
f.querySelectorAll("input[aria-labelledby][aria-describedby]").forEach(function(i){if(i.getAttribute("data-cw-guide"))return;
|
|
49
|
+
var l=document.getElementById(i.getAttribute("aria-labelledby"));var h=l&&D[l.textContent.trim()];if(!h)return;
|
|
50
|
+
var d=document.getElementById(i.getAttribute("aria-describedby"));if(!d||!d.parentNode)return;
|
|
51
|
+
var b=document.createElement("div");b.className="col-span-full space-y-2";b.setAttribute("data-cw-guides","");b.innerHTML=h;
|
|
52
|
+
var p=b.querySelector("[data-cw-intro]");p.id=d.id+"-cw";d.hidden=true;d.parentNode.insertBefore(b,d.nextSibling);
|
|
53
|
+
i.setAttribute("aria-describedby",p.id);i.setAttribute("data-cw-guide","1");});}
|
|
54
|
+
function later(){if(!q)q=requestAnimationFrame(run);}
|
|
55
|
+
new MutationObserver(later).observe(document.documentElement,{childList:true,subtree:true});later();})();`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Add the script to an admin HTML page. Returns the response unchanged when it isn't HTML. */
|
|
59
|
+
export async function injectAdminEnhancements(response: Response): Promise<Response> {
|
|
60
|
+
if (!(response.headers.get("content-type") ?? "").includes("text/html") || response.status !== 200) return response;
|
|
61
|
+
const html = await response.text();
|
|
62
|
+
const at = html.lastIndexOf("</body>");
|
|
63
|
+
const script = `<script data-coywolf-pack>${settingsEnhanceScript()}</script>`;
|
|
64
|
+
const body = at === -1 ? html + script : html.slice(0, at) + script + html.slice(at);
|
|
65
|
+
const headers = new Headers(response.headers);
|
|
66
|
+
headers.delete("content-length");
|
|
67
|
+
const out = new Response(body, { status: response.status, statusText: response.statusText, headers });
|
|
68
|
+
// Astro keeps cookies set during the request on the response under this symbol; a new
|
|
69
|
+
// Response drops it, which would lose session cookies (EmDash's middleware does the same).
|
|
70
|
+
const cookies = Symbol.for("astro.cookies");
|
|
71
|
+
const value = (response as unknown as Record<symbol, unknown>)[cookies];
|
|
72
|
+
if (value !== undefined) (out as unknown as Record<symbol, unknown>)[cookies] = value;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
package/src/core/site.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's name as editors set it (Settings → Site title). EmDash's plugin
|
|
3
|
+
* context reports the title from initial setup (`emdash:site_title`), which
|
|
4
|
+
* Settings doesn't change, so prefer the Settings value.
|
|
5
|
+
*/
|
|
6
|
+
import type { PluginContext } from "emdash";
|
|
7
|
+
|
|
8
|
+
import { noteReadFailure } from "./read-failures.js";
|
|
9
|
+
import { requestStore } from "./request-memo.js";
|
|
10
|
+
|
|
11
|
+
export async function siteName(ctx: Pick<PluginContext, "site">): Promise<string> {
|
|
12
|
+
try {
|
|
13
|
+
const { getSiteSettings } = await import("emdash");
|
|
14
|
+
const settings = (await getSiteSettings()) as { title?: unknown };
|
|
15
|
+
if (typeof settings.title === "string" && settings.title.trim()) return settings.title.trim();
|
|
16
|
+
} catch {
|
|
17
|
+
// Outside a request context: fall back to the plugin context. Inside one it's a failed read.
|
|
18
|
+
if (requestStore()) noteReadFailure("site settings (site name)");
|
|
19
|
+
}
|
|
20
|
+
return ctx.site?.name ?? "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the site is in EmDash's staging mode (Settings → General → Site Status, EmDash 1.5+):
|
|
25
|
+
* hidden from search engines until it goes live. Sites without the setting (all sites before
|
|
26
|
+
* that release, and sites that went live) are live. A failed read counts as live.
|
|
27
|
+
*/
|
|
28
|
+
export async function siteInStaging(): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const { getSiteSetting } = await import("emdash");
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: "staging" isn't in older EmDash versions' setting keys.
|
|
32
|
+
return (await (getSiteSetting as (key: any) => Promise<unknown>)("staging")) === true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched plugin-storage reads and deletes. EmDash's getMany and deleteMany
|
|
3
|
+
* put every id in one `IN (…)` list next to the plugin id and collection
|
|
4
|
+
* name, and D1 refuses statements with more than 100 bound values, so a
|
|
5
|
+
* large list fails with "too many SQL variables". These keep each statement
|
|
6
|
+
* well under the limit.
|
|
7
|
+
*/
|
|
8
|
+
import type { StorageCollection } from "emdash";
|
|
9
|
+
|
|
10
|
+
/** Ids per statement: 90 + plugin id + collection stays under D1's 100. */
|
|
11
|
+
export const STORAGE_IN_LIMIT = 90;
|
|
12
|
+
|
|
13
|
+
export async function getManyBatched<T>(collection: StorageCollection<T>, ids: readonly string[]): Promise<Map<string, T>> {
|
|
14
|
+
const out = new Map<string, T>();
|
|
15
|
+
const unique = [...new Set(ids)];
|
|
16
|
+
for (let i = 0; i < unique.length; i += STORAGE_IN_LIMIT) {
|
|
17
|
+
for (const [id, data] of await collection.getMany(unique.slice(i, i + STORAGE_IN_LIMIT))) out.set(id, data);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function deleteManyBatched<T>(collection: StorageCollection<T>, ids: readonly string[]): Promise<number> {
|
|
23
|
+
let deleted = 0;
|
|
24
|
+
const unique = [...new Set(ids)];
|
|
25
|
+
for (let i = 0; i < unique.length; i += STORAGE_IN_LIMIT) deleted += await collection.deleteMany(unique.slice(i, i + STORAGE_IN_LIMIT));
|
|
26
|
+
return deleted;
|
|
27
|
+
}
|