@coywolf/emdash 0.0.0-stage → 0.35.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Blocks markup and CSS, shared by the Astro components
|
|
3
|
+
* (src/astro/customBlocks) and the live previews on the Custom Blocks admin
|
|
4
|
+
* page, so the preview is exactly what the site prints. Block fields are
|
|
5
|
+
* cleaned here (normalize*), then rendered with renderRich for their text.
|
|
6
|
+
*
|
|
7
|
+
* Pure, no imports beyond siblings: runs in the Worker, the admin and tests.
|
|
8
|
+
*/
|
|
9
|
+
import { escapeHtml, plainText, renderRich, safeUrl } from "./rich.js";
|
|
10
|
+
|
|
11
|
+
export const BLOCK_TYPES = {
|
|
12
|
+
note: "coywolf-note",
|
|
13
|
+
details: "coywolf-details",
|
|
14
|
+
disclosure: "coywolf-disclosure",
|
|
15
|
+
quote: "coywolf-quote",
|
|
16
|
+
testimonial: "coywolf-testimonial",
|
|
17
|
+
podcast: "coywolf-podcast",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const str = (v: unknown, max = 500_000) => (typeof v === "string" ? v.slice(0, max) : typeof v === "number" ? String(v) : "");
|
|
21
|
+
const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T => (allowed.includes(v as T) ? (v as T) : fallback);
|
|
22
|
+
const truthy = (v: unknown) => v === true || v === "true" || v === "1" || v === "open";
|
|
23
|
+
|
|
24
|
+
/** An id for aria-labelledby, from the block's key (unique per page). */
|
|
25
|
+
export function blockId(prefix: string, key: unknown): string {
|
|
26
|
+
const k = typeof key === "string" ? key.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) : "";
|
|
27
|
+
return `${prefix}-${k || "block"}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Note / callout ───────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export const NOTE_VARIANTS = [
|
|
33
|
+
["note", "Note"],
|
|
34
|
+
["editor", "Editor's note"],
|
|
35
|
+
["tip", "Tip"],
|
|
36
|
+
["warning", "Warning"],
|
|
37
|
+
] as const;
|
|
38
|
+
export type NoteVariant = (typeof NOTE_VARIANTS)[number][0];
|
|
39
|
+
const NOTE_IDS = NOTE_VARIANTS.map(([v]) => v);
|
|
40
|
+
export const NOTE_TITLE_TAGS = ["p", "h2", "h3", "h4"] as const;
|
|
41
|
+
export type NoteTitleTag = (typeof NOTE_TITLE_TAGS)[number];
|
|
42
|
+
|
|
43
|
+
export interface Note {
|
|
44
|
+
variant: NoteVariant;
|
|
45
|
+
/** Rich inline text; empty = the variant's name. */
|
|
46
|
+
title: string;
|
|
47
|
+
hideTitle: boolean;
|
|
48
|
+
/** "p": bold text; "h2"–"h4": a heading. */
|
|
49
|
+
titleTag: NoteTitleTag;
|
|
50
|
+
body: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeNote(node: Record<string, unknown>): Note {
|
|
54
|
+
return {
|
|
55
|
+
variant: pick(node.variant, NOTE_IDS, "note"),
|
|
56
|
+
title: str(node.title, 300).trim(),
|
|
57
|
+
hideTitle: truthy(node.hideTitle),
|
|
58
|
+
titleTag: pick(node.titleTag, NOTE_TITLE_TAGS, "p"),
|
|
59
|
+
body: str(node.body),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const variantLabel = (v: NoteVariant) => NOTE_VARIANTS.find(([id]) => id === v)?.[1] ?? "Note";
|
|
64
|
+
|
|
65
|
+
/** `<aside>` labelled by its title (or the variant name when the title is hidden). */
|
|
66
|
+
export function renderNoteHtml(note: Note, id: string): string {
|
|
67
|
+
const body = renderRich(note.body);
|
|
68
|
+
const title = renderRich(note.title || escapeHtml(variantLabel(note.variant)), { inline: true });
|
|
69
|
+
if (!body && !note.title) return "";
|
|
70
|
+
const tag = note.titleTag;
|
|
71
|
+
const labelled = note.hideTitle ? `aria-label="${escapeHtml(plainText(note.title) || variantLabel(note.variant))}"` : `aria-labelledby="${escapeHtml(id)}"`;
|
|
72
|
+
const heading = note.hideTitle ? "" : `<${tag} class="cw-note__title" id="${escapeHtml(id)}">${title}</${tag}>`;
|
|
73
|
+
return `<aside class="cw-note cw-note--${note.variant}" ${labelled}>${heading}${body ? `<div class="cw-note__body">${body}</div>` : ""}</aside>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Details / disclosure ─────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export const DETAILS_VARIANTS = [
|
|
79
|
+
["details", "Details"],
|
|
80
|
+
["transcript", "Transcript"],
|
|
81
|
+
] as const;
|
|
82
|
+
export type DetailsVariant = (typeof DETAILS_VARIANTS)[number][0];
|
|
83
|
+
|
|
84
|
+
export interface Details {
|
|
85
|
+
variant: DetailsVariant;
|
|
86
|
+
summary: string;
|
|
87
|
+
body: string;
|
|
88
|
+
open: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const DEFAULT_SUMMARY: Record<DetailsVariant, string> = { details: "Details", transcript: "Read the transcript" };
|
|
92
|
+
|
|
93
|
+
export function normalizeDetails(node: Record<string, unknown>): Details {
|
|
94
|
+
const variant = pick(node.variant, DETAILS_VARIANTS.map(([v]) => v), "details");
|
|
95
|
+
return { variant, summary: str(node.summary, 500).trim(), body: str(node.body), open: truthy(node.open) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Native <details>/<summary>: keyboard and screen reader support come with it. */
|
|
99
|
+
export function renderDetailsHtml(d: Details): string {
|
|
100
|
+
const body = renderRich(d.body);
|
|
101
|
+
if (!body && !d.summary) return "";
|
|
102
|
+
const summary = renderRich(d.summary || DEFAULT_SUMMARY[d.variant], { inline: true });
|
|
103
|
+
return `<details class="cw-details cw-details--${d.variant}"${d.open ? " open" : ""}><summary class="cw-details__summary">${summary}</summary><div class="cw-details__body">${body}</div></details>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Affiliate disclosure ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export const DISCLOSURE_KINDS = [
|
|
109
|
+
["affiliate", "Affiliate links"],
|
|
110
|
+
["amazon", "Amazon Associates"],
|
|
111
|
+
] as const;
|
|
112
|
+
export type DisclosureKind = (typeof DISCLOSURE_KINDS)[number][0];
|
|
113
|
+
|
|
114
|
+
export interface DisclosureSettings {
|
|
115
|
+
/** Rich inline text for affiliate links. */
|
|
116
|
+
affiliateText: string;
|
|
117
|
+
/** Rich inline text for Amazon Associates links. */
|
|
118
|
+
amazonText: string;
|
|
119
|
+
/** Optional page explaining the site's disclosures, linked after the text. */
|
|
120
|
+
linkUrl: string;
|
|
121
|
+
linkText: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const DEFAULT_DISCLOSURE: DisclosureSettings = {
|
|
125
|
+
affiliateText: "This page contains affiliate links. If you buy something through one of them, we may earn a commission at no extra cost to you.",
|
|
126
|
+
amazonText: "As an Amazon Associate I earn from qualifying purchases.",
|
|
127
|
+
linkUrl: "",
|
|
128
|
+
linkText: "Learn more",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const MAX_DISCLOSURE_TEXT = 1000;
|
|
132
|
+
|
|
133
|
+
export function normalizeDisclosureSettings(value: unknown): DisclosureSettings {
|
|
134
|
+
const v = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
135
|
+
const text = (x: unknown, fallback: string) => (typeof x === "string" && x.trim() ? x.trim().slice(0, MAX_DISCLOSURE_TEXT) : fallback);
|
|
136
|
+
const url = typeof v.linkUrl === "string" ? (safeUrl(v.linkUrl.trim()) ?? "") : "";
|
|
137
|
+
return {
|
|
138
|
+
affiliateText: text(v.affiliateText, DEFAULT_DISCLOSURE.affiliateText),
|
|
139
|
+
amazonText: text(v.amazonText, DEFAULT_DISCLOSURE.amazonText),
|
|
140
|
+
linkUrl: url,
|
|
141
|
+
linkText: text(v.linkText, DEFAULT_DISCLOSURE.linkText).slice(0, 100),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface Disclosure {
|
|
146
|
+
kind: DisclosureKind;
|
|
147
|
+
/** This block's own wording; empty = the site's. */
|
|
148
|
+
text: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeDisclosure(node: Record<string, unknown>): Disclosure {
|
|
152
|
+
return { kind: pick(node.kind, DISCLOSURE_KINDS.map(([k]) => k), "affiliate"), text: str(node.text, MAX_DISCLOSURE_TEXT).trim() };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function renderDisclosureHtml(d: Disclosure, s: DisclosureSettings): string {
|
|
156
|
+
const text = renderRich(d.text || (d.kind === "amazon" ? s.amazonText : s.affiliateText), { inline: true });
|
|
157
|
+
if (!text) return "";
|
|
158
|
+
const link = s.linkUrl ? ` <a class="cw-disclosure__link" href="${escapeHtml(s.linkUrl)}">${escapeHtml(s.linkText || DEFAULT_DISCLOSURE.linkText)}</a>` : "";
|
|
159
|
+
const label = d.kind === "amazon" ? "Amazon Associates disclosure" : "Affiliate disclosure";
|
|
160
|
+
return `<aside class="cw-disclosure cw-disclosure--${d.kind}" aria-label="${label}"><p class="cw-disclosure__text">${text}${link}</p></aside>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Quote with citation ──────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface Quote {
|
|
166
|
+
quote: string;
|
|
167
|
+
/** Rich inline: who said it (may hold a link). */
|
|
168
|
+
citation: string;
|
|
169
|
+
/** The source's URL, for the blockquote's cite attribute. */
|
|
170
|
+
sourceUrl: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function normalizeQuote(node: Record<string, unknown>): Quote {
|
|
174
|
+
const url = str(node.sourceUrl, 2000).trim();
|
|
175
|
+
const safe = url ? safeUrl(url) : null;
|
|
176
|
+
return { quote: str(node.quote), citation: str(node.citation, 2000).trim(), sourceUrl: safe && /^(?:https?:)?\/\//i.test(safe) ? safe : safe?.startsWith("/") ? safe : "" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `<figure><blockquote cite>…</blockquote><figcaption><cite>…</cite></figcaption></figure>`, like WordPress's custom block. */
|
|
180
|
+
export function renderQuoteHtml(q: Quote): string {
|
|
181
|
+
const body = renderRich(q.quote);
|
|
182
|
+
if (!body) return "";
|
|
183
|
+
const caption = renderRich(q.citation, { inline: true });
|
|
184
|
+
return `<figure class="cw-quote"><blockquote class="cw-quote__text"${q.sourceUrl ? ` cite="${escapeHtml(q.sourceUrl)}"` : ""}>${body}</blockquote>${
|
|
185
|
+
caption ? `<figcaption class="cw-quote__caption"><cite>${caption}</cite></figcaption>` : ""
|
|
186
|
+
}</figure>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Testimonial ──────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export interface Testimonial {
|
|
192
|
+
/** Rich text: what the person said. */
|
|
193
|
+
quote: string;
|
|
194
|
+
/** Plain text. */
|
|
195
|
+
name: string;
|
|
196
|
+
/** Plain text: role, company, … */
|
|
197
|
+
title: string;
|
|
198
|
+
/** Headshot URL (http(s) or site-relative), or empty. */
|
|
199
|
+
photo: string;
|
|
200
|
+
/** Link for the name (e.g. a social profile), or empty. */
|
|
201
|
+
nameUrl: string;
|
|
202
|
+
/** Link for the title (e.g. the company's site), or empty. */
|
|
203
|
+
titleUrl: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** An http(s) or site-relative URL, else "". */
|
|
207
|
+
export function linkUrl(value: unknown): string {
|
|
208
|
+
const raw = typeof value === "string" ? value.trim() : value && typeof value === "object" && "url" in value ? String((value as { url: unknown }).url ?? "").trim() : "";
|
|
209
|
+
if (!raw) return "";
|
|
210
|
+
const safe = safeUrl(raw);
|
|
211
|
+
return safe && (/^https?:\/\/[^\s]+$/i.test(safe) || /^\/(?!\/)\S*$/.test(safe)) ? safe : "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function normalizeTestimonial(node: Record<string, unknown>): Testimonial {
|
|
215
|
+
return {
|
|
216
|
+
quote: str(node.quote),
|
|
217
|
+
name: str(node.name, 200).trim(),
|
|
218
|
+
title: str(node.title, 300).trim(),
|
|
219
|
+
photo: linkUrl(node.photo),
|
|
220
|
+
nameUrl: linkUrl(node.nameUrl),
|
|
221
|
+
titleUrl: linkUrl(node.titleUrl),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `<figure><blockquote>…</blockquote><figcaption>photo, name, title</figcaption></figure>`.
|
|
227
|
+
* The photo's alt is empty: the name right after it says who it is. No
|
|
228
|
+
* schema: testimonials a site picks about itself aren't eligible for review
|
|
229
|
+
* rich results. `photoSrc` replaces the photo's URL with resized copies (the
|
|
230
|
+
* media host's 64px and 128px squares) when the caller has them.
|
|
231
|
+
*/
|
|
232
|
+
export function renderTestimonialHtml(t: Testimonial, photoSrc?: { src: string; srcset?: string; avif?: string } | null): string {
|
|
233
|
+
const body = renderRich(t.quote);
|
|
234
|
+
if (!body) return "";
|
|
235
|
+
const link = (url: string, text: string) => (url ? `<a href="${escapeHtml(url)}">${text}</a>` : text);
|
|
236
|
+
const src = photoSrc?.src || t.photo;
|
|
237
|
+
const srcset = photoSrc?.src && photoSrc.srcset ? ` srcset="${escapeHtml(photoSrc.srcset)}"` : "";
|
|
238
|
+
const img = t.photo ? `<img class="cw-testimonial__photo" src="${escapeHtml(src)}"${srcset} alt="" width="64" height="64" loading="lazy" decoding="async">` : "";
|
|
239
|
+
// Stored sizes: AVIF first for browsers that take it.
|
|
240
|
+
const photo = img && photoSrc?.avif ? `<picture><source type="image/avif" srcset="${escapeHtml(photoSrc.avif)}">${img}</picture>` : img;
|
|
241
|
+
const who =
|
|
242
|
+
t.name || t.title
|
|
243
|
+
? `<span class="cw-testimonial__who">${t.name ? `<span class="cw-testimonial__name">${link(t.nameUrl, escapeHtml(t.name))}</span>` : ""}${
|
|
244
|
+
t.title ? `<span class="cw-testimonial__title">${link(t.titleUrl, escapeHtml(t.title))}</span>` : ""
|
|
245
|
+
}</span>`
|
|
246
|
+
: "";
|
|
247
|
+
return `<figure class="cw-testimonial"><blockquote class="cw-testimonial__quote">${body}</blockquote>${
|
|
248
|
+
photo || who ? `<figcaption class="cw-testimonial__person">${photo}${who}</figcaption>` : ""
|
|
249
|
+
}</figure>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Podcast links ────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export const PODCAST_SERVICES = [
|
|
255
|
+
["apple", "Apple Podcasts"],
|
|
256
|
+
["spotify", "Spotify"],
|
|
257
|
+
["youtube", "YouTube"],
|
|
258
|
+
["amazon", "Amazon Music"],
|
|
259
|
+
["overcast", "Overcast"],
|
|
260
|
+
["pocketCasts", "Pocket Casts"],
|
|
261
|
+
["rss", "RSS feed"],
|
|
262
|
+
] as const;
|
|
263
|
+
export type PodcastService = (typeof PODCAST_SERVICES)[number][0];
|
|
264
|
+
export type PodcastLinks = Record<PodcastService, string>;
|
|
265
|
+
export const PODCAST_HEADING_TAGS = ["h2", "h3", "h4", "p"] as const;
|
|
266
|
+
export type PodcastHeadingTag = (typeof PODCAST_HEADING_TAGS)[number];
|
|
267
|
+
|
|
268
|
+
export interface PodcastSettings {
|
|
269
|
+
/** Plain text. */
|
|
270
|
+
heading: string;
|
|
271
|
+
/** "p": bold text. */
|
|
272
|
+
headingTag: PodcastHeadingTag;
|
|
273
|
+
showIcons: boolean;
|
|
274
|
+
links: PodcastLinks;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const noLinks = (): PodcastLinks => Object.fromEntries(PODCAST_SERVICES.map(([id]) => [id, ""])) as PodcastLinks;
|
|
278
|
+
|
|
279
|
+
export const DEFAULT_PODCAST: PodcastSettings = { heading: "Subscribe to the podcast", headingTag: "h2", showIcons: true, links: noLinks() };
|
|
280
|
+
|
|
281
|
+
function podcastLinks(value: unknown): PodcastLinks {
|
|
282
|
+
const v = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
283
|
+
const links = noLinks();
|
|
284
|
+
for (const [id] of PODCAST_SERVICES) links[id] = linkUrl(v[id]);
|
|
285
|
+
return links;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function normalizePodcastSettings(value: unknown): PodcastSettings {
|
|
289
|
+
const v = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
290
|
+
return {
|
|
291
|
+
heading: str(v.heading, 200).trim() || DEFAULT_PODCAST.heading,
|
|
292
|
+
headingTag: pick(v.headingTag, PODCAST_HEADING_TAGS, DEFAULT_PODCAST.headingTag),
|
|
293
|
+
showIcons: v.showIcons === undefined ? DEFAULT_PODCAST.showIcons : truthy(v.showIcons),
|
|
294
|
+
links: podcastLinks(v.links),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface Podcast {
|
|
299
|
+
/** "site": the links on the Custom Blocks page; "block": this block's own. */
|
|
300
|
+
source: "site" | "block";
|
|
301
|
+
/** Plain text; empty = the site's heading. */
|
|
302
|
+
heading: string;
|
|
303
|
+
links: PodcastLinks;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function normalizePodcast(node: Record<string, unknown>): Podcast {
|
|
307
|
+
return { source: pick(node.source, ["site", "block"] as const, "site"), heading: str(node.heading, 200).trim(), links: podcastLinks(node) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const svg = (body: string) =>
|
|
311
|
+
`<svg class="cw-podcast__icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${body}</svg>`;
|
|
312
|
+
|
|
313
|
+
/** Simple line icons drawn for the pack (not the services' logos), with the service's name always next to them. */
|
|
314
|
+
export const PODCAST_ICONS: Record<PodcastService, string> = {
|
|
315
|
+
apple: svg('<circle cx="12" cy="10" r="2.2" fill="currentColor" stroke="none"/><path d="M12 14v7"/><path d="M8.2 15.6a5.6 5.6 0 1 1 7.6 0"/><path d="M5.6 18.6a9.2 9.2 0 1 1 12.8 0"/>'),
|
|
316
|
+
spotify: svg('<circle cx="12" cy="12" r="9.5"/><path d="M7 9.6c3.4-1 6.9-.7 10 1"/><path d="M7.6 12.9c2.9-.8 5.7-.5 8.2.8"/><path d="M8.2 16c2.3-.6 4.4-.4 6.4.6"/>'),
|
|
317
|
+
youtube: svg('<rect x="2.5" y="5.5" width="19" height="13" rx="4"/><path d="M10 9.3v5.4l4.6-2.7z" fill="currentColor"/>'),
|
|
318
|
+
amazon: svg('<path d="M9 17V5.5l10-2V15"/><circle cx="6.5" cy="17" r="2.5"/><circle cx="16.5" cy="15" r="2.5"/>'),
|
|
319
|
+
overcast: svg('<circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="11" r="1.4" fill="currentColor" stroke="none"/><path d="M12 13l-2.4 6.6M12 13l2.4 6.6"/><path d="M9.2 8.2a4 4 0 0 0 0 5.6M14.8 8.2a4 4 0 0 1 0 5.6"/>'),
|
|
320
|
+
pocketCasts: svg('<circle cx="12" cy="12" r="9.5"/><path d="M12 17.5a5.5 5.5 0 1 1 5.5-5.5"/><path d="M12 14.5a2.5 2.5 0 1 1 2.5-2.5"/>'),
|
|
321
|
+
rss: svg('<circle cx="5.5" cy="18.5" r="1.6" fill="currentColor" stroke="none"/><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** A labelled `<section>` with a heading and a list of links, each named by its service. Empty when there are no links. */
|
|
325
|
+
export function renderPodcastHtml(p: Podcast, s: PodcastSettings, id: string): string {
|
|
326
|
+
const links = p.source === "block" ? p.links : s.links;
|
|
327
|
+
const items = PODCAST_SERVICES.filter(([svc]) => links[svc]).map(
|
|
328
|
+
([svc, label]) =>
|
|
329
|
+
`<li><a class="cw-podcast__link cw-podcast__link--${svc}" href="${escapeHtml(links[svc])}"${svc === "rss" ? ' type="application/rss+xml"' : ""}>${
|
|
330
|
+
s.showIcons ? PODCAST_ICONS[svc] : ""
|
|
331
|
+
}<span>${escapeHtml(label)}</span></a></li>`,
|
|
332
|
+
);
|
|
333
|
+
if (!items.length) return "";
|
|
334
|
+
const tag = s.headingTag;
|
|
335
|
+
const heading = escapeHtml(p.heading || s.heading || DEFAULT_PODCAST.heading);
|
|
336
|
+
return `<section class="cw-podcast" aria-labelledby="${escapeHtml(id)}"><${tag} class="cw-podcast__title" id="${escapeHtml(id)}">${heading}</${tag}><ul class="cw-podcast__links" role="list">${items.join("")}</ul></section>`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── CSS ──────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* One stylesheet for all the blocks, printed once per page before the first
|
|
343
|
+
* one. Theme-agnostic: colors are mixed from the text color (currentColor), so
|
|
344
|
+
* the blocks follow the theme's light or dark text; each variant adds a hue
|
|
345
|
+
* through a custom property a site can override (--cw-note-accent, …).
|
|
346
|
+
*/
|
|
347
|
+
export const CUSTOM_BLOCKS_CSS = `.cw-note,.cw-details,.cw-disclosure,.cw-quote,.cw-testimonial,.cw-podcast{box-sizing:border-box;margin-block:1.5em}
|
|
348
|
+
.cw-note{--cw-note-accent:#2f6fde;--cw-note-mix:9%;padding:1em 1.25em;border:1px solid color-mix(in srgb,var(--cw-note-accent) 35%,transparent);border-inline-start:4px solid var(--cw-note-accent);border-radius:.5em;background:color-mix(in srgb,var(--cw-note-accent) var(--cw-note-mix),transparent)}
|
|
349
|
+
.cw-note--editor{--cw-note-accent:#8a6a00}
|
|
350
|
+
.cw-note--tip{--cw-note-accent:#1f8a4c}
|
|
351
|
+
.cw-note--warning{--cw-note-accent:#c2410c}
|
|
352
|
+
@media (prefers-color-scheme:dark){.cw-note{--cw-note-mix:16%}.cw-note--editor{--cw-note-accent:#d9b22b}.cw-note--note{--cw-note-accent:#6aa0ff}.cw-note--tip{--cw-note-accent:#4cc27e}.cw-note--warning{--cw-note-accent:#f08a4b}}
|
|
353
|
+
.cw-note .cw-note__title{margin:0 0 .4em;padding:0;font-size:1em;font-weight:700;line-height:1.35}
|
|
354
|
+
.cw-note .cw-note__body>:first-child{margin-top:0}
|
|
355
|
+
.cw-note .cw-note__body>:last-child{margin-bottom:0}
|
|
356
|
+
.cw-details{--cw-details-line:color-mix(in srgb,currentColor 18%,transparent);border:1px solid var(--cw-details-line);border-radius:.5em;padding:0 1.25em}
|
|
357
|
+
.cw-details .cw-details__summary{cursor:pointer;padding:.75em 0;font-weight:600}
|
|
358
|
+
.cw-details .cw-details__summary:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:.25em}
|
|
359
|
+
.cw-details[open]>.cw-details__summary{border-bottom:1px solid var(--cw-details-line);margin-bottom:.75em}
|
|
360
|
+
.cw-details .cw-details__body{padding-bottom:.75em}
|
|
361
|
+
.cw-details .cw-details__body>:first-child{margin-top:0}
|
|
362
|
+
.cw-details--transcript .cw-details__body{padding:.75em 1em;margin-bottom:.75em;border-radius:.375em;background:color-mix(in srgb,currentColor 5%,transparent)}
|
|
363
|
+
.cw-disclosure .cw-disclosure__text{margin:0;font-size:.875em;line-height:1.5;color:color-mix(in srgb,currentColor 82%,transparent)}
|
|
364
|
+
.cw-disclosure a{color:inherit;text-decoration-thickness:1px;text-underline-offset:.15em}
|
|
365
|
+
.cw-quote{margin-inline:0}
|
|
366
|
+
.cw-quote .cw-quote__text{margin:0;padding:.25em 0 .25em 1.25em;border-inline-start:4px solid color-mix(in srgb,currentColor 30%,transparent);font-size:1.05em}
|
|
367
|
+
.cw-quote .cw-quote__text>:first-child{margin-top:0}
|
|
368
|
+
.cw-quote .cw-quote__text>:last-child{margin-bottom:0}
|
|
369
|
+
.cw-quote .cw-quote__caption{margin:.6em 0 0 1.5em;font-size:.9em;color:color-mix(in srgb,currentColor 82%,transparent)}
|
|
370
|
+
.cw-quote .cw-quote__caption::before{content:"— "}
|
|
371
|
+
.cw-quote .cw-quote__caption cite{font-style:normal}
|
|
372
|
+
.cw-testimonial{margin-inline:0;text-align:start}
|
|
373
|
+
.cw-testimonial .cw-testimonial__quote{position:relative;margin:0 0 1.4em;padding:1em 1.25em;border:0;border-radius:.75em;background:color-mix(in srgb,currentColor 6%,transparent);font-size:1.05em}
|
|
374
|
+
.cw-testimonial .cw-testimonial__quote::after{content:"";position:absolute;top:100%;left:1.75em;width:1.1em;height:.7em;background:inherit;clip-path:polygon(0 0,100% 0,0 100%)}
|
|
375
|
+
.cw-testimonial .cw-testimonial__quote>:first-child{margin-top:0}
|
|
376
|
+
.cw-testimonial .cw-testimonial__quote>:last-child{margin-bottom:0}
|
|
377
|
+
.cw-testimonial .cw-testimonial__quote>:first-child::before{content:"\\201C";content:"\\201C"/""}
|
|
378
|
+
.cw-testimonial .cw-testimonial__quote>:last-child::after{content:"\\201D";content:"\\201D"/""}
|
|
379
|
+
.cw-testimonial .cw-testimonial__person{display:flex;align-items:center;gap:.75em;margin:0 0 0 1em;font-size:.9em;line-height:1.35}
|
|
380
|
+
.cw-testimonial .cw-testimonial__person picture{display:contents}
|
|
381
|
+
.cw-testimonial .cw-testimonial__photo{flex:none;width:3.5em;height:3.5em;margin:0;border-radius:50%;object-fit:cover;background:color-mix(in srgb,currentColor 12%,transparent)}
|
|
382
|
+
.cw-testimonial .cw-testimonial__name{display:block;font-weight:700;text-transform:uppercase;letter-spacing:.02em}
|
|
383
|
+
.cw-testimonial .cw-testimonial__title{display:block;color:color-mix(in srgb,currentColor 82%,transparent)}
|
|
384
|
+
.cw-testimonial .cw-testimonial__person a{color:inherit;text-decoration-thickness:1px;text-underline-offset:.15em}
|
|
385
|
+
.cw-podcast .cw-podcast__title{margin:0 0 .6em;font-weight:700}
|
|
386
|
+
.cw-podcast p.cw-podcast__title{font-size:1em}
|
|
387
|
+
.cw-podcast .cw-podcast__links{display:flex;flex-wrap:wrap;gap:.5em;margin:0;padding:0;list-style:none}
|
|
388
|
+
.cw-podcast .cw-podcast__links li{margin:0;padding:0}
|
|
389
|
+
.cw-podcast .cw-podcast__link{display:inline-flex;align-items:center;gap:.45em;padding:.4em .9em;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:999px;color:inherit;font-size:.95em;line-height:1.3;text-decoration:none}
|
|
390
|
+
.cw-podcast .cw-podcast__link:hover{background:color-mix(in srgb,currentColor 7%,transparent)}
|
|
391
|
+
.cw-podcast .cw-podcast__link:focus-visible{outline:2px solid currentColor;outline-offset:2px}
|
|
392
|
+
.cw-podcast .cw-podcast__icon{flex:none;width:1.15em;height:1.15em}`;
|
|
393
|
+
|
|
394
|
+
// ── Samples for the admin preview ────────────────────────────────
|
|
395
|
+
|
|
396
|
+
export const SAMPLE_TESTIMONIAL: Testimonial = {
|
|
397
|
+
quote: "Clear, practical and always worth the read. I learn something new every time.",
|
|
398
|
+
name: "Alex Rivera",
|
|
399
|
+
title: "Head of Content, Example Co.",
|
|
400
|
+
photo: "",
|
|
401
|
+
nameUrl: "https://example.com/alex/",
|
|
402
|
+
titleUrl: "",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
export const SAMPLE_NOTE: Note = {
|
|
406
|
+
variant: "note",
|
|
407
|
+
title: "",
|
|
408
|
+
hideTitle: false,
|
|
409
|
+
titleTag: "p",
|
|
410
|
+
body: "Notes stand apart from the article. Use them for an aside, an update, or a tip, with **bold** and [links](https://example.com/).",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const SAMPLE_DETAILS: Details = {
|
|
414
|
+
variant: "details",
|
|
415
|
+
summary: "What's included?",
|
|
416
|
+
body: "Everything in the box: the device, a charging cable and a quick start guide.\n\nOpen and close it with a click or the keyboard.",
|
|
417
|
+
open: true,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
export const SAMPLE_QUOTE: Quote = {
|
|
421
|
+
quote: "Make it work, make it right, make it fast.",
|
|
422
|
+
citation: "Kent Beck",
|
|
423
|
+
sourceUrl: "",
|
|
424
|
+
};
|