@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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Blocks page: pick the site's code block theme with a live preview.
|
|
3
|
+
* The label, copy button and line numbers are turned on or off on the
|
|
4
|
+
* Features page; the preview shows them as they're currently set.
|
|
5
|
+
*/
|
|
6
|
+
import { Banner, Button, Loader, Select } from "@cloudflare/kumo";
|
|
7
|
+
import { FloppyDisk } from "@phosphor-icons/react";
|
|
8
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
import { SaveBar } from "./save-bar.js";
|
|
12
|
+
import { PartSwitches } from "./feature-switches.js";
|
|
13
|
+
|
|
14
|
+
const API = "/_emdash/api/plugins/coywolf-pack";
|
|
15
|
+
|
|
16
|
+
interface ThemeOption {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
group: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Switches = Record<string, boolean>;
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
26
|
+
|
|
27
|
+
async function post<T>(path: string, body: unknown, fallback: string): Promise<T> {
|
|
28
|
+
const response = await apiFetch(`${API}/${path}`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
return parseApiResponse<T>(response, fallback);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function CodeBlocksPage() {
|
|
37
|
+
const [themes, setThemes] = React.useState<ThemeOption[] | null>(null);
|
|
38
|
+
const [saved, setSaved] = React.useState<{ theme: string; switches: Switches } | null>(null);
|
|
39
|
+
const [theme, setTheme] = React.useState("");
|
|
40
|
+
const [switches, setSwitches] = React.useState<Switches>({});
|
|
41
|
+
const [preview, setPreview] = React.useState<{ css: string; html: string } | null>(null);
|
|
42
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
43
|
+
const [status, setStatus] = React.useState("");
|
|
44
|
+
const [saving, setSaving] = React.useState(false);
|
|
45
|
+
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
void (async () => {
|
|
48
|
+
try {
|
|
49
|
+
const [settings, features] = await Promise.all([
|
|
50
|
+
apiFetch(`${API}/codeBlocks/settings`).then((r) =>
|
|
51
|
+
parseApiResponse<{ theme: string; themes: ThemeOption[] }>(r, "Couldn't load settings"),
|
|
52
|
+
),
|
|
53
|
+
apiFetch(`${API}/features/list`).then((r) =>
|
|
54
|
+
parseApiResponse<{ modules: Array<{ id: string; features: Array<{ id: string; enabled: boolean }> }> }>(
|
|
55
|
+
r,
|
|
56
|
+
"Couldn't load features",
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
]);
|
|
60
|
+
const mine = features.modules.find((m) => m.id === "codeBlocks")?.features ?? [];
|
|
61
|
+
const state: Switches = Object.fromEntries(mine.map((f) => [f.id, f.enabled]));
|
|
62
|
+
setThemes(settings.themes);
|
|
63
|
+
setTheme(settings.theme);
|
|
64
|
+
setSwitches(state);
|
|
65
|
+
setSaved({ theme: settings.theme, switches: state });
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
setError(errorText(cause, "Couldn't load settings"));
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Live preview, rendered by the same code as the site.
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!theme) return;
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
post<{ css: string; html: string }>(
|
|
78
|
+
"codeBlocks/preview",
|
|
79
|
+
{
|
|
80
|
+
theme,
|
|
81
|
+
label: switches["codeBlocks.label"] ?? false,
|
|
82
|
+
copy: switches["codeBlocks.copy"] ?? false,
|
|
83
|
+
lineNumbers: switches["codeBlocks.lineNumbers"] ?? false,
|
|
84
|
+
},
|
|
85
|
+
"Couldn't load the preview",
|
|
86
|
+
)
|
|
87
|
+
.then((result) => !cancelled && setPreview(result))
|
|
88
|
+
.catch((cause) => !cancelled && setError(errorText(cause, "Couldn't load the preview")));
|
|
89
|
+
}, 120);
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
};
|
|
94
|
+
}, [theme, switches]);
|
|
95
|
+
|
|
96
|
+
const dirty = saved !== null && theme !== saved.theme;
|
|
97
|
+
|
|
98
|
+
async function save() {
|
|
99
|
+
if (!saved) return;
|
|
100
|
+
setSaving(true);
|
|
101
|
+
setError(null);
|
|
102
|
+
setStatus("Saving…");
|
|
103
|
+
try {
|
|
104
|
+
if (theme !== saved.theme) await post("codeBlocks/save", { theme }, "Couldn't save the theme");
|
|
105
|
+
setSaved({ theme, switches });
|
|
106
|
+
setStatus("Saved. The site picks up changes within a minute.");
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
setStatus("");
|
|
109
|
+
setError(errorText(cause, "Couldn't save"));
|
|
110
|
+
} finally {
|
|
111
|
+
setSaving(false);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const groups = themes ? [...new Set(themes.map((t) => t.group))] : [];
|
|
116
|
+
const mainOn = switches.codeBlocks ?? false;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="space-y-6">
|
|
120
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
121
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
122
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Code Blocks</h1>
|
|
123
|
+
<div className="flex shrink-0 justify-end">
|
|
124
|
+
<Button variant="primary" icon={<FloppyDisk />} disabled={!dirty || saving} onClick={() => void save()}>
|
|
125
|
+
Save
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
129
|
+
Syntax highlighting for code blocks on the site, done on the server, so visitors download no highlighting
|
|
130
|
+
script. Only the chosen theme's styles are sent, and only on pages with code.
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
</header>
|
|
134
|
+
|
|
135
|
+
<PartSwitches module="codeBlocks" />
|
|
136
|
+
|
|
137
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
138
|
+
<p className="sr-only" role="status" aria-live="polite">
|
|
139
|
+
{status}
|
|
140
|
+
</p>
|
|
141
|
+
{status && !saving && <p className="text-sm text-kumo-subtle">{status}</p>}
|
|
142
|
+
|
|
143
|
+
{!themes && !error && (
|
|
144
|
+
<div className="flex justify-center py-12">
|
|
145
|
+
<Loader />
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{themes && (
|
|
150
|
+
<div className="grid gap-6 cw-lg-cols-20-fill">
|
|
151
|
+
<div className="space-y-6">
|
|
152
|
+
<Select
|
|
153
|
+
label="Theme"
|
|
154
|
+
value={theme}
|
|
155
|
+
onValueChange={(value: string | null) => value && setTheme(value)}
|
|
156
|
+
items={themes.map((t) => ({ value: t.id, label: t.label }))}
|
|
157
|
+
>
|
|
158
|
+
{groups.map((group) => (
|
|
159
|
+
<Select.Group key={group}>
|
|
160
|
+
<Select.GroupLabel>{group}</Select.GroupLabel>
|
|
161
|
+
{themes
|
|
162
|
+
.filter((t) => t.group === group)
|
|
163
|
+
.map((t) => (
|
|
164
|
+
<Select.Option key={t.id} value={t.id}>
|
|
165
|
+
{t.label}
|
|
166
|
+
</Select.Option>
|
|
167
|
+
))}
|
|
168
|
+
</Select.Group>
|
|
169
|
+
))}
|
|
170
|
+
</Select>
|
|
171
|
+
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<section aria-labelledby="cw-code-preview-title" className="min-w-0">
|
|
175
|
+
<h2 id="cw-code-preview-title" className="mb-2 text-sm font-medium">
|
|
176
|
+
Preview
|
|
177
|
+
</h2>
|
|
178
|
+
{!mainOn && (
|
|
179
|
+
<p className="mb-2 text-sm text-kumo-subtle">
|
|
180
|
+
Enhanced code blocks are off, so the site shows EmDash's plain code blocks. This is how they'll look when on.
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
{preview ? (
|
|
184
|
+
// The markup comes from this plugin's own renderer (escaped text and hljs spans only).
|
|
185
|
+
<div inert className="text-left">
|
|
186
|
+
<style>{preview.css}</style>
|
|
187
|
+
<div dangerouslySetInnerHTML={{ __html: preview.html }} />
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="flex justify-center py-12">
|
|
191
|
+
<Loader />
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
<p className="mt-2 text-xs text-kumo-subtle">
|
|
195
|
+
"Light/dark by system" themes follow your computer's appearance setting, as they will for visitors.
|
|
196
|
+
</p>
|
|
197
|
+
</section>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
<SaveBar dirty={dirty} saving={saving} onSave={() => void save()} onDiscard={() => saved && setTheme(saved.theme)} />
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Blocks page: the site's affiliate disclosure wording and podcast
|
|
3
|
+
* links, and live previews of every block (rendered by the same code as the
|
|
4
|
+
* site). Turning blocks on or off happens on the Coywolf Pack page.
|
|
5
|
+
*/
|
|
6
|
+
import { Banner, Button, Checkbox, Input, InputArea, Loader, Select } from "@cloudflare/kumo";
|
|
7
|
+
import { FloppyDisk } from "@phosphor-icons/react";
|
|
8
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
import { PreviewSection } from "./preview.js";
|
|
12
|
+
import { SaveBar, isDirty } from "./save-bar.js";
|
|
13
|
+
import {
|
|
14
|
+
CUSTOM_BLOCKS_CSS,
|
|
15
|
+
DEFAULT_DISCLOSURE,
|
|
16
|
+
DEFAULT_PODCAST,
|
|
17
|
+
type DisclosureSettings,
|
|
18
|
+
MAX_DISCLOSURE_TEXT,
|
|
19
|
+
NOTE_VARIANTS,
|
|
20
|
+
PODCAST_SERVICES,
|
|
21
|
+
type PodcastHeadingTag,
|
|
22
|
+
type PodcastService,
|
|
23
|
+
type PodcastSettings,
|
|
24
|
+
SAMPLE_DETAILS,
|
|
25
|
+
SAMPLE_NOTE,
|
|
26
|
+
SAMPLE_QUOTE,
|
|
27
|
+
SAMPLE_TESTIMONIAL,
|
|
28
|
+
linkUrl,
|
|
29
|
+
normalizeDisclosureSettings,
|
|
30
|
+
normalizePodcastSettings,
|
|
31
|
+
renderDetailsHtml,
|
|
32
|
+
renderDisclosureHtml,
|
|
33
|
+
renderNoteHtml,
|
|
34
|
+
renderPodcastHtml,
|
|
35
|
+
renderQuoteHtml,
|
|
36
|
+
renderTestimonialHtml,
|
|
37
|
+
} from "../customBlocks/render.js";
|
|
38
|
+
import { safeUrl } from "../customBlocks/rich.js";
|
|
39
|
+
import { PartSwitches } from "./feature-switches.js";
|
|
40
|
+
|
|
41
|
+
const HEADING_ITEMS: Array<{ value: PodcastHeadingTag; label: string }> = [
|
|
42
|
+
{ value: "h2", label: "Heading 2" },
|
|
43
|
+
{ value: "h3", label: "Heading 3" },
|
|
44
|
+
{ value: "h4", label: "Heading 4" },
|
|
45
|
+
{ value: "p", label: "Bold text" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const SAMPLE_PODCAST_LINKS = Object.fromEntries(PODCAST_SERVICES.map(([id]) => [id, "https://example.com/"])) as Record<PodcastService, string>;
|
|
49
|
+
|
|
50
|
+
const API = "/_emdash/api/plugins/coywolf-pack/customBlocks";
|
|
51
|
+
|
|
52
|
+
const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
53
|
+
|
|
54
|
+
const same = (a: DisclosureSettings, b: DisclosureSettings) =>
|
|
55
|
+
a.affiliateText === b.affiliateText && a.amazonText === b.amazonText && a.linkUrl === b.linkUrl && a.linkText === b.linkText;
|
|
56
|
+
|
|
57
|
+
export function CustomBlocksPage() {
|
|
58
|
+
const [saved, setSaved] = React.useState<DisclosureSettings | null>(null);
|
|
59
|
+
const [draft, setDraft] = React.useState<DisclosureSettings>(DEFAULT_DISCLOSURE);
|
|
60
|
+
const [podcastSaved, setPodcastSaved] = React.useState<PodcastSettings | null>(null);
|
|
61
|
+
const [podcast, setPodcast] = React.useState<PodcastSettings>(DEFAULT_PODCAST);
|
|
62
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
63
|
+
const [status, setStatus] = React.useState("");
|
|
64
|
+
const [saving, setSaving] = React.useState(false);
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
void (async () => {
|
|
68
|
+
try {
|
|
69
|
+
const r = await apiFetch(`${API}/settings`).then((res) =>
|
|
70
|
+
parseApiResponse<{ settings: DisclosureSettings; podcast: PodcastSettings }>(res, "Couldn't load settings"),
|
|
71
|
+
);
|
|
72
|
+
setDraft(r.settings);
|
|
73
|
+
setSaved(r.settings);
|
|
74
|
+
const p = normalizePodcastSettings(r.podcast);
|
|
75
|
+
setPodcast(p);
|
|
76
|
+
setPodcastSaved(p);
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
setError(errorText(cause, "Couldn't load settings"));
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const disclosureDirty = saved !== null && !same(draft, saved);
|
|
84
|
+
const podcastDirty = isDirty(podcast, podcastSaved);
|
|
85
|
+
const dirty = disclosureDirty || podcastDirty;
|
|
86
|
+
const linkValid = !draft.linkUrl.trim() || Boolean(safeUrl(draft.linkUrl.trim()));
|
|
87
|
+
const tooLong = draft.affiliateText.length > MAX_DISCLOSURE_TEXT || draft.amazonText.length > MAX_DISCLOSURE_TEXT;
|
|
88
|
+
const badPodcastLinks = PODCAST_SERVICES.filter(([id]) => podcast.links[id].trim() && !linkUrl(podcast.links[id])).map(([id]) => id);
|
|
89
|
+
const canSave = linkValid && !tooLong && !badPodcastLinks.length && podcast.heading.length <= 200;
|
|
90
|
+
const set = (patch: Partial<DisclosureSettings>) => setDraft((cur) => ({ ...cur, ...patch }));
|
|
91
|
+
const setPod = (patch: Partial<PodcastSettings>) => setPodcast((cur) => ({ ...cur, ...patch }));
|
|
92
|
+
const setLink = (id: PodcastService, url: string) => setPodcast((cur) => ({ ...cur, links: { ...cur.links, [id]: url } }));
|
|
93
|
+
|
|
94
|
+
async function save() {
|
|
95
|
+
if (!saved || !podcastSaved || !canSave) return;
|
|
96
|
+
setSaving(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
setStatus("Saving…");
|
|
99
|
+
try {
|
|
100
|
+
if (disclosureDirty) {
|
|
101
|
+
const response = await apiFetch(`${API}/settings/save`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify(draft),
|
|
105
|
+
});
|
|
106
|
+
const r = await parseApiResponse<{ settings: DisclosureSettings }>(response, "Couldn't save the disclosure");
|
|
107
|
+
setDraft(r.settings);
|
|
108
|
+
setSaved(r.settings);
|
|
109
|
+
}
|
|
110
|
+
if (podcastDirty) {
|
|
111
|
+
const response = await apiFetch(`${API}/podcast/save`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ ...podcast, links: Object.fromEntries(PODCAST_SERVICES.map(([id]) => [id, podcast.links[id].trim()])) }),
|
|
115
|
+
});
|
|
116
|
+
const r = await parseApiResponse<{ podcast: PodcastSettings }>(response, "Couldn't save the podcast links");
|
|
117
|
+
setPodcast(r.podcast);
|
|
118
|
+
setPodcastSaved(r.podcast);
|
|
119
|
+
}
|
|
120
|
+
setStatus("Saved. The site picks up changes within a minute.");
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
setStatus("");
|
|
123
|
+
setError(errorText(cause, "Couldn't save"));
|
|
124
|
+
} finally {
|
|
125
|
+
setSaving(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What the site would print with the draft (empty fields fall back to the defaults, as on save).
|
|
130
|
+
const effective = normalizeDisclosureSettings(draft);
|
|
131
|
+
const notes = NOTE_VARIANTS.map(([variant], i) => renderNoteHtml({ ...SAMPLE_NOTE, variant, body: i ? `A ${variant === "editor" ? "note from the editor" : variant}, set apart from the text.` : SAMPLE_NOTE.body }, `cw-note-sample-${variant}`)).join("");
|
|
132
|
+
const disclosures =
|
|
133
|
+
renderDisclosureHtml({ kind: "affiliate", text: "" }, effective) + renderDisclosureHtml({ kind: "amazon", text: "" }, effective);
|
|
134
|
+
const details = renderDetailsHtml(SAMPLE_DETAILS) + renderDetailsHtml({ variant: "transcript", summary: "", body: "<p><abbr title=\"Jon Henshaw\">JH</abbr>: Welcome to the show.</p>\n<p><abbr title=\"Guest\">G</abbr>: Thanks for having me.</p>", open: false });
|
|
135
|
+
const quote = renderQuoteHtml(SAMPLE_QUOTE);
|
|
136
|
+
const testimonial = renderTestimonialHtml(SAMPLE_TESTIMONIAL);
|
|
137
|
+
// What the site would print with the draft; invalid links are left out, as on save.
|
|
138
|
+
const podcastEffective = normalizePodcastSettings(podcast);
|
|
139
|
+
const podcastHasLinks = PODCAST_SERVICES.some(([id]) => podcastEffective.links[id]);
|
|
140
|
+
const podcastHtml = renderPodcastHtml(
|
|
141
|
+
{ source: "site", heading: "", links: podcastEffective.links },
|
|
142
|
+
podcastHasLinks ? podcastEffective : { ...podcastEffective, links: SAMPLE_PODCAST_LINKS },
|
|
143
|
+
"cw-podcast-sample",
|
|
144
|
+
);
|
|
145
|
+
const note = (
|
|
146
|
+
<>
|
|
147
|
+
The previews use this page's fonts and your computer's light or dark setting. On the site the blocks use your theme's fonts and text color.
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="space-y-6">
|
|
153
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
154
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
155
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Custom Blocks</h1>
|
|
156
|
+
<div className="flex shrink-0 justify-end">
|
|
157
|
+
<Button variant="primary" icon={<FloppyDisk />} disabled={!dirty || saving || !canSave} onClick={() => void save()}>
|
|
158
|
+
Save
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
162
|
+
Adds Note, Details, Affiliate disclosure, Quote, Testimonial and Podcast links blocks to the editor (turn each one on on the Coywolf Pack page). Their text fields take
|
|
163
|
+
plain text: a blank line starts a new paragraph, <code>[link text](https://…)</code> makes a link and <code>**text**</code> is bold. Simple
|
|
164
|
+
HTML (links, bold, italics, code, lists) works too. The blocks are styled with a little CSS sent only on pages that use them; no script.
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
</header>
|
|
168
|
+
|
|
169
|
+
<PartSwitches module="customBlocks" />
|
|
170
|
+
|
|
171
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
172
|
+
<p className="sr-only" role="status" aria-live="polite">
|
|
173
|
+
{status}
|
|
174
|
+
</p>
|
|
175
|
+
{status && !saving && <p className="text-sm text-kumo-subtle">{status}</p>}
|
|
176
|
+
|
|
177
|
+
{!saved && !error && (
|
|
178
|
+
<div className="flex justify-center py-12">
|
|
179
|
+
<Loader />
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{saved && (
|
|
184
|
+
<>
|
|
185
|
+
<section aria-labelledby="cw-cb-disclosure" className="grid gap-6 cw-lg-cols-24-fill">
|
|
186
|
+
<div className="space-y-4">
|
|
187
|
+
<div>
|
|
188
|
+
<h2 id="cw-cb-disclosure" className="text-base font-semibold">
|
|
189
|
+
Affiliate disclosure
|
|
190
|
+
</h2>
|
|
191
|
+
<p className="mt-1 text-sm text-kumo-subtle">
|
|
192
|
+
The wording every Affiliate disclosure block shows, unless a block has its own. Put the block before the first affiliate link.
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
<InputArea
|
|
196
|
+
label="Affiliate links"
|
|
197
|
+
rows={3}
|
|
198
|
+
value={draft.affiliateText}
|
|
199
|
+
placeholder={DEFAULT_DISCLOSURE.affiliateText}
|
|
200
|
+
aria-invalid={draft.affiliateText.length > MAX_DISCLOSURE_TEXT || undefined}
|
|
201
|
+
aria-describedby="cw-cb-disclosure-limit"
|
|
202
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set({ affiliateText: e.target.value })}
|
|
203
|
+
/>
|
|
204
|
+
<InputArea
|
|
205
|
+
label="Amazon Associates"
|
|
206
|
+
rows={2}
|
|
207
|
+
value={draft.amazonText}
|
|
208
|
+
placeholder={DEFAULT_DISCLOSURE.amazonText}
|
|
209
|
+
aria-invalid={draft.amazonText.length > MAX_DISCLOSURE_TEXT || undefined}
|
|
210
|
+
aria-describedby="cw-cb-disclosure-limit"
|
|
211
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set({ amazonText: e.target.value })}
|
|
212
|
+
/>
|
|
213
|
+
<p id="cw-cb-disclosure-limit" className={`text-xs ${tooLong ? "text-kumo-danger" : "text-kumo-subtle"}`}>
|
|
214
|
+
Up to {MAX_DISCLOSURE_TEXT.toLocaleString()} characters each. Amazon requires the words “As an Amazon Associate I earn from qualifying
|
|
215
|
+
purchases.” Empty fields use the wording shown in gray.
|
|
216
|
+
</p>
|
|
217
|
+
<Input
|
|
218
|
+
label="Disclosure page (optional)"
|
|
219
|
+
value={draft.linkUrl}
|
|
220
|
+
placeholder="/disclosures/"
|
|
221
|
+
aria-invalid={!linkValid}
|
|
222
|
+
aria-describedby={linkValid ? undefined : "cw-cb-link-error"}
|
|
223
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ linkUrl: e.target.value })}
|
|
224
|
+
/>
|
|
225
|
+
{!linkValid && (
|
|
226
|
+
<p id="cw-cb-link-error" className="text-sm text-kumo-danger" role="alert">
|
|
227
|
+
Use a link like https://example.com/disclosures/ or /disclosures/.
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
<Input
|
|
231
|
+
label="Link text"
|
|
232
|
+
value={draft.linkText}
|
|
233
|
+
placeholder={DEFAULT_DISCLOSURE.linkText}
|
|
234
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ linkText: e.target.value })}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
<PreviewSection id="cw-cb-disclosure-preview" title="Affiliate disclosure preview" css={CUSTOM_BLOCKS_CSS} html={disclosures} note={note} />
|
|
238
|
+
</section>
|
|
239
|
+
|
|
240
|
+
<section aria-labelledby="cw-cb-podcast" className="grid gap-6 border-t border-kumo-line pt-6 cw-lg-cols-24-fill">
|
|
241
|
+
<div className="space-y-4">
|
|
242
|
+
<div>
|
|
243
|
+
<h2 id="cw-cb-podcast" className="text-base font-semibold">
|
|
244
|
+
Podcast links
|
|
245
|
+
</h2>
|
|
246
|
+
<p className="mt-1 text-sm text-kumo-subtle">
|
|
247
|
+
Where people can listen to your podcast. Every Podcast links block shows these, unless a block has its own. Leave a service empty to
|
|
248
|
+
leave it out.
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
<Input
|
|
252
|
+
label="Heading"
|
|
253
|
+
value={podcast.heading}
|
|
254
|
+
placeholder={DEFAULT_PODCAST.heading}
|
|
255
|
+
maxLength={200}
|
|
256
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPod({ heading: e.target.value })}
|
|
257
|
+
/>
|
|
258
|
+
<Select
|
|
259
|
+
label="Heading style"
|
|
260
|
+
value={podcast.headingTag}
|
|
261
|
+
onValueChange={(value: string | null) => setPod({ headingTag: (value as PodcastHeadingTag | null) ?? "h2" })}
|
|
262
|
+
items={HEADING_ITEMS}
|
|
263
|
+
/>
|
|
264
|
+
<Checkbox label="Show an icon next to each link" checked={podcast.showIcons} onCheckedChange={(c: boolean) => setPod({ showIcons: c })} />
|
|
265
|
+
{PODCAST_SERVICES.map(([id, label]) => (
|
|
266
|
+
<div key={id}>
|
|
267
|
+
<Input
|
|
268
|
+
label={label}
|
|
269
|
+
value={podcast.links[id]}
|
|
270
|
+
placeholder={id === "rss" ? "https://example.com/podcast.xml" : "https://…"}
|
|
271
|
+
aria-invalid={badPodcastLinks.includes(id)}
|
|
272
|
+
aria-describedby={badPodcastLinks.includes(id) ? `cw-cb-podcast-${id}-error` : undefined}
|
|
273
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLink(id, e.target.value)}
|
|
274
|
+
/>
|
|
275
|
+
{badPodcastLinks.includes(id) && (
|
|
276
|
+
<p id={`cw-cb-podcast-${id}-error`} className="mt-1 text-sm text-kumo-danger" role="alert">
|
|
277
|
+
Use a web address like https://example.com/…{id === "rss" ? " or /podcast.xml" : ""}.
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
<PreviewSection
|
|
284
|
+
id="cw-cb-podcast-preview"
|
|
285
|
+
title="Podcast links preview"
|
|
286
|
+
css={CUSTOM_BLOCKS_CSS}
|
|
287
|
+
html={podcastHtml}
|
|
288
|
+
note={podcastHasLinks ? note : <>No links yet, so the preview shows every service. On the site, a block with no links shows nothing.</>}
|
|
289
|
+
/>
|
|
290
|
+
</section>
|
|
291
|
+
|
|
292
|
+
<section aria-labelledby="cw-cb-blocks" className="space-y-6 border-t border-kumo-line pt-6">
|
|
293
|
+
<div>
|
|
294
|
+
<h2 id="cw-cb-blocks" className="text-base font-semibold">
|
|
295
|
+
The other blocks
|
|
296
|
+
</h2>
|
|
297
|
+
<p className="mt-1 text-sm text-kumo-subtle">
|
|
298
|
+
They have no site settings. To restyle them, set the <code>--cw-note-accent</code> custom property or target the{" "}
|
|
299
|
+
<code>.cw-note</code>, <code>.cw-details</code>, <code>.cw-quote</code>, <code>.cw-testimonial</code> and <code>.cw-podcast</code>{" "}
|
|
300
|
+
classes in your theme's CSS.
|
|
301
|
+
</p>
|
|
302
|
+
</div>
|
|
303
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
304
|
+
<PreviewSection id="cw-cb-note-preview" title="Note block preview" css={CUSTOM_BLOCKS_CSS} html={notes} />
|
|
305
|
+
<div className="space-y-6">
|
|
306
|
+
<PreviewSection id="cw-cb-details-preview" title="Details block preview" css={CUSTOM_BLOCKS_CSS} html={details} />
|
|
307
|
+
<PreviewSection id="cw-cb-quote-preview" title="Quote block preview" css={CUSTOM_BLOCKS_CSS} html={quote} />
|
|
308
|
+
<PreviewSection id="cw-cb-testimonial-preview" title="Testimonial block preview" css={CUSTOM_BLOCKS_CSS} html={testimonial} note={note} />
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</section>
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
<SaveBar dirty={dirty} saving={saving} canSave={canSave} onSave={() => void save()} onDiscard={() => {
|
|
316
|
+
if (saved) setDraft(saved);
|
|
317
|
+
if (podcastSaved) setPodcast(podcastSaved);
|
|
318
|
+
}} />
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unsaved-changes comparison for settings pages: a draft is dirty when it no
|
|
3
|
+
* longer matches the last saved snapshot. Key order doesn't matter, so a
|
|
4
|
+
* draft rebuilt with its keys in another order still counts as unchanged.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** JSON with object keys sorted, so equal values always serialize the same. */
|
|
8
|
+
export function stableStringify(value: unknown): string {
|
|
9
|
+
return JSON.stringify(value, (_key, v: unknown) =>
|
|
10
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
11
|
+
? Object.fromEntries(Object.keys(v as Record<string, unknown>).sort().map((k) => [k, (v as Record<string, unknown>)[k]]))
|
|
12
|
+
: v,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when `draft` differs from `saved`. Nothing saved yet (still loading) is never dirty. */
|
|
17
|
+
export function isDirty(draft: unknown, saved: unknown): boolean {
|
|
18
|
+
if (saved === undefined || saved === null) return false;
|
|
19
|
+
return stableStringify(draft) !== stableStringify(saved);
|
|
20
|
+
}
|