@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,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(/:/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 `javascript:`).
|
|
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
|
+
}
|