@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.
Files changed (66) hide show
  1. package/README.md +21 -0
  2. package/package.json +14 -5
  3. package/src/README.md +69 -0
  4. package/src/__tests__/blogRecords.test.ts +270 -0
  5. package/src/__tests__/categoryTree.test.ts +209 -0
  6. package/src/__tests__/date.test.ts +134 -0
  7. package/src/__tests__/handlePosts.test.ts +256 -230
  8. package/src/__tests__/loaders.test.ts +198 -154
  9. package/src/__tests__/mod.test.ts +6 -6
  10. package/src/__tests__/publicationStatus.test.ts +176 -0
  11. package/src/__tests__/records.test.ts +68 -68
  12. package/src/__tests__/seoSections.test.ts +245 -0
  13. package/src/actions/submitRating.ts +36 -0
  14. package/src/actions/submitReview.ts +95 -0
  15. package/src/actions/submitView.ts +28 -0
  16. package/src/client.ts +70 -0
  17. package/src/core/blogRecords.ts +94 -0
  18. package/src/core/categoryTree.ts +210 -0
  19. package/src/core/handlePosts.ts +160 -64
  20. package/src/core/records.ts +147 -12
  21. package/src/index.ts +48 -14
  22. package/src/loaderMap.ts +28 -24
  23. package/src/loaders/BlogPostItem.ts +15 -5
  24. package/src/loaders/BlogPostPage.ts +61 -24
  25. package/src/loaders/BlogRelatedPosts.ts +45 -45
  26. package/src/loaders/BlogpostList.ts +93 -0
  27. package/src/loaders/BlogpostListing.ts +151 -85
  28. package/src/loaders/GetCategories.ts +51 -28
  29. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  30. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  31. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  32. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  33. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  34. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  35. package/src/loaders/extensions/types.ts +15 -0
  36. package/src/manifest.gen.ts +82 -14
  37. package/src/mod.ts +73 -20
  38. package/src/registry.ts +5 -5
  39. package/src/sections/Seo/SeoBlogPost.tsx +77 -0
  40. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  41. package/src/sections/Template.tsx +106 -0
  42. package/src/sections/blocks/BlockImage.tsx +42 -0
  43. package/src/sections/blocks/Callout.tsx +64 -0
  44. package/src/sections/blocks/CardGroup.tsx +60 -0
  45. package/src/sections/blocks/Checklist.tsx +58 -0
  46. package/src/sections/blocks/Code.tsx +29 -0
  47. package/src/sections/blocks/Comparison.tsx +77 -0
  48. package/src/sections/blocks/Cta.tsx +22 -0
  49. package/src/sections/blocks/Divider.tsx +3 -0
  50. package/src/sections/blocks/Heading.tsx +28 -0
  51. package/src/sections/blocks/List.tsx +39 -0
  52. package/src/sections/blocks/Paragraph.tsx +15 -0
  53. package/src/sections/blocks/Quote.tsx +32 -0
  54. package/src/sections/blocks/Stat.tsx +23 -0
  55. package/src/sections/blocks/StatGroup.tsx +38 -0
  56. package/src/sections/blocks/Steps.tsx +64 -0
  57. package/src/sections/blocks/Table.tsx +80 -0
  58. package/src/sections/blocks/Video.tsx +39 -0
  59. package/src/static/css.ts +178 -0
  60. package/src/types.ts +333 -77
  61. package/src/utils/blocksToSections.ts +175 -0
  62. package/src/utils/constants.ts +1 -0
  63. package/src/utils/date.ts +95 -0
  64. package/src/utils/hardSanitize.ts +106 -0
  65. package/src/utils/jsonLD.ts +118 -0
  66. package/src/utils/sanitizeHtml.ts +37 -0
