@decocms/apps-blog 8.0.0 → 8.1.0-next.0
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/README.md +21 -0
- package/package.json +14 -5
- package/src/README.md +69 -0
- package/src/__tests__/blogRecords.test.ts +270 -0
- package/src/__tests__/categoryTree.test.ts +209 -0
- package/src/__tests__/date.test.ts +134 -0
- package/src/__tests__/handlePosts.test.ts +256 -230
- package/src/__tests__/loaders.test.ts +198 -154
- package/src/__tests__/mod.test.ts +6 -6
- package/src/__tests__/publicationStatus.test.ts +176 -0
- package/src/__tests__/records.test.ts +68 -68
- package/src/__tests__/seoSections.test.ts +245 -0
- package/src/actions/submitRating.ts +36 -0
- package/src/actions/submitReview.ts +95 -0
- package/src/actions/submitView.ts +28 -0
- package/src/client.ts +70 -0
- package/src/core/blogRecords.ts +94 -0
- package/src/core/categoryTree.ts +210 -0
- package/src/core/handlePosts.ts +160 -64
- package/src/core/records.ts +147 -12
- package/src/index.ts +48 -14
- package/src/loaderMap.ts +28 -24
- package/src/loaders/BlogPostItem.ts +15 -5
- package/src/loaders/BlogPostPage.ts +61 -24
- package/src/loaders/BlogRelatedPosts.ts +45 -45
- package/src/loaders/BlogpostList.ts +93 -0
- package/src/loaders/BlogpostListing.ts +151 -85
- package/src/loaders/GetCategories.ts +51 -28
- package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
- package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
- package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
- package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
- package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
- package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
- package/src/loaders/extensions/types.ts +15 -0
- package/src/manifest.gen.ts +82 -14
- package/src/mod.ts +73 -20
- package/src/registry.ts +5 -5
- package/src/sections/Seo/SeoBlogPost.tsx +77 -0
- package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
- package/src/sections/Template.tsx +106 -0
- package/src/sections/blocks/BlockImage.tsx +42 -0
- package/src/sections/blocks/Callout.tsx +64 -0
- package/src/sections/blocks/CardGroup.tsx +60 -0
- package/src/sections/blocks/Checklist.tsx +58 -0
- package/src/sections/blocks/Code.tsx +29 -0
- package/src/sections/blocks/Comparison.tsx +77 -0
- package/src/sections/blocks/Cta.tsx +22 -0
- package/src/sections/blocks/Divider.tsx +3 -0
- package/src/sections/blocks/Heading.tsx +28 -0
- package/src/sections/blocks/List.tsx +39 -0
- package/src/sections/blocks/Paragraph.tsx +15 -0
- package/src/sections/blocks/Quote.tsx +32 -0
- package/src/sections/blocks/Stat.tsx +23 -0
- package/src/sections/blocks/StatGroup.tsx +38 -0
- package/src/sections/blocks/Steps.tsx +64 -0
- package/src/sections/blocks/Table.tsx +80 -0
- package/src/sections/blocks/Video.tsx +39 -0
- package/src/static/css.ts +178 -0
- package/src/types.ts +333 -77
- package/src/utils/blocksToSections.ts +175 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/date.ts +95 -0
- package/src/utils/hardSanitize.ts +106 -0
- package/src/utils/jsonLD.ts +118 -0
- package/src/utils/sanitizeHtml.ts +37 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
type Card = { icon?: string; title: string; body: string };
|
|
4
|
+
|
|
5
|
+
export type Props = {
|
|
6
|
+
/** JSON-encoded Card[] (max 3) */
|
|
7
|
+
cards: Card[] | string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const GRID_CLASS: Record<number, string> = {
|
|
11
|
+
1: "grid-cols-1",
|
|
12
|
+
2: "grid-cols-1 sm:grid-cols-2",
|
|
13
|
+
3: "grid-cols-1 sm:grid-cols-3",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function CardGroup({ cards }: Props) {
|
|
17
|
+
let items: Card[] = [];
|
|
18
|
+
|
|
19
|
+
if (Array.isArray(cards)) {
|
|
20
|
+
items = cards;
|
|
21
|
+
} else if (typeof cards === "string") {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(cards);
|
|
24
|
+
if (Array.isArray(parsed)) items = parsed;
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const clamped = items.slice(0, 3);
|
|
31
|
+
const gridCols = GRID_CLASS[clamped.length] ?? GRID_CLASS[3];
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={`grid gap-4 my-10 ${gridCols}`}>
|
|
35
|
+
{clamped.map((card, i) => (
|
|
36
|
+
<div
|
|
37
|
+
key={i}
|
|
38
|
+
className="flex flex-col gap-3 p-6 sm:p-8 bg-alt border border-line rounded-brand transition-shadow hover:shadow-brand"
|
|
39
|
+
>
|
|
40
|
+
{card.icon && (
|
|
41
|
+
<div
|
|
42
|
+
className="flex items-center justify-center w-9 h-9 text-lg bg-base border border-line rounded-brand-sm flex-shrink-0 leading-none"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
>
|
|
45
|
+
{card.icon}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
<strong
|
|
49
|
+
className="text-[1.0625rem] font-semibold leading-snug"
|
|
50
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(card.title) }}
|
|
51
|
+
/>
|
|
52
|
+
<p
|
|
53
|
+
className="text-sm leading-normal [text-wrap:pretty] m-0"
|
|
54
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(card.body) }}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
title?: string;
|
|
5
|
+
/** JSON-encoded string[] */
|
|
6
|
+
items: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function Checklist({ title, items }: Props) {
|
|
10
|
+
let list: string[] = [];
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(items ?? "[]");
|
|
13
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
14
|
+
} catch {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="my-6">
|
|
20
|
+
{title && (
|
|
21
|
+
<h3 className="font-display text-[var(--text-xl)] font-normal tracking-snug mb-4">
|
|
22
|
+
{title}
|
|
23
|
+
</h3>
|
|
24
|
+
)}
|
|
25
|
+
<ul className="list-none p-0 m-0">
|
|
26
|
+
{list.map((item, i) => (
|
|
27
|
+
<li
|
|
28
|
+
key={i}
|
|
29
|
+
className="flex items-start gap-3 py-3.5 border-b border-line-subtle first:border-t first:border-line-subtle"
|
|
30
|
+
>
|
|
31
|
+
<span
|
|
32
|
+
className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-accent mt-[1px]"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<svg
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
width="13"
|
|
38
|
+
height="13"
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="2.5"
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeLinejoin="round"
|
|
45
|
+
>
|
|
46
|
+
<polyline points="20 6 9 17 4 12" />
|
|
47
|
+
</svg>
|
|
48
|
+
</span>
|
|
49
|
+
<span
|
|
50
|
+
className="text-[1.0625rem] leading-normal [&_a]:text-accent [&_strong]:font-semibold [&_strong]:text-base"
|
|
51
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(item) }}
|
|
52
|
+
/>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Props = {
|
|
2
|
+
code: string;
|
|
3
|
+
language?: string;
|
|
4
|
+
filename?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default function Code({ code, language, filename }: Props) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="my-6 bg-alt border border-line rounded-brand overflow-hidden text-sm">
|
|
10
|
+
{(filename || language) && (
|
|
11
|
+
<div className="flex items-center justify-between px-5 py-2.5 border-b border-line bg-inset">
|
|
12
|
+
{filename ? (
|
|
13
|
+
<span className="font-mono text-xs text-secondary">{filename}</span>
|
|
14
|
+
) : (
|
|
15
|
+
<span />
|
|
16
|
+
)}
|
|
17
|
+
{language && (
|
|
18
|
+
<span className="text-xs font-semibold tracking-caps uppercase text-muted">
|
|
19
|
+
{language}
|
|
20
|
+
</span>
|
|
21
|
+
)}
|
|
22
|
+
</div>
|
|
23
|
+
)}
|
|
24
|
+
<pre className="p-5 overflow-x-auto m-0 bg-transparent border-0 leading-[1.75]">
|
|
25
|
+
<code className="font-mono text-sm bg-transparent p-0 whitespace-pre">{code}</code>
|
|
26
|
+
</pre>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
type Col = { title: string; items: string[] };
|
|
4
|
+
|
|
5
|
+
export type Props = {
|
|
6
|
+
/** JSON-encoded Col */
|
|
7
|
+
left: string;
|
|
8
|
+
/** JSON-encoded Col */
|
|
9
|
+
right: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function Comparison({ left, right }: Props) {
|
|
13
|
+
let leftCol: Col = { title: "Option A", items: [] };
|
|
14
|
+
let rightCol: Col = { title: "Option B", items: [] };
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(left ?? "{}");
|
|
17
|
+
leftCol = {
|
|
18
|
+
title: typeof parsed?.title === "string" ? parsed.title : "Option A",
|
|
19
|
+
items: Array.isArray(parsed?.items) ? parsed.items : [],
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(right ?? "{}");
|
|
26
|
+
rightCol = {
|
|
27
|
+
title: typeof parsed?.title === "string" ? parsed.title : "Option B",
|
|
28
|
+
items: Array.isArray(parsed?.items) ? parsed.items : [],
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="grid grid-cols-2 gap-px bg-line border border-line rounded-brand my-8 overflow-hidden">
|
|
36
|
+
{/* Left column — pros */}
|
|
37
|
+
<div className="bg-base p-5 sm:p-6">
|
|
38
|
+
<div className="text-xs font-semibold tracking-caps uppercase pb-4 border-b-2 border-line mb-4">
|
|
39
|
+
{leftCol.title}
|
|
40
|
+
</div>
|
|
41
|
+
<ul className="list-none p-0 m-0 flex flex-col gap-3">
|
|
42
|
+
{leftCol.items.map((item, j) => (
|
|
43
|
+
<li key={j} className="flex items-start gap-2.5 text-sm leading-snug">
|
|
44
|
+
<span
|
|
45
|
+
className="flex-shrink-0 text-emerald-500 font-bold mt-px leading-none"
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
>
|
|
48
|
+
✓
|
|
49
|
+
</span>
|
|
50
|
+
<span dangerouslySetInnerHTML={{ __html: sanitizeHtml(item) }} />
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Right column — cons */}
|
|
57
|
+
<div className="bg-alt p-5 sm:p-6">
|
|
58
|
+
<div className="text-xs font-semibold tracking-caps uppercase pb-4 border-b-2 border-line mb-4">
|
|
59
|
+
{rightCol.title}
|
|
60
|
+
</div>
|
|
61
|
+
<ul className="list-none p-0 m-0 flex flex-col gap-3">
|
|
62
|
+
{rightCol.items.map((item, j) => (
|
|
63
|
+
<li key={j} className="flex items-start gap-2.5 text-sm leading-snug">
|
|
64
|
+
<span
|
|
65
|
+
className="flex-shrink-0 text-tertiary font-bold mt-px leading-none"
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
>
|
|
68
|
+
✕
|
|
69
|
+
</span>
|
|
70
|
+
<span dangerouslySetInnerHTML={{ __html: sanitizeHtml(item) }} />
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { sanitizeHref } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
text: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function Cta({ text, href }: Props) {
|
|
9
|
+
const safeHref = sanitizeHref(href);
|
|
10
|
+
const isExternal = /^https?:\/\//i.test(safeHref);
|
|
11
|
+
return (
|
|
12
|
+
<div className="my-8 text-center">
|
|
13
|
+
<a
|
|
14
|
+
href={safeHref}
|
|
15
|
+
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
16
|
+
className="inline-block py-[0.75em] px-[2em] rounded-full bg-accent text-inverted font-semibold text-sm tracking-[0.02em] no-underline transition-opacity hover:opacity-85"
|
|
17
|
+
>
|
|
18
|
+
{text}
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
text: string;
|
|
5
|
+
level?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const styles: Record<string, string> = {
|
|
9
|
+
h2: "font-display font-semibold text-2xl tracking-snug leading-tight mt-10 mb-4 break-words [text-wrap:balance] uppercase",
|
|
10
|
+
h3: "font-display font-semibold text-xl tracking-snug leading-snug mt-8 mb-3 break-words [text-wrap:balance] uppercase",
|
|
11
|
+
h4: "font-display font-semibold text-lg leading-snug mt-7 mb-2 break-words uppercase",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function Heading({ text, level }: Props) {
|
|
15
|
+
const tag =
|
|
16
|
+
level === "3" || level === "h3" ? "h3" : level === "4" || level === "h4" ? "h4" : "h2";
|
|
17
|
+
|
|
18
|
+
const cls = styles[tag] ?? styles.h2;
|
|
19
|
+
|
|
20
|
+
const safe = sanitizeHtml(text);
|
|
21
|
+
if (tag === "h3") {
|
|
22
|
+
return <h3 className={cls} dangerouslySetInnerHTML={{ __html: safe }} />;
|
|
23
|
+
}
|
|
24
|
+
if (tag === "h4") {
|
|
25
|
+
return <h4 className={cls} dangerouslySetInnerHTML={{ __html: safe }} />;
|
|
26
|
+
}
|
|
27
|
+
return <h2 className={cls} dangerouslySetInnerHTML={{ __html: safe }} />;
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
items: string;
|
|
5
|
+
style?: "ordered" | "unordered";
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function List({ items, style }: Props) {
|
|
9
|
+
const entries = items.split("\n").filter(Boolean);
|
|
10
|
+
const ordered = style === "ordered";
|
|
11
|
+
|
|
12
|
+
const baseClass = "pl-6 my-4 text-[1.0625rem] leading-[1.75] space-y-2";
|
|
13
|
+
|
|
14
|
+
if (ordered) {
|
|
15
|
+
return (
|
|
16
|
+
<ol className={`list-decimal ${baseClass}`}>
|
|
17
|
+
{entries.map((item, i) => (
|
|
18
|
+
<li
|
|
19
|
+
key={i}
|
|
20
|
+
className="pl-1.5 marker:text-muted [&_a]:text-accent [&_strong]:font-semibold [&_strong]:text-base"
|
|
21
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(item) }}
|
|
22
|
+
/>
|
|
23
|
+
))}
|
|
24
|
+
</ol>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<ul className={`list-disc ${baseClass}`}>
|
|
30
|
+
{entries.map((item, i) => (
|
|
31
|
+
<li
|
|
32
|
+
key={i}
|
|
33
|
+
className="pl-1.5 marker:text-muted [&_a]:text-accent [&_strong]:font-semibold [&_strong]:text-base"
|
|
34
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(item) }}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
html?: string;
|
|
5
|
+
text?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function Paragraph({ html, text }: Props) {
|
|
9
|
+
return (
|
|
10
|
+
<p
|
|
11
|
+
className="text-[1.0625rem] leading-[1.75] break-words [text-wrap:pretty] [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-[3px] [&_a]:decoration-[1px] hover:[&_a]:decoration-[2px] [&_strong]:font-semibold [&_strong]:text-base"
|
|
12
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(html ?? text ?? "") }}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type Props = {
|
|
2
|
+
quote?: string;
|
|
3
|
+
text?: string;
|
|
4
|
+
attribution?: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function Quote({ quote, text, attribution, source }: Props) {
|
|
9
|
+
return (
|
|
10
|
+
<blockquote className="relative px-10 py-10 sm:px-12 sm:py-12 bg-alt border-l-4 border-accent rounded-brand my-10 overflow-hidden">
|
|
11
|
+
{/* oversized decorative quotation mark */}
|
|
12
|
+
<span
|
|
13
|
+
className="pointer-events-none select-none absolute bottom-[-0.45em] right-10 font-display text-[20rem] sm:text-[28rem] leading-none text-accent opacity-[0.07]"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
>
|
|
16
|
+
“
|
|
17
|
+
</span>
|
|
18
|
+
|
|
19
|
+
<p className="relative font-display text-[var(--text-xl)] sm:text-[var(--text-2xl)] italic leading-snug m-0 [text-wrap:pretty]">
|
|
20
|
+
{quote ?? text}
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
{(attribution || source) && (
|
|
24
|
+
<footer className="relative mt-5 text-sm text-tertiary not-italic flex items-center gap-1.5">
|
|
25
|
+
<span className="block w-5 h-px bg-current opacity-50" aria-hidden="true" />
|
|
26
|
+
{attribution && <cite className="not-italic">{attribution}</cite>}
|
|
27
|
+
{source && <span>{source}</span>}
|
|
28
|
+
</footer>
|
|
29
|
+
)}
|
|
30
|
+
</blockquote>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Props = {
|
|
2
|
+
value: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default function Stat({ value, label, description }: Props) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-col items-center text-center py-12 px-8 border-y-2 border-primary/20 my-10">
|
|
10
|
+
<div className="font-display text-[var(--text-5xl)] leading-none text-accent tracking-display tabular-nums">
|
|
11
|
+
{value}
|
|
12
|
+
</div>
|
|
13
|
+
{label && (
|
|
14
|
+
<div className="text-xs font-semibold tracking-caps uppercase text-muted mt-4">{label}</div>
|
|
15
|
+
)}
|
|
16
|
+
{description && (
|
|
17
|
+
<p className="text-sm text-tertiary leading-normal max-w-[40ch] mt-3 [text-wrap:pretty] m-0">
|
|
18
|
+
{description}
|
|
19
|
+
</p>
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type StatItem = { value: string; label: string };
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
/** JSON-encoded StatItem[] (max 3) */
|
|
5
|
+
stats: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const GRID_CLASS: Record<number, string> = {
|
|
9
|
+
1: "grid-cols-1",
|
|
10
|
+
2: "grid-cols-1 sm:grid-cols-2",
|
|
11
|
+
3: "grid-cols-1 sm:grid-cols-3",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function StatGroup({ stats }: Props) {
|
|
15
|
+
let items: StatItem[] = [];
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(stats ?? "[]");
|
|
18
|
+
if (Array.isArray(parsed)) items = parsed;
|
|
19
|
+
} catch {
|
|
20
|
+
/* ignore */
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const clamped = items.slice(0, 3);
|
|
24
|
+
const gridCols = GRID_CLASS[clamped.length] ?? GRID_CLASS[3];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={`grid gap-4 my-10 ${gridCols}`}>
|
|
28
|
+
{clamped.map((stat, i) => (
|
|
29
|
+
<div key={i} className="p-7 sm:p-8 border-[1.5px] border-line rounded-brand">
|
|
30
|
+
<div className="font-display text-[var(--text-4xl)] font-bold leading-none text-accent tracking-display mb-3 tabular-nums">
|
|
31
|
+
{stat.value}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="text-sm leading-normal [text-wrap:pretty]">{stat.label}</div>
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
2
|
+
|
|
3
|
+
type Step = { title: string; description?: string };
|
|
4
|
+
|
|
5
|
+
export type Props = {
|
|
6
|
+
title?: string;
|
|
7
|
+
/** JSON-encoded Step[] */
|
|
8
|
+
steps: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function Steps({ title, steps }: Props) {
|
|
12
|
+
let list: Step[] = [];
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(steps ?? "[]");
|
|
15
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
16
|
+
} catch {
|
|
17
|
+
/* ignore */
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="my-8">
|
|
22
|
+
{title && (
|
|
23
|
+
<h3 className="font-display text-xl font-semibold uppercase tracking-snug mb-6">{title}</h3>
|
|
24
|
+
)}
|
|
25
|
+
<ol className="list-none p-0 m-0 flex flex-col">
|
|
26
|
+
{list.map((step, i) => (
|
|
27
|
+
<li key={i} className="grid grid-cols-[48px_1fr] gap-5 pb-8 relative">
|
|
28
|
+
{/* connector line — -bottom-8 matches the pb-8 gap so the line bridges into the next step's number box */}
|
|
29
|
+
{i < list.length - 1 && (
|
|
30
|
+
<span
|
|
31
|
+
className="absolute left-6 -translate-x-1/2 top-12 -bottom-8 w-px bg-accent z-0"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
/>
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
{/* number box */}
|
|
37
|
+
<div
|
|
38
|
+
className="relative z-[1] w-12 h-12 flex-shrink-0 flex items-center justify-center border-2 border-accent bg-base text-accent font-semibold text-lg leading-none"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
>
|
|
41
|
+
{i + 1}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* content */}
|
|
45
|
+
<div className="min-w-0 pt-2.5">
|
|
46
|
+
<strong
|
|
47
|
+
className="font-semibold leading-snug block mb-1.5 [&_a]:text-inherit [&_a]:no-underline"
|
|
48
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(step.title) }}
|
|
49
|
+
/>
|
|
50
|
+
{step.description && (
|
|
51
|
+
<p
|
|
52
|
+
className="text-sm leading-normal m-0 [&_a]:text-accent [&_a]:underline"
|
|
53
|
+
dangerouslySetInnerHTML={{
|
|
54
|
+
__html: sanitizeHtml(step.description),
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ol>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { hardSanitize } from "../../utils/hardSanitize";
|
|
2
|
+
|
|
3
|
+
export type Props = {
|
|
4
|
+
/** JSON-encoded string[] — optional header row */
|
|
5
|
+
headers?: string[] | string;
|
|
6
|
+
/** JSON-encoded string[][] — body rows × cells */
|
|
7
|
+
rows?: string[][] | string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function parseHeaders(value: string[] | string | undefined): string[] {
|
|
11
|
+
if (Array.isArray(value)) return value.map((c) => String(c ?? ""));
|
|
12
|
+
if (typeof value === "string") {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(value);
|
|
15
|
+
if (Array.isArray(parsed)) return parsed.map((c) => String(c ?? ""));
|
|
16
|
+
} catch {
|
|
17
|
+
/* ignore */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRows(value: string[][] | string | undefined): string[][] {
|
|
24
|
+
const toRow = (row: unknown): string[] =>
|
|
25
|
+
Array.isArray(row) ? row.map((c) => String(c ?? "")) : [];
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value)) return value.map(toRow);
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(value);
|
|
31
|
+
if (Array.isArray(parsed)) return parsed.map(toRow);
|
|
32
|
+
} catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function Table({ headers, rows }: Props) {
|
|
40
|
+
const head = parseHeaders(headers);
|
|
41
|
+
const body = parseRows(rows);
|
|
42
|
+
|
|
43
|
+
if (head.length === 0 && body.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
const cellClass =
|
|
46
|
+
"px-4 py-3 text-sm leading-normal align-top [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base";
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="my-8 border border-line rounded-brand overflow-x-auto">
|
|
50
|
+
<table className="w-full border-collapse text-left">
|
|
51
|
+
{head.length > 0 && (
|
|
52
|
+
<thead>
|
|
53
|
+
<tr className="bg-alt">
|
|
54
|
+
{head.map((cell, i) => (
|
|
55
|
+
<th
|
|
56
|
+
key={i}
|
|
57
|
+
className="px-4 py-3 text-xs font-semibold tracking-caps uppercase border-b-2 border-line"
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
|
|
59
|
+
/>
|
|
60
|
+
))}
|
|
61
|
+
</tr>
|
|
62
|
+
</thead>
|
|
63
|
+
)}
|
|
64
|
+
<tbody>
|
|
65
|
+
{body.map((row, r) => (
|
|
66
|
+
<tr key={r} className="border-b border-line-subtle last:border-b-0">
|
|
67
|
+
{row.map((cell, c) => (
|
|
68
|
+
<td
|
|
69
|
+
key={c}
|
|
70
|
+
className={cellClass}
|
|
71
|
+
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</tr>
|
|
75
|
+
))}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Props = {
|
|
2
|
+
url: string;
|
|
3
|
+
caption?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
function resolveEmbedSrc(raw: string): string | null {
|
|
7
|
+
const ytMatch = raw.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
|
8
|
+
if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
|
|
9
|
+
|
|
10
|
+
const vimeoMatch = raw.match(/vimeo\.com\/(\d+)/);
|
|
11
|
+
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
|
12
|
+
|
|
13
|
+
// Reject anything that didn't match a trusted provider.
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Video({ url, caption }: Props) {
|
|
18
|
+
const src = resolveEmbedSrc(url ?? "");
|
|
19
|
+
|
|
20
|
+
if (!src) return null;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<figure className="my-8">
|
|
24
|
+
<div className="relative aspect-video overflow-hidden bg-alt">
|
|
25
|
+
<iframe
|
|
26
|
+
src={src}
|
|
27
|
+
className="absolute inset-0 w-full h-full border-0"
|
|
28
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
29
|
+
allowFullScreen
|
|
30
|
+
loading="lazy"
|
|
31
|
+
title={caption ?? "Video"}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
{caption && (
|
|
35
|
+
<figcaption className="text-sm text-tertiary text-center mt-3 italic">{caption}</figcaption>
|
|
36
|
+
)}
|
|
37
|
+
</figure>
|
|
38
|
+
);
|
|
39
|
+
}
|