@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,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn imported WordPress blocks into native Coywolf Pack blocks, in stored
|
|
3
|
+
* Portable Text. Reads:
|
|
4
|
+
*
|
|
5
|
+
* - markers written by the prepare step (./prepare.ts, ./markers.ts);
|
|
6
|
+
* - the `data-wb-block` markers an earlier, site-specific import script wrote
|
|
7
|
+
* (wellbeing.io: cloudflare-stream, review);
|
|
8
|
+
* - what EmDash's importer makes of Coywolf blocks with saved HTML when the
|
|
9
|
+
* export wasn't prepared (`htmlBlock` with `originalBlockName` and
|
|
10
|
+
* `originalAttrs`: coywolf/video, coywolf/file).
|
|
11
|
+
*
|
|
12
|
+
* For any WordPress site it moves "anchor" markers (heading ids) onto the
|
|
13
|
+
* next heading's `anchor` field, "table-caption" markers onto the table
|
|
14
|
+
* before them (its `caption` field; EmDash's editor keeps the field when it
|
|
15
|
+
* saves), turns core Details markers into
|
|
16
|
+
* `coywolf-details`, and maps Prism language names on code blocks to the
|
|
17
|
+
* editor's (markup → html). For content from Coywolf's WordPress plugins it
|
|
18
|
+
* produces `coywolf-video`, `coywolf-review`, `coywolf-toc` and
|
|
19
|
+
* `coywolf-file` blocks, plus `coywolf-note`, `coywolf-details`,
|
|
20
|
+
* `coywolf-quote`, `coywolf-disclosure`, `coywolf-testimonial` and
|
|
21
|
+
* `coywolf-podcast` (Custom Blocks, each only while its switch is on). Other
|
|
22
|
+
* template markers (related links, Gravity Forms) stay HTML blocks. Anything
|
|
23
|
+
* else is left exactly as it is, and running it again changes nothing.
|
|
24
|
+
*
|
|
25
|
+
* Pure, no I/O.
|
|
26
|
+
*/
|
|
27
|
+
import { ratingValue } from "../reviews/lib.js";
|
|
28
|
+
import { type Marker, parseMarker } from "./markers.js";
|
|
29
|
+
import { type StreamEmbed, decodeHtml, textOf } from "./stream.js";
|
|
30
|
+
|
|
31
|
+
/** Video Manager's site-wide defaults (WordPress option coywolf_cvm_settings). Blocks without a value used these. */
|
|
32
|
+
export interface VideoDefaults {
|
|
33
|
+
controls: boolean;
|
|
34
|
+
autoplay: boolean;
|
|
35
|
+
loop: boolean;
|
|
36
|
+
mute: boolean;
|
|
37
|
+
preload: "auto" | "metadata" | "none";
|
|
38
|
+
showName: boolean;
|
|
39
|
+
showDescription: boolean;
|
|
40
|
+
showDate: boolean;
|
|
41
|
+
showPlays: boolean;
|
|
42
|
+
showLikes: boolean;
|
|
43
|
+
showLikeCount: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The Video Manager plugin's own defaults, used when the site's settings weren't pasted on the import page. */
|
|
47
|
+
export const VIDEO_MANAGER_DEFAULTS: VideoDefaults = {
|
|
48
|
+
controls: true,
|
|
49
|
+
autoplay: false,
|
|
50
|
+
loop: false,
|
|
51
|
+
mute: false,
|
|
52
|
+
preload: "metadata",
|
|
53
|
+
showName: true,
|
|
54
|
+
showDescription: true,
|
|
55
|
+
showDate: true,
|
|
56
|
+
showPlays: true,
|
|
57
|
+
showLikes: true,
|
|
58
|
+
showLikeCount: true,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Coywolf Files' card defaults (WordPress option coywolf_files_settings). */
|
|
62
|
+
export interface FileDefaults {
|
|
63
|
+
showIcon: boolean;
|
|
64
|
+
showDescription: boolean;
|
|
65
|
+
showMeta: boolean;
|
|
66
|
+
showDownload: boolean;
|
|
67
|
+
showCopyLink: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const FILES_DEFAULTS: FileDefaults = { showIcon: true, showDescription: true, showMeta: true, showDownload: true, showCopyLink: true };
|
|
71
|
+
|
|
72
|
+
/** Which Custom Blocks are on. A block that's off isn't converted to (its marker stays an HTML block until it's on). */
|
|
73
|
+
export interface CustomBlockSwitches {
|
|
74
|
+
note: boolean;
|
|
75
|
+
details: boolean;
|
|
76
|
+
disclosure: boolean;
|
|
77
|
+
quote: boolean;
|
|
78
|
+
testimonial: boolean;
|
|
79
|
+
podcast: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ConvertOptions {
|
|
83
|
+
videoDefaults?: Partial<VideoDefaults>;
|
|
84
|
+
fileDefaults?: Partial<FileDefaults>;
|
|
85
|
+
/** Defaults to all on. */
|
|
86
|
+
customBlocks?: Partial<CustomBlockSwitches>;
|
|
87
|
+
/** @deprecated The 0.10/0.11 name of `customBlocks`. */
|
|
88
|
+
contentBlocks?: Partial<CustomBlockSwitches>;
|
|
89
|
+
/** Key generator for new blocks (defaults to a random one). */
|
|
90
|
+
key?: () => string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Facts about a video learned from WordPress, for the Videos module's per-video data. */
|
|
94
|
+
export interface VideoFact {
|
|
95
|
+
uid: string;
|
|
96
|
+
name?: string;
|
|
97
|
+
duration?: number;
|
|
98
|
+
created?: string;
|
|
99
|
+
width?: number;
|
|
100
|
+
height?: number;
|
|
101
|
+
host?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface Change {
|
|
105
|
+
/** What it was: a marker name or WordPress block name. */
|
|
106
|
+
from: string;
|
|
107
|
+
/** What it became: a block type, "anchor", or "removed". */
|
|
108
|
+
to: string;
|
|
109
|
+
detail?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ConvertResult<T> {
|
|
113
|
+
value: T;
|
|
114
|
+
changed: boolean;
|
|
115
|
+
changes: Change[];
|
|
116
|
+
videos: VideoFact[];
|
|
117
|
+
/** Imported blocks left as HTML, by original block or marker name (for the report). */
|
|
118
|
+
leftovers: Record<string, number>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Block = Record<string, unknown> & { _type?: unknown; _key?: unknown };
|
|
122
|
+
|
|
123
|
+
const UID = /^[0-9a-f]{32}$/;
|
|
124
|
+
const str = (v: unknown) => (typeof v === "string" ? v.trim() : typeof v === "number" && Number.isFinite(v) ? String(v) : "");
|
|
125
|
+
const num = (v: unknown): number | undefined => {
|
|
126
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : Number.NaN;
|
|
127
|
+
return Number.isFinite(n) ? n : undefined;
|
|
128
|
+
};
|
|
129
|
+
const bool = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
|
|
130
|
+
|
|
131
|
+
function randomKey(): string {
|
|
132
|
+
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
133
|
+
return [...bytes].map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 12);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Videos ───────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const HEIGHT_FOR = (pct: number) => Math.round((1920 * pct) / 100);
|
|
139
|
+
|
|
140
|
+
/** Seconds from the cloudflare-stream block's hours / minutes / seconds fields. */
|
|
141
|
+
export function streamSeconds(a: Record<string, unknown>): number | undefined {
|
|
142
|
+
const total = (num(a["cs-hours"]) ?? 0) * 3600 + (num(a["cs-minutes"]) ?? 0) * 60 + (num(a["cs-seconds"]) ?? 0);
|
|
143
|
+
return total > 0 ? total : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function embedFields(embed: StreamEmbed | null | undefined, block: Block): void {
|
|
147
|
+
if (!embed) return;
|
|
148
|
+
block.controls = embed.controls;
|
|
149
|
+
block.autoplay = embed.autoplay;
|
|
150
|
+
block.loop = embed.loop;
|
|
151
|
+
block.muted = embed.muted || embed.autoplay;
|
|
152
|
+
if (embed.preload) block.preload = embed.preload;
|
|
153
|
+
if (embed.posterTime !== null && embed.posterTime >= 0) block.posterTime = embed.posterTime;
|
|
154
|
+
if (embed.startTime) block.startTime = embed.startTime;
|
|
155
|
+
if (embed.maxWidth) {
|
|
156
|
+
block.sizeMode = "maxwidth";
|
|
157
|
+
block.maxWidth = embed.maxWidth;
|
|
158
|
+
}
|
|
159
|
+
if (embed.aspect) block.aspect = Math.round(embed.aspect * 1000) / 1000;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** coywolf-custom-blocks/cloudflare-stream (+ its embed), a stand-alone embed, or a wellbeing marker → coywolf-video. */
|
|
163
|
+
function streamVideo(attrs: Record<string, unknown>, embed: StreamEmbed | null): { block: Block; fact: VideoFact } | null {
|
|
164
|
+
const uid = (str(attrs["cs-id"]) || embed?.uid || "").toLowerCase();
|
|
165
|
+
if (!UID.test(uid)) return null;
|
|
166
|
+
const caption = embed?.caption ?? null;
|
|
167
|
+
const block: Block = {
|
|
168
|
+
_type: "coywolf-video",
|
|
169
|
+
uid,
|
|
170
|
+
preset: "standard",
|
|
171
|
+
title: str(attrs["cs-name"]) || embed?.title || undefined,
|
|
172
|
+
caption: caption ?? (str(attrs["cs-description"]) || undefined),
|
|
173
|
+
showName: "hide",
|
|
174
|
+
showDescription: caption ? "show" : "hide",
|
|
175
|
+
showPlays: "hide",
|
|
176
|
+
showLikes: "hide",
|
|
177
|
+
showDate: "hide",
|
|
178
|
+
};
|
|
179
|
+
embedFields(embed, block);
|
|
180
|
+
const host = embed?.host ?? (/^customer-[a-z0-9]+\.cloudflarestream\.com$/.test(str(attrs["cs-subdomain"])) ? str(attrs["cs-subdomain"]) : undefined);
|
|
181
|
+
const fact: VideoFact = { uid, name: str(attrs["cs-name"]) || undefined, duration: streamSeconds(attrs) };
|
|
182
|
+
if (embed?.aspect) {
|
|
183
|
+
fact.width = 1920;
|
|
184
|
+
fact.height = HEIGHT_FOR(embed.aspect);
|
|
185
|
+
}
|
|
186
|
+
if (host) fact.host = host;
|
|
187
|
+
return { block, fact };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** wellbeing.io's cloudflare-stream marker ({ id, host, aspect, name, description, seconds }): it played like a GIF. */
|
|
191
|
+
function wellbeingVideo(a: Record<string, unknown>): { block: Block; fact: VideoFact } | null {
|
|
192
|
+
const uid = str(a.id).toLowerCase();
|
|
193
|
+
if (!UID.test(uid)) return null;
|
|
194
|
+
const aspect = num(a.aspect);
|
|
195
|
+
const block: Block = { _type: "coywolf-video", uid, preset: "gif", title: str(a.name) || undefined, caption: str(a.description) || undefined };
|
|
196
|
+
if (aspect) block.aspect = aspect;
|
|
197
|
+
const fact: VideoFact = { uid, name: str(a.name) || undefined, duration: num(a.seconds) };
|
|
198
|
+
if (aspect) {
|
|
199
|
+
fact.width = 1920;
|
|
200
|
+
fact.height = HEIGHT_FOR(aspect);
|
|
201
|
+
}
|
|
202
|
+
if (/^customer-[a-z0-9]+\.cloudflarestream\.com$/.test(str(a.host))) fact.host = str(a.host);
|
|
203
|
+
return { block, fact };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A show/hide attribute: "show"/"hide" when the WordPress block set it.
|
|
208
|
+
* Unset, WordPress used its site setting, as the Videos module does: left
|
|
209
|
+
* unset (the Videos site default, on unless changed), or "hide" when the
|
|
210
|
+
* WordPress site had it off, so the page looks as it did.
|
|
211
|
+
*/
|
|
212
|
+
const showAttr = (value: unknown, wpDefault: boolean): "show" | "hide" | undefined => {
|
|
213
|
+
const v = bool(value);
|
|
214
|
+
if (v !== undefined) return v ? "show" : "hide";
|
|
215
|
+
return wpDefault ? undefined : "hide";
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Video Manager's coywolf/video → coywolf-video, applying the site defaults where the block had no value. */
|
|
219
|
+
function managerVideo(a: Record<string, unknown>, d: VideoDefaults): { block: Block; fact: VideoFact } | null {
|
|
220
|
+
const uid = str(a.videoId).toLowerCase();
|
|
221
|
+
if (!UID.test(uid)) return null;
|
|
222
|
+
const autoplay = bool(a.autoplay) ?? d.autoplay;
|
|
223
|
+
const preload = str(a.preload);
|
|
224
|
+
const block: Block = {
|
|
225
|
+
_type: "coywolf-video",
|
|
226
|
+
uid,
|
|
227
|
+
preset: "standard",
|
|
228
|
+
title: str(a.videoName) || undefined,
|
|
229
|
+
caption: textOf(str(a.videoDescription)) || undefined,
|
|
230
|
+
controls: bool(a.controls) ?? d.controls,
|
|
231
|
+
autoplay,
|
|
232
|
+
loop: bool(a.loop) ?? d.loop,
|
|
233
|
+
muted: (bool(a.mute) ?? d.mute) || autoplay,
|
|
234
|
+
preload: preload === "auto" || preload === "none" || preload === "metadata" ? preload : d.preload,
|
|
235
|
+
};
|
|
236
|
+
const shows: Array<[string, unknown, boolean]> = [
|
|
237
|
+
["showName", a.showName, d.showName],
|
|
238
|
+
["showDescription", a.showDescription, d.showDescription],
|
|
239
|
+
["showDate", a.showDate, d.showDate],
|
|
240
|
+
["showPlays", a.showPlays, d.showPlays],
|
|
241
|
+
["showLikes", a.enableLikes, d.showLikes],
|
|
242
|
+
["showLikeCount", a.showLikeCount, d.showLikeCount],
|
|
243
|
+
];
|
|
244
|
+
for (const [key, value, wpDefault] of shows) {
|
|
245
|
+
const show = showAttr(value, wpDefault);
|
|
246
|
+
if (show) block[key] = show;
|
|
247
|
+
}
|
|
248
|
+
// Per-video look, only where the WordPress block set it (else it followed the site settings).
|
|
249
|
+
for (const key of ["contentAlign", "metaAlign"]) if (a[key] === "left" || a[key] === "center" || a[key] === "right") block[key] = a[key];
|
|
250
|
+
const radius = num(a.radius);
|
|
251
|
+
if (radius !== undefined) block.radius = Math.max(0, Math.min(48, Math.round(radius)));
|
|
252
|
+
const border = bool(a.showBorder);
|
|
253
|
+
if (border !== undefined) block.showBorder = border ? "show" : "hide";
|
|
254
|
+
const borderWidth = num(a.borderWidth);
|
|
255
|
+
if (borderWidth !== undefined) block.borderWidth = Math.max(0, Math.min(20, Math.round(borderWidth)));
|
|
256
|
+
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(str(a.borderColor))) block.borderColor = str(a.borderColor);
|
|
257
|
+
const posterTime = num(a.posterTime);
|
|
258
|
+
if (posterTime && posterTime > 0) block.posterTime = posterTime;
|
|
259
|
+
const startTime = num(a.startTime);
|
|
260
|
+
if (startTime && startTime > 0) block.startTime = startTime;
|
|
261
|
+
if (a.sizeMode === "maxwidth") {
|
|
262
|
+
block.sizeMode = "maxwidth";
|
|
263
|
+
block.maxWidth = num(a.maxWidth) ?? 800;
|
|
264
|
+
}
|
|
265
|
+
const ratio = num(a.aspectRatio);
|
|
266
|
+
if (ratio && ratio > 0) block.aspect = Math.round(ratio * 100_000) / 1000;
|
|
267
|
+
const fact: VideoFact = { uid, name: str(a.videoName) || undefined, duration: num(a.duration) };
|
|
268
|
+
if (ratio && ratio > 0) {
|
|
269
|
+
fact.width = 1920;
|
|
270
|
+
fact.height = Math.round(1920 * ratio);
|
|
271
|
+
}
|
|
272
|
+
const uploaded = str(a.uploaded);
|
|
273
|
+
if (uploaded && !Number.isNaN(Date.parse(uploaded))) fact.created = uploaded;
|
|
274
|
+
return { block, fact };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Reviews ──────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** Text of each <li> (or line) in a WordPress list field. */
|
|
280
|
+
export function listItems(html: unknown): string[] {
|
|
281
|
+
if (typeof html !== "string" || !html.trim()) return [];
|
|
282
|
+
const source = html.slice(0, 50_000);
|
|
283
|
+
const items = [...source.matchAll(/<li\b[^>]*>([\s\S]*?)(?=<\/li\s*>|<li\b|<\/[uo]l\s*>|$)/gi)].map((m) => textOf(m[1] as string));
|
|
284
|
+
const list = items.length ? items : source.split(/<br\s*\/?>|<\/p\s*>|\n/i).map((t) => textOf(t));
|
|
285
|
+
return list.filter(Boolean);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const SCHEMA_TYPES: Record<string, string> = { product: "Product", software: "SoftwareApplication", softwareapplication: "SoftwareApplication", book: "Book" };
|
|
289
|
+
|
|
290
|
+
/** `"macOS","Windows"` (the WordPress field held a JSON array body) → "macOS, Windows". */
|
|
291
|
+
export function operatingSystems(value: unknown): string {
|
|
292
|
+
const text = str(value);
|
|
293
|
+
if (!text) return "";
|
|
294
|
+
try {
|
|
295
|
+
const list = JSON.parse(`[${text}]`) as unknown;
|
|
296
|
+
if (Array.isArray(list)) return list.map((v) => str(v)).filter(Boolean).join(", ");
|
|
297
|
+
} catch {
|
|
298
|
+
// Not JSON: use it as written.
|
|
299
|
+
}
|
|
300
|
+
return text.replace(/"/g, "").replace(/\s*,\s*/g, ", ");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Coywolf Custom Blocks' review (or wellbeing's review marker) → coywolf-review. */
|
|
304
|
+
export function reviewBlock(a: Record<string, unknown>): Block | null {
|
|
305
|
+
const name = textOf(str(a.name));
|
|
306
|
+
if (!name && !str(a.rating)) return null;
|
|
307
|
+
const explicit = SCHEMA_TYPES[str(a["schema-type"]).toLowerCase()];
|
|
308
|
+
const isBook = Boolean(str(a.author) || str(a.isbn) || str(a.publisher) || str(a.genre));
|
|
309
|
+
const isSoftware = Boolean(operatingSystems(a.os) || str(a.category));
|
|
310
|
+
// WordPress picked the type from the post's category when the field was left at its default.
|
|
311
|
+
const itemType = explicit && explicit !== "Product" ? explicit : isBook ? "Book" : isSoftware ? "SoftwareApplication" : "Product";
|
|
312
|
+
const block: Block = {
|
|
313
|
+
_type: "coywolf-review",
|
|
314
|
+
itemName: name,
|
|
315
|
+
itemType,
|
|
316
|
+
// Exact, to one decimal (4.7 stays 4.7), in the block's menu format.
|
|
317
|
+
rating: ratingValue(a.rating),
|
|
318
|
+
pros: listItems(a.strengths).join("\n"),
|
|
319
|
+
cons: listItems(a.shortcomings).join("\n"),
|
|
320
|
+
headingLevel: "2",
|
|
321
|
+
};
|
|
322
|
+
const brand = textOf(str(a.brand));
|
|
323
|
+
if (brand) block.brand = brand;
|
|
324
|
+
const url = str(a.id) || str(a.url);
|
|
325
|
+
if (/^https?:\/\/\S+$/i.test(url)) block.itemUrl = url;
|
|
326
|
+
if (itemType === "Book") {
|
|
327
|
+
if (str(a.author)) block.bookAuthor = textOf(str(a.author));
|
|
328
|
+
if (str(a.isbn)) block.isbn = str(a.isbn);
|
|
329
|
+
if (str(a.publisher)) block.bookPublisher = textOf(str(a.publisher));
|
|
330
|
+
if (str(a.genre)) block.genre = textOf(str(a.genre));
|
|
331
|
+
if (num(a.copyright)) block.copyrightYear = String(num(a.copyright));
|
|
332
|
+
}
|
|
333
|
+
if (itemType === "SoftwareApplication") {
|
|
334
|
+
const os = operatingSystems(a.os);
|
|
335
|
+
if (os) block.operatingSystem = os;
|
|
336
|
+
if (str(a.category)) block.applicationCategory = textOf(str(a.category));
|
|
337
|
+
}
|
|
338
|
+
return block;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Table of contents ────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/** Coywolf SEO's table-of-contents attributes → coywolf-toc (WordPress defaults made explicit). */
|
|
344
|
+
export function tocBlock(a: Record<string, unknown>): Block {
|
|
345
|
+
const levels = Array.isArray(a.levels) ? a.levels.map((n) => Number(n)).filter((n) => Number.isInteger(n) && n >= 2 && n <= 6) : [];
|
|
346
|
+
const style = str(a.listStyle);
|
|
347
|
+
const block: Block = {
|
|
348
|
+
_type: "coywolf-toc",
|
|
349
|
+
levels: (levels.length ? levels : [2, 3]).map(String),
|
|
350
|
+
listStyle: style === "disc" ? "bulleted" : style === "decimal" ? "numbered" : "none",
|
|
351
|
+
display: a.initiallyCollapsed === true ? "collapsed" : a.collapsible === true ? "collapsible" : "open",
|
|
352
|
+
showTitle: a.showTitle === false ? "hide" : "show",
|
|
353
|
+
};
|
|
354
|
+
const title = textOf(str(a.title));
|
|
355
|
+
if (title) block.title = title;
|
|
356
|
+
return block;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Files ────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/** Coywolf Files' coywolf/file → coywolf-file. The id stays the WordPress file id (see docs/wordpress-import.md for moving the file). */
|
|
362
|
+
export function fileBlock(a: Record<string, unknown>, d: FileDefaults): Block | null {
|
|
363
|
+
const id = str(a.fileId);
|
|
364
|
+
if (!/^[0-9a-z]{6,40}$/i.test(id)) return null;
|
|
365
|
+
const block: Block = {
|
|
366
|
+
_type: "coywolf-file",
|
|
367
|
+
id,
|
|
368
|
+
showIcon: bool(a.showIcon) ?? d.showIcon,
|
|
369
|
+
showDescription: bool(a.showDescription) ?? d.showDescription,
|
|
370
|
+
showMeta: bool(a.showMeta) ?? d.showMeta,
|
|
371
|
+
showDownload: bool(a.showDownload) ?? d.showDownload,
|
|
372
|
+
showCopyLink: bool(a.showCopyLink) ?? d.showCopyLink,
|
|
373
|
+
};
|
|
374
|
+
const title = textOf(str(a.title));
|
|
375
|
+
if (title) block.title = title;
|
|
376
|
+
const description = textOf(str(a.description));
|
|
377
|
+
if (description) block.description = description;
|
|
378
|
+
return block;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Notes, details, quotes, disclosures (Custom Blocks) ─────────
|
|
382
|
+
|
|
383
|
+
const NOTE_TITLES: Record<string, string> = { sidenote: "\u{1F4CC} Sidenote", editorsnote: "\u{1F4DD} Editor's Note" };
|
|
384
|
+
|
|
385
|
+
const unescapeAttr = (v: string) => v.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
386
|
+
|
|
387
|
+
/** Coywolf Custom Blocks' sidenote / editor's note → coywolf-note, with WordPress's title (an H2 with an emoji). */
|
|
388
|
+
export function noteBlock(kind: "sidenote" | "editorsnote", attrs: Record<string, unknown>, inner = ""): Block | null {
|
|
389
|
+
// Markers from 0.10.0 had no fields: read the body back from the fallback HTML.
|
|
390
|
+
const text = typeof attrs.text === "string" ? attrs.text : inner.match(/^\s*<aside class="sidenote[^"]*"><h2>[\s\S]*?<\/h2>([\s\S]*)<\/aside>\s*$/)?.[1];
|
|
391
|
+
if (typeof text !== "string" || !text.trim()) return null;
|
|
392
|
+
return { _type: "coywolf-note", variant: kind === "editorsnote" ? "editor" : "note", title: NOTE_TITLES[kind], hideTitle: false, titleTag: "h2", body: text.trim() };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Transcript, accordion or core Details → coywolf-details. */
|
|
396
|
+
export function detailsBlock(kind: "transcript" | "accordion" | "details", attrs: Record<string, unknown>, inner = ""): Block | null {
|
|
397
|
+
let summary = typeof attrs.summary === "string" ? attrs.summary : null;
|
|
398
|
+
let body = typeof attrs.body === "string" ? attrs.body : null;
|
|
399
|
+
let open = attrs.open === true;
|
|
400
|
+
if (summary === null || body === null) {
|
|
401
|
+
const m =
|
|
402
|
+
inner.match(/^\s*<details class="(?:transcript|accordion)"><summary>([\s\S]*?)<\/summary><div class="(?:transcript|accordion)__body">([\s\S]*)<\/div><\/details>\s*$/) ??
|
|
403
|
+
inner.match(/^\s*<details\b([^>]*)>\s*<summary\b[^>]*>([\s\S]*?)<\/summary>([\s\S]*)<\/details>\s*$/);
|
|
404
|
+
if (!m) return null;
|
|
405
|
+
if (m.length === 3) [summary, body] = [m[1] as string, m[2] as string];
|
|
406
|
+
else {
|
|
407
|
+
[summary, body] = [m[2] as string, m[3] as string];
|
|
408
|
+
open = /\sopen\b/.test(m[1] as string);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!summary.trim() && !body.trim()) return null;
|
|
412
|
+
const block: Block = { _type: "coywolf-details", variant: kind === "transcript" ? "transcript" : "details", summary: summary.trim(), body: body.trim() };
|
|
413
|
+
if (open) block.open = true;
|
|
414
|
+
return block;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Coywolf Custom Blocks' blockquote → coywolf-quote (quote, who said it, source URL). */
|
|
418
|
+
export function quoteBlock(attrs: Record<string, unknown>, inner = ""): Block | null {
|
|
419
|
+
let quote = typeof attrs.quote === "string" ? attrs.quote : null;
|
|
420
|
+
let cite = typeof attrs.cite === "string" ? attrs.cite : "";
|
|
421
|
+
let url = typeof attrs.url === "string" ? attrs.url : "";
|
|
422
|
+
if (quote === null) {
|
|
423
|
+
const m = inner.match(/^\s*<figure class="wp-custom-blockquote"><blockquote(?: cite="([^"]*)")?>([\s\S]*?)<\/blockquote>(?:<figcaption><cite>([\s\S]*?)<\/cite><\/figcaption>)?<\/figure>\s*$/);
|
|
424
|
+
if (!m) return null;
|
|
425
|
+
[url, quote, cite] = [unescapeAttr(m[1] ?? ""), m[2] as string, m[3] ?? ""];
|
|
426
|
+
}
|
|
427
|
+
if (!quote.trim()) return null;
|
|
428
|
+
const block: Block = { _type: "coywolf-quote", quote: quote.trim() };
|
|
429
|
+
if (cite.trim()) block.citation = cite.trim();
|
|
430
|
+
if (/^https?:\/\/\S+$/i.test(url.trim())) block.sourceUrl = url.trim();
|
|
431
|
+
return block;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** ftc / Genesis disclosure → an affiliate disclosure; amazon → the Amazon Associates one. Both use the site's wording. */
|
|
435
|
+
export function disclosureBlock(attrs: Record<string, unknown>): Block {
|
|
436
|
+
return { _type: "coywolf-disclosure", kind: attrs.block === "coywolf-custom-blocks/amazon" ? "amazon" : "affiliate" };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Coywolf Custom Blocks' testimonial → coywolf-testimonial. Reads the marker's
|
|
441
|
+
* fields, or (markers from 0.10/0.11, which held only WordPress's markup) the
|
|
442
|
+
* fallback HTML. The headshot keeps its WordPress URL.
|
|
443
|
+
*/
|
|
444
|
+
export function testimonialBlock(attrs: Record<string, unknown>, inner = ""): Block | null {
|
|
445
|
+
let f: { quote: string; name: string; title: string; photo: string; nameUrl: string; titleUrl: string };
|
|
446
|
+
if (typeof attrs.quote === "string") {
|
|
447
|
+
f = { quote: attrs.quote, name: str(attrs.name), title: str(attrs.title), photo: str(attrs.photo), nameUrl: str(attrs.nameUrl), titleUrl: str(attrs.titleUrl) };
|
|
448
|
+
} else {
|
|
449
|
+
const m = inner.match(
|
|
450
|
+
/^\s*<blockquote class="testimonial"><div class="quote"><p><q>([\s\S]*?)<\/q><\/p><\/div><div class="influencer">(?:<img alt="[^"]*" height="60" width="60" src="([^"]*)">)?<p>(?:<a href="([^"]*)">([\s\S]*?)<\/a>|([\s\S]*?))<\/p><p>(?:<a href="([^"]*)">([\s\S]*?)<\/a>|([\s\S]*?))<\/p><\/div><\/blockquote>\s*$/,
|
|
451
|
+
);
|
|
452
|
+
if (!m) return null;
|
|
453
|
+
f = {
|
|
454
|
+
quote: m[1] as string,
|
|
455
|
+
photo: unescapeAttr(m[2] ?? ""),
|
|
456
|
+
nameUrl: unescapeAttr(m[3] ?? ""),
|
|
457
|
+
name: m[4] ?? m[5] ?? "",
|
|
458
|
+
titleUrl: unescapeAttr(m[6] ?? ""),
|
|
459
|
+
title: m[7] ?? m[8] ?? "",
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (!f.quote.trim()) return null;
|
|
463
|
+
const block: Block = { _type: "coywolf-testimonial", quote: f.quote.trim() };
|
|
464
|
+
// Name and title were printed as HTML on WordPress; the block's fields are plain text.
|
|
465
|
+
const name = textOf(f.name);
|
|
466
|
+
if (name) block.name = name;
|
|
467
|
+
const title = textOf(f.title);
|
|
468
|
+
if (title) block.title = title;
|
|
469
|
+
const url = (v: string) => (/^https?:\/\/\S+$/i.test(v.trim()) ? v.trim() : "");
|
|
470
|
+
if (url(f.photo)) block.photo = url(f.photo);
|
|
471
|
+
if (url(f.nameUrl)) block.nameUrl = url(f.nameUrl);
|
|
472
|
+
if (url(f.titleUrl)) block.titleUrl = url(f.titleUrl);
|
|
473
|
+
return block;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The podcast links block had no fields (its template printed the show's links): it uses the site's links. */
|
|
477
|
+
export function podcastBlock(): Block {
|
|
478
|
+
return { _type: "coywolf-podcast", source: "site" };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Walking ──────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/** Prism names (Code Block Enhancer) the editor's language list spells differently. */
|
|
484
|
+
const PRISM_LANGUAGES: Record<string, string> = { markup: "html", svg: "xml", mathml: "xml" };
|
|
485
|
+
|
|
486
|
+
const isHeading = (b: Block) => b._type === "block" && typeof b.style === "string" && /^h[1-6]$/.test(b.style);
|
|
487
|
+
const validAnchor = (id: string) => id.length <= 120 && /^[\p{L}][\p{L}\p{N}_-]*$/u.test(id);
|
|
488
|
+
|
|
489
|
+
interface Walk {
|
|
490
|
+
opts: Required<Pick<ConvertOptions, "key">> & { videoDefaults: VideoDefaults; fileDefaults: FileDefaults; customBlocks: CustomBlockSwitches };
|
|
491
|
+
changes: Change[];
|
|
492
|
+
videos: VideoFact[];
|
|
493
|
+
leftovers: Record<string, number>;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The native block for an imported HTML block, or undefined to leave it. `null` removes it. */
|
|
497
|
+
function convertHtmlBlock(block: Block, w: Walk): Block | null | undefined | { anchor: string } {
|
|
498
|
+
const marker: Marker | null = parseMarker(block.html);
|
|
499
|
+
const original = typeof block.originalBlockName === "string" ? block.originalBlockName : null;
|
|
500
|
+
const originalAttrs = (block.originalAttrs && typeof block.originalAttrs === "object" ? block.originalAttrs : null) as Record<string, unknown> | null;
|
|
501
|
+
const name = marker ? (marker.source === "wellbeing" ? `wb:${marker.name}` : marker.name) : original === "coywolf/video" ? "video" : original === "coywolf/file" ? "file" : null;
|
|
502
|
+
const attrs = marker?.attrs ?? originalAttrs ?? {};
|
|
503
|
+
if (!name) {
|
|
504
|
+
if (original && original !== "core/html") w.leftovers[original] = (w.leftovers[original] ?? 0) + 1;
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
const video = (made: { block: Block; fact: VideoFact } | null) => {
|
|
508
|
+
if (!made) return undefined;
|
|
509
|
+
w.videos.push(made.fact);
|
|
510
|
+
return made.block;
|
|
511
|
+
};
|
|
512
|
+
switch (name) {
|
|
513
|
+
case "cloudflare-stream":
|
|
514
|
+
return video(streamVideo(attrs, (attrs.embed as StreamEmbed | null) ?? null));
|
|
515
|
+
case "stream-embed":
|
|
516
|
+
return video(streamVideo({}, (attrs.embed as StreamEmbed | null) ?? null));
|
|
517
|
+
case "wb:cloudflare-stream":
|
|
518
|
+
return video(wellbeingVideo(attrs));
|
|
519
|
+
case "video":
|
|
520
|
+
return video(managerVideo(attrs, w.opts.videoDefaults));
|
|
521
|
+
case "review":
|
|
522
|
+
case "wb:review":
|
|
523
|
+
return reviewBlock(attrs) ?? undefined;
|
|
524
|
+
case "toc":
|
|
525
|
+
return tocBlock(attrs);
|
|
526
|
+
case "file":
|
|
527
|
+
return fileBlock(attrs, w.opts.fileDefaults) ?? undefined;
|
|
528
|
+
case "sidenote":
|
|
529
|
+
case "editorsnote":
|
|
530
|
+
return w.opts.customBlocks.note ? (noteBlock(name, attrs, marker?.inner) ?? leftover(name, w)) : leftover(name, w);
|
|
531
|
+
case "transcript":
|
|
532
|
+
case "accordion":
|
|
533
|
+
case "details":
|
|
534
|
+
return w.opts.customBlocks.details ? (detailsBlock(name, attrs, marker?.inner) ?? leftover(name, w)) : leftover(name, w);
|
|
535
|
+
case "blockquote":
|
|
536
|
+
return w.opts.customBlocks.quote ? (quoteBlock(attrs, marker?.inner) ?? leftover(name, w)) : leftover(name, w);
|
|
537
|
+
case "disclosure":
|
|
538
|
+
return w.opts.customBlocks.disclosure ? disclosureBlock(attrs) : leftover(name, w);
|
|
539
|
+
case "testimonial":
|
|
540
|
+
return w.opts.customBlocks.testimonial ? (testimonialBlock(attrs, marker?.inner) ?? leftover(name, w)) : leftover(name, w);
|
|
541
|
+
case "podcast-links":
|
|
542
|
+
return w.opts.customBlocks.podcast ? podcastBlock() : leftover(name, w);
|
|
543
|
+
case "anchor": {
|
|
544
|
+
const id = decodeHtml(str(attrs.id));
|
|
545
|
+
return validAnchor(id) ? { anchor: id } : null;
|
|
546
|
+
}
|
|
547
|
+
default:
|
|
548
|
+
return leftover(name, w);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The caption in a "table-caption" marker (prepare step), or null when the block isn't one. */
|
|
553
|
+
function tableCaptionMarker(block: Block): string | null {
|
|
554
|
+
const marker = parseMarker(block.html);
|
|
555
|
+
if (marker?.name !== "table-caption" || marker.source !== "coywolf") return null;
|
|
556
|
+
return str(marker.attrs.caption).replace(/\s+/g, " ");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Leave a marker as it is, counting it for the report. */
|
|
560
|
+
function leftover(name: string, w: Walk): undefined {
|
|
561
|
+
w.leftovers[`marker:${name}`] = (w.leftovers[`marker:${name}`] ?? 0) + 1;
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function convertArray(blocks: unknown[], w: Walk, depth: number): unknown[] | null {
|
|
566
|
+
let out: unknown[] | null = null;
|
|
567
|
+
let pendingAnchor: string | null = null;
|
|
568
|
+
const set = (i: number, value: unknown) => {
|
|
569
|
+
out ??= blocks.slice();
|
|
570
|
+
out[i] = value;
|
|
571
|
+
};
|
|
572
|
+
const REMOVE = Symbol("remove");
|
|
573
|
+
/** Index of the block just before this one when it's a table (a caption marker attaches to it). */
|
|
574
|
+
let tableBefore: number | null = null;
|
|
575
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
576
|
+
const item = blocks[i];
|
|
577
|
+
const table = tableBefore;
|
|
578
|
+
tableBefore = null;
|
|
579
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
580
|
+
if (Array.isArray(item)) {
|
|
581
|
+
const inner = convertArray(item, w, depth + 1);
|
|
582
|
+
if (inner) set(i, inner);
|
|
583
|
+
}
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const block = item as Block;
|
|
587
|
+
if (block._type === "table") tableBefore = i;
|
|
588
|
+
if (block._type === "htmlBlock") {
|
|
589
|
+
const caption = tableCaptionMarker(block);
|
|
590
|
+
if (caption !== null) {
|
|
591
|
+
if (table === null) {
|
|
592
|
+
leftover("table-caption", w);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const current = ((out as unknown[] | null) ?? blocks)[table] as Block;
|
|
596
|
+
if (caption && current.caption !== caption) set(table, { ...current, caption });
|
|
597
|
+
set(i, REMOVE);
|
|
598
|
+
w.changes.push({ from: "table-caption", to: "table", detail: caption || undefined });
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const result = convertHtmlBlock(block, w);
|
|
602
|
+
if (result === undefined) continue;
|
|
603
|
+
const from = parseMarker(block.html)?.name ?? String(block.originalBlockName ?? "htmlBlock");
|
|
604
|
+
if (result === null) {
|
|
605
|
+
set(i, REMOVE);
|
|
606
|
+
w.changes.push({ from, to: "removed" });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if ("anchor" in result && typeof result.anchor === "string") {
|
|
610
|
+
pendingAnchor = result.anchor;
|
|
611
|
+
set(i, REMOVE);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const next: Block = { ...(result as Block), _key: typeof block._key === "string" ? block._key : w.opts.key() };
|
|
615
|
+
for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k];
|
|
616
|
+
set(i, next);
|
|
617
|
+
w.changes.push({ from, to: String(next._type), detail: String(next.uid ?? next.itemName ?? next.id ?? "") || undefined });
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (isHeading(block)) {
|
|
621
|
+
if (pendingAnchor) {
|
|
622
|
+
if (block.anchor !== pendingAnchor) {
|
|
623
|
+
set(i, { ...block, anchor: pendingAnchor });
|
|
624
|
+
w.changes.push({ from: "anchor", to: "heading", detail: pendingAnchor });
|
|
625
|
+
}
|
|
626
|
+
pendingAnchor = null;
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (block._type === "code" && typeof block.language === "string") {
|
|
631
|
+
const lang = PRISM_LANGUAGES[block.language.toLowerCase()];
|
|
632
|
+
if (lang) {
|
|
633
|
+
set(i, { ...block, language: lang });
|
|
634
|
+
w.changes.push({ from: `code:${block.language}`, to: `code:${lang}` });
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (depth < 8) {
|
|
639
|
+
// Nested Portable Text (columns, covers, custom blocks with content arrays).
|
|
640
|
+
let patched: Block | null = null;
|
|
641
|
+
for (const [k, v] of Object.entries(block)) {
|
|
642
|
+
if (!Array.isArray(v) || !v.length) continue;
|
|
643
|
+
const inner = convertArray(v, w, depth + 1);
|
|
644
|
+
if (inner) {
|
|
645
|
+
patched ??= { ...block };
|
|
646
|
+
patched[k] = inner;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (patched) set(i, patched);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// An anchor marker with no heading after it: it was removed (nothing to attach to).
|
|
653
|
+
if (!out) return null;
|
|
654
|
+
return (out as unknown[]).filter((b) => b !== REMOVE);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** An array that looks like Portable Text. */
|
|
658
|
+
function isPortableText(value: unknown): value is unknown[] {
|
|
659
|
+
return Array.isArray(value) && value.length > 0 && value.every((b) => b && typeof b === "object" && typeof (b as { _type?: unknown })._type === "string");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function walkFor(opts: ConvertOptions): Walk {
|
|
663
|
+
return {
|
|
664
|
+
opts: {
|
|
665
|
+
key: opts.key ?? randomKey,
|
|
666
|
+
videoDefaults: { ...VIDEO_MANAGER_DEFAULTS, ...opts.videoDefaults },
|
|
667
|
+
fileDefaults: { ...FILES_DEFAULTS, ...opts.fileDefaults },
|
|
668
|
+
customBlocks: { note: true, details: true, disclosure: true, quote: true, testimonial: true, podcast: true, ...opts.contentBlocks, ...opts.customBlocks },
|
|
669
|
+
},
|
|
670
|
+
changes: [],
|
|
671
|
+
videos: [],
|
|
672
|
+
leftovers: {},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Convert one Portable Text array. */
|
|
677
|
+
export function convertPortableText(blocks: unknown[], opts: ConvertOptions = {}): ConvertResult<unknown[]> {
|
|
678
|
+
const w = walkFor(opts);
|
|
679
|
+
const out = convertArray(blocks, w, 0);
|
|
680
|
+
return { value: out ?? blocks, changed: Boolean(out), changes: w.changes, videos: w.videos, leftovers: w.leftovers };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Convert every Portable Text field of an entry's data. */
|
|
684
|
+
export function convertEntryData(data: Record<string, unknown>, opts: ConvertOptions = {}): ConvertResult<Record<string, unknown>> {
|
|
685
|
+
const w = walkFor(opts);
|
|
686
|
+
let out: Record<string, unknown> | null = null;
|
|
687
|
+
for (const [field, value] of Object.entries(data)) {
|
|
688
|
+
if (!isPortableText(value)) continue;
|
|
689
|
+
const next = convertArray(value, w, 0);
|
|
690
|
+
if (next) {
|
|
691
|
+
out ??= { ...data };
|
|
692
|
+
out[field] = next;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return { value: out ?? data, changed: Boolean(out), changes: w.changes, videos: w.videos, leftovers: w.leftovers };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Quick check before the full walk: does this data hold anything to convert? */
|
|
699
|
+
export function mightNeedConversion(data: unknown): boolean {
|
|
700
|
+
const json = JSON.stringify(data ?? null);
|
|
701
|
+
return /data-coywolf-wp=|data-wb-block=|"originalBlockName":"coywolf\/|"language":"(?:markup|svg|mathml)"/.test(json);
|
|
702
|
+
}
|