@@ -0,0 +1,175 @@
1
+ import type { Section } from "../types";
2
+
3
+ /** Minimal Block type — matches the structure returned by the Spire API. */
4
+ interface Block {
5
+ type?: string;
6
+ position?: number;
7
+ content?: Record<string, unknown>;
8
+ system_block_id?: string;
9
+ custom_block_id?: string;
10
+ }
11
+
12
+ const BASE = "blog/sections/blocks";
13
+
14
+ function toSection(resolveType: string, props: Record<string, unknown>): Section {
15
+ return { __resolveType: resolveType, ...props } as Section;
16
+ }
17
+
18
+ /**
19
+ * Converts a Spire Block array into a Section[] using blog/sections/blocks
20
+ * components. Sections are sorted by block position before conversion.
21
+ * Supports an optional override map to remap block types to custom section
22
+ * renderers.
23
+ */
24
+ export function blocksToSections(
25
+ blocks: Block[],
26
+ overrides: Record<string, { __resolveType?: string }> = {},
27
+ ): Section[] {
28
+ return [...blocks]
29
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
30
+ .map((block) => blockToSection(block, overrides))
31
+ .filter((s): s is Section => s !== null);
32
+ }
33
+
34
+ function blockToSection(
35
+ block: Block,
36
+ overrides: Record<string, { __resolveType?: string }>,
37
+ ): Section | null {
38
+ const content = (block.content ?? {}) as Record<string, unknown>;
39
+
40
+ if ("type" in block && block.type) {
41
+ if (overrides[block.type]) {
42
+ return toSection(overrides[block.type]?.__resolveType ?? block.type, content);
43
+ }
44
+
45
+ switch (block.type) {
46
+ case "paragraph":
47
+ return toSection(`${BASE}/Paragraph.tsx`, {
48
+ html: content.html,
49
+ text: content.text,
50
+ });
51
+
52
+ case "heading":
53
+ return toSection(`${BASE}/Heading.tsx`, {
54
+ text: content.text,
55
+ level: content.level,
56
+ });
57
+
58
+ case "list":
59
+ return toSection(`${BASE}/List.tsx`, {
60
+ items: content.items,
61
+ style: content.style,
62
+ });
63
+
64
+ case "divider":
65
+ return toSection(`${BASE}/Divider.tsx`, {});
66
+
67
+ case "quote":
68
+ return toSection(`${BASE}/Quote.tsx`, {
69
+ quote: content.quote,
70
+ text: content.text,
71
+ attribution: content.attribution,
72
+ source: content.source,
73
+ });
74
+
75
+ case "callout":
76
+ return toSection(`${BASE}/Callout.tsx`, {
77
+ title: content.title,
78
+ body: content.body,
79
+ variant: content.variant,
80
+ });
81
+
82
+ case "checklist":
83
+ return toSection(`${BASE}/Checklist.tsx`, {
84
+ title: content.title,
85
+ items: content.items,
86
+ });
87
+
88
+ case "steps":
89
+ return toSection(`${BASE}/Steps.tsx`, {
90
+ title: content.title,
91
+ steps: content.steps,
92
+ });
93
+
94
+ case "stat":
95
+ return toSection(`${BASE}/Stat.tsx`, {
96
+ value: content.value,
97
+ label: content.label,
98
+ description: content.description,
99
+ });
100
+
101
+ case "stat-group":
102
+ return toSection(`${BASE}/StatGroup.tsx`, {
103
+ stats: content.stats,
104
+ });
105
+
106
+ case "card-group":
107
+ return toSection(`${BASE}/CardGroup.tsx`, {
108
+ cards: content.cards,
109
+ });
110
+
111
+ case "comparison":
112
+ return toSection(`${BASE}/Comparison.tsx`, {
113
+ left: content.left,
114
+ right: content.right,
115
+ });
116
+
117
+ case "table":
118
+ return toSection(`${BASE}/Table.tsx`, {
119
+ headers: content.headers,
120
+ rows: content.rows,
121
+ });
122
+
123
+ case "image":
124
+ return toSection(`${BASE}/BlockImage.tsx`, {
125
+ url: content.url,
126
+ mobileUrl: content.mobileUrl,
127
+ alt: content.alt,
128
+ caption: content.caption,
129
+ size: content.size,
130
+ // Upstream does not forward this, which leaves every block-pipeline
131
+ // image lazy-loaded and the prop reachable only on a hand-placed
132
+ // BlockImage section.
133
+ highPriority: content.highPriority,
134
+ });
135
+
136
+ case "video":
137
+ return toSection(`${BASE}/Video.tsx`, {
138
+ url: content.url,
139
+ caption: content.caption,
140
+ });
141
+
142
+ case "code":
143
+ return toSection(`${BASE}/Code.tsx`, {
144
+ code: content.code,
145
+ language: content.language,
146
+ filename: content.filename,
147
+ });
148
+
149
+ case "cta":
150
+ return toSection(`${BASE}/Cta.tsx`, {
151
+ text: content.text,
152
+ href: content.href,
153
+ });
154
+
155
+ default:
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // System blocks (no explicit type) — render as paragraph if they have html/text
161
+ if ("system_block_id" in block) {
162
+ if (content.html) {
163
+ return toSection(`${BASE}/Paragraph.tsx`, {
164
+ html: content.html as string,
165
+ });
166
+ }
167
+ if (content.text) {
168
+ return toSection(`${BASE}/Paragraph.tsx`, {
169
+ text: content.text as string,
170
+ });
171
+ }
172
+ }
173
+
174
+ return null;
175
+ }
@@ -0,0 +1 @@
1
+ export const VALID_SORT_ORDERS = ["asc", "desc"];
@@ -0,0 +1,95 @@
1
+ /** An ISO 8601 date or date-time carrying no timezone designator. */
2
+ const ISO_WITHOUT_TIMEZONE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/;
3
+
4
+ /**
5
+ * Parses a CMS-authored date or date-time into a timestamp.
6
+ *
7
+ * `BlogPost.date` may be a bare `YYYY-MM-DD` or a full ISO 8601 timestamp, and
8
+ * `scheduledDatetime` is a date-time that may or may not carry an offset.
9
+ *
10
+ * Anything without a timezone designator is pinned to UTC, so the result never
11
+ * depends on the machine timezone. That matters for both shapes: per spec a
12
+ * bare date is already UTC, but an offset-less datetime is parsed as *local*
13
+ * time, which would otherwise reorder posts near a day boundary from one
14
+ * server to the next — and, for a scheduled post, move its go-live instant.
15
+ *
16
+ * Unparseable values fall back to 0 instead of leaking NaN into a comparator
17
+ * (a NaN result is treated as 0, so the post would never move). Callers that
18
+ * compare against "now" must reject 0 explicitly rather than let it through as
19
+ * a very old date.
20
+ */
21
+ export const dateToTime = (date: string) =>
22
+ new Date(
23
+ ISO_WITHOUT_TIMEZONE.test(date) ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` : date,
24
+ ).getTime() || 0;
25
+
26
+ /**
27
+ * An ISO 8601 date, optionally with a time and an offset. Anchored, grouped and
28
+ * deliberately narrow: `Date` accepts far more than this, and the extras are the
29
+ * problem — see `scheduledTime`.
30
+ */
31
+ const STRICT_ISO =
32
+ /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?(Z|[+-]\d{2}:\d{2})?$/;
33
+
34
+ const lastDayOfMonth = (year: number, month: number) =>
35
+ new Date(Date.UTC(year, month, 0)).getUTCDate();
36
+
37
+ /**
38
+ * Parses a scheduled go-live instant, or returns `null` if the value isn't one.
39
+ *
40
+ * Publishing needs a stricter parse than sorting does, because here a
41
+ * misreading doesn't reorder a list — it puts a post on the live site at the
42
+ * wrong moment. `Date` is lenient in two ways that both do exactly that:
43
+ *
44
+ * - It accepts non-ISO strings and parses them in *server-local* time, so
45
+ * `"Sep 1 2026"` would go live at a different instant on every machine —
46
+ * silently defeating the UTC pinning this module exists to guarantee. Worse,
47
+ * it accepts strings that aren't dates in any useful sense: `"0"` is the year
48
+ * 2000, i.e. already live.
49
+ * - It rolls calendar overflow forward instead of rejecting it, so a typo'd
50
+ * `"2026-02-31"` publishes on March 3rd.
51
+ *
52
+ * So the shape is matched against an anchored ISO pattern and the fields are
53
+ * range-checked before `Date` ever sees them. Anything else is `null`, and
54
+ * callers treat `null` as "not live" — a schedule we can't read is one we must
55
+ * not act on.
56
+ *
57
+ * A bare `YYYY-MM-DD` is accepted as midnight UTC: unlike the cases above it's
58
+ * an unambiguous ISO form, and rejecting it would strand a post forever over a
59
+ * missing time. `null` is used rather than 0 so that the Unix epoch stays a
60
+ * representable instant instead of being indistinguishable from a failure.
61
+ */
62
+ export const scheduledTime = (value: string): number | null => {
63
+ const match = STRICT_ISO.exec(value);
64
+
65
+ if (!match) {
66
+ return null;
67
+ }
68
+
69
+ const [, ...groups] = match;
70
+ const [year, month, day] = groups.slice(0, 3).map(Number);
71
+ const [hour, minute, second] = groups.slice(3, 6).map((part) => Number(part ?? 0));
72
+
73
+ if (
74
+ month < 1 ||
75
+ month > 12 ||
76
+ day < 1 ||
77
+ day > lastDayOfMonth(year, month) ||
78
+ hour > 23 ||
79
+ minute > 59 ||
80
+ second > 59
81
+ ) {
82
+ return null;
83
+ }
84
+
85
+ // Only pin UTC when the value carries no designator of its own; `dateToTime`
86
+ // isn't reused here because its 0-on-failure fallback is precisely the
87
+ // ambiguity this function exists to remove.
88
+ const parsed = new Date(
89
+ groups[6] ? value : `${value.includes("T") ? value : `${value}T00:00:00`}Z`,
90
+ ).getTime();
91
+
92
+ // Still reachable despite the checks above — an out-of-range offset such as
93
+ // `+99:00` matches the pattern but is not a real instant.
94
+ return Number.isNaN(parsed) ? null : parsed;
95
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Stricter, dependency-free HTML sanitizer for blocks that render CMS-provided
3
+ * markup in a table-like context (see Table.tsx). It is intentionally separate
4
+ * from the shared `sanitizeHtml` so hardening this path cannot regress the many
5
+ * other blocks that rely on the lighter sanitizer.
6
+ *
7
+ * On top of the shared sanitizer's guarantees it also:
8
+ * - Removes dangerous elements together with their content (script, style,
9
+ * iframe, object, embed, applet, form, svg, math, template, noscript, base,
10
+ * link, meta, frame, frameset), plus any leftover open/close/self-closing tags
11
+ * - Strips inline event-handler attributes (on*), srcdoc and inline style
12
+ * - Neutralizes javascript:, data: and vbscript: protocols in url-bearing
13
+ * attributes (href, src, action, formaction, xlink:href), decoding entities
14
+ * and stripping whitespace/control chars first so obfuscated schemes are
15
+ * caught, while harmless values like "data-*" or "javascriptX" are preserved
16
+ *
17
+ * Note: this is a pragmatic denylist — not a full HTML parser. Keep the content
18
+ * model simple (text + basic inline/formatting markup).
19
+ */
20
+ const DANGEROUS_ELEMENTS = [
21
+ "script",
22
+ "style",
23
+ "iframe",
24
+ "object",
25
+ "embed",
26
+ "applet",
27
+ "form",
28
+ "svg",
29
+ "math",
30
+ "template",
31
+ "noscript",
32
+ "base",
33
+ "link",
34
+ "meta",
35
+ "frame",
36
+ "frameset",
37
+ ];
38
+
39
+ const URL_ATTRS = "href|src|action|formaction|xlink:href";
40
+ const DANGEROUS_PROTOCOLS = "javascript|data|vbscript";
41
+
42
+ // Captures a url-bearing attribute and its value (double/single-quoted or bare).
43
+ const URL_ATTR_RE = new RegExp(`\\b(${URL_ATTRS})\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, "gi");
44
+ // A dangerous scheme must be a real scheme: name immediately followed by ":".
45
+ const DANGEROUS_SCHEME_RE = new RegExp(`^(?:${DANGEROUS_PROTOCOLS}):`, "i");
46
+
47
+ function toCodePoint(n: number): string {
48
+ return Number.isFinite(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
49
+ }
50
+
51
+ /** Decode the HTML entities most commonly used to smuggle a scheme past a filter. */
52
+ function decodeEntities(value: string): string {
53
+ return value
54
+ .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => toCodePoint(parseInt(hex, 16)))
55
+ .replace(/&#(\d+);?/g, (_, dec) => toCodePoint(parseInt(dec, 10)))
56
+ .replace(/&colon;/gi, ":")
57
+ .replace(/&tab;/gi, "\t")
58
+ .replace(/&newline;/gi, "\n");
59
+ }
60
+
61
+ /**
62
+ * True when an attribute value resolves to a dangerous URL scheme. The value is
63
+ * decoded and stripped of whitespace/control chars first, since browsers ignore
64
+ * those within a scheme (e.g. `java\tscript:` and `&#106;avascript:`).
65
+ */
66
+ function hasDangerousScheme(value: string): boolean {
67
+ const normalized = decodeEntities(value)
68
+ // Control chars are intentional: browsers strip C0 controls/whitespace from
69
+ // a URL scheme, so an attacker can hide one inside `javascript:`.
70
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: see above
71
+ .replace(/[\s\u0000-\u001f]+/g, "")
72
+ .toLowerCase();
73
+ return DANGEROUS_SCHEME_RE.test(normalized);
74
+ }
75
+
76
+ export function hardSanitize(raw: string | null | undefined): string {
77
+ if (!raw) return "";
78
+
79
+ let html = raw;
80
+
81
+ for (const tag of DANGEROUS_ELEMENTS) {
82
+ // Remove the element with its content, then any stray open/close/self-closing tag.
83
+ html = html
84
+ .replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "")
85
+ .replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"), "");
86
+ }
87
+
88
+ return (
89
+ html
90
+ // Inline event handlers (onclick, onerror, …)
91
+ .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
92
+ // srcdoc (smuggles an inline document into iframes) and inline styles
93
+ .replace(/\s+(srcdoc|style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
94
+ // Neutralize dangerous protocols in url-bearing attributes. One pass handles
95
+ // quoted and unquoted values identically: decode/normalize, then require a
96
+ // real dangerous scheme followed by ":" (so harmless "data-*"/"javascriptX"
97
+ // values are left intact).
98
+ .replace(URL_ATTR_RE, (match, attr, value) => {
99
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : "";
100
+ const inner = quote ? value.slice(1, -1) : value;
101
+ if (!hasDangerousScheme(inner)) return match;
102
+ const q = quote || '"';
103
+ return `${attr}=${q}#${q}`;
104
+ })
105
+ );
106
+ }
@@ -0,0 +1,118 @@
1
+ import type { Author, BlogPost, Category, Publisher } from "../types";
2
+
3
+ const toAuthor = (author: Author) => {
4
+ const type = author.type ?? "Person";
5
+ return {
6
+ "@type": type,
7
+ name: author.name,
8
+ // url and image are valid on both Person and Organization
9
+ ...(author.url ? { url: author.url } : {}),
10
+ ...(author.avatar ? { image: author.avatar } : {}),
11
+ // jobTitle and worksFor are Person-only properties
12
+ ...(type === "Person" && author.jobTitle ? { jobTitle: author.jobTitle } : {}),
13
+ ...(type === "Person" && author.company
14
+ ? { worksFor: { "@type": "Organization" as const, name: author.company } }
15
+ : {}),
16
+ };
17
+ };
18
+
19
+ export const toOrganization = (publisher: Publisher) => ({
20
+ "@type": "Organization" as const,
21
+ name: publisher.name,
22
+ ...(publisher.url ? { url: publisher.url } : {}),
23
+ ...(publisher.logo ? { logo: { "@type": "ImageObject" as const, url: publisher.logo } } : {}),
24
+ });
25
+
26
+ /**
27
+ * Normalizes a page URL for SEO use (canonical and JSON-LD): strips the query
28
+ * string and hash, keeping only origin + pathname, and replaces the origin
29
+ * with the canonical base's when one is configured. Relative URLs are
30
+ * resolved against the canonical base; without a base, relative URLs are
31
+ * returned unchanged.
32
+ */
33
+ export const withCanonicalBase = (url: string, canonicalBaseUrl?: string) => {
34
+ if (!canonicalBaseUrl) {
35
+ if (!URL.canParse(url)) {
36
+ return url;
37
+ }
38
+ const { origin, pathname } = new URL(url);
39
+ return origin + pathname;
40
+ }
41
+ const { pathname } = new URL(url, canonicalBaseUrl);
42
+ return new URL(pathname, canonicalBaseUrl).href;
43
+ };
44
+
45
+ /**
46
+ * Maps a BlogPost to a schema.org BlogPosting node, following
47
+ * https://developers.google.com/search/docs/appearance/structured-data/article
48
+ *
49
+ * The "@context" is intentionally omitted: the Seo component adds it when
50
+ * serializing the top-level JSON-LD object.
51
+ */
52
+ export const toBlogPosting = (post: BlogPost, url?: string, publisher?: Publisher) => {
53
+ const image = post.seo?.image || post.image;
54
+ const categories = post.categories?.map((category) => category.name).filter(Boolean);
55
+
56
+ // AggregateRating requires ratingValue and ratingCount/reviewCount;
57
+ // InteractionCounter requires userInteractionCount. Posts may carry empty
58
+ // {"@type": ...} stubs, which are invalid in the Rich Results Test.
59
+ const aggregateRating =
60
+ post.aggregateRating?.ratingValue != null &&
61
+ (post.aggregateRating.ratingCount != null || post.aggregateRating.reviewCount != null)
62
+ ? post.aggregateRating
63
+ : undefined;
64
+ const interactionStatistic =
65
+ post.interactionStatistic?.userInteractionCount != null ? post.interactionStatistic : undefined;
66
+
67
+ return {
68
+ "@type": "BlogPosting" as const,
69
+ headline: post.title,
70
+ ...(post.excerpt ? { description: post.excerpt } : {}),
71
+ ...(image ? { image: [image] } : {}),
72
+ ...(post.date ? { datePublished: post.date } : {}),
73
+ ...(post.dateModified ? { dateModified: post.dateModified } : {}),
74
+ ...(post.authors?.length ? { author: post.authors.map(toAuthor) } : {}),
75
+ ...(publisher?.name ? { publisher: toOrganization(publisher) } : {}),
76
+ ...(categories?.length ? { articleSection: categories } : {}),
77
+ ...(post.readTime && post.readTime > 0 && Number.isFinite(post.readTime)
78
+ ? { timeRequired: `PT${post.readTime}M` }
79
+ : {}),
80
+ ...(url ? { url, mainEntityOfPage: { "@type": "WebPage" as const, "@id": url } } : {}),
81
+ ...(aggregateRating ? { aggregateRating } : {}),
82
+ ...(interactionStatistic ? { interactionStatistic } : {}),
83
+ };
84
+ };
85
+
86
+ const humanize = (slug: string) =>
87
+ decodeURIComponent(slug)
88
+ .replace(/[-_]+/g, " ")
89
+ .replace(/^./, (char) => char.toUpperCase());
90
+
91
+ /**
92
+ * Builds a schema.org BreadcrumbList from the pathname of the given absolute
93
+ * URL. Intermediate segments resolve their names against the known categories
94
+ * (falling back to a humanized slug) and link to absolute URLs; the last item
95
+ * uses the real page name and omits "item", as allowed by Google.
96
+ */
97
+ export const toBreadcrumbList = (
98
+ url: string,
99
+ { currentName, categories }: { currentName?: string; categories?: Category[] } = {},
100
+ ) => {
101
+ const { origin, pathname } = new URL(url);
102
+ const segments = pathname.split("/").filter(Boolean);
103
+
104
+ const nameOf = (segment: string) =>
105
+ categories?.find((category) => category.slug === segment)?.name ?? humanize(segment);
106
+
107
+ const itemListElement = segments.map((segment, index) => {
108
+ const isLast = index === segments.length - 1;
109
+ return {
110
+ "@type": "ListItem" as const,
111
+ position: index + 1,
112
+ name: isLast ? currentName || nameOf(segment) : nameOf(segment),
113
+ ...(isLast ? {} : { item: `${origin}/${segments.slice(0, index + 1).join("/")}` }),
114
+ };
115
+ });
116
+
117
+ return { "@type": "BreadcrumbList" as const, itemListElement };
118
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Lightweight allowlist-based HTML sanitizer that works in both server (SSR)
3
+ * and browser contexts without external dependencies.
4
+ *
5
+ * Strips the most dangerous XSS vectors from CMS-provided HTML:
6
+ * - <script> and <style> blocks
7
+ * - Inline event-handler attributes (on*)
8
+ * - javascript: and data: protocol values in href/src/action attributes
9
+ */
10
+ export function sanitizeHtml(raw: string | null | undefined): string {
11
+ if (!raw) return "";
12
+ return raw
13
+ .replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
14
+ .replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
15
+ .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
16
+ .replace(/\b(href|src|action)\s*=\s*"(?:javascript|data)[^"]*"/gi, '$1="#"')
17
+ .replace(/\b(href|src|action)\s*=\s*'(?:javascript|data)[^']*'/gi, "$1='#'");
18
+ }
19
+
20
+ /**
21
+ * Returns a safe href: allows http, https, mailto, tel, and hash-only links.
22
+ * Falls back to "#" for anything else (e.g. javascript: URIs).
23
+ */
24
+ export function sanitizeHref(href: string | null | undefined): string {
25
+ if (!href) return "#";
26
+ const trimmed = href.trim();
27
+ if (
28
+ /^https?:\/\//i.test(trimmed) ||
29
+ /^mailto:/i.test(trimmed) ||
30
+ /^tel:/i.test(trimmed) ||
31
+ /^#/.test(trimmed) ||
32
+ /^\//.test(trimmed)
33
+ ) {
34
+ return trimmed;
35
+ }
36
+ return "#";
37
+ }