@coywolf/emdash 0.0.0-stage → 0.35.13
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 +201 -2
- package/package.json +70 -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.test.ts +187 -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/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -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.test.mjs +287 -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.test.mjs +103 -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.test.ts +152 -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.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -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.test.ts +30 -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.test.ts +64 -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/headings.test.ts +159 -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.test.mjs +224 -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.test.mjs +670 -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.test.ts +50 -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.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -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.test.mjs +522 -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,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviews module, pure logic: cleaning block fields (and legacy WordPress
|
|
3
|
+
* review markers) into a Review, the review box markup and CSS shared by the
|
|
4
|
+
* site renderer and the admin preview, custom CSS sanitizing, and the
|
|
5
|
+
* Schema.org nodes. No EmDash or Astro imports, so it runs in tests and in the
|
|
6
|
+
* admin bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const BLOCK_TYPE = "coywolf-review";
|
|
10
|
+
export const STYLE_SETTING = "reviewsStyle";
|
|
11
|
+
export const DEFAULT_ACCENT = "#2C8452";
|
|
12
|
+
export const DEFAULT_PROS_HEADING = "What I liked most";
|
|
13
|
+
export const DEFAULT_CONS_HEADING = "Could be better";
|
|
14
|
+
export const BEST_RATING = 5;
|
|
15
|
+
export const WORST_RATING = 0;
|
|
16
|
+
export const MAX_CUSTOM_CSS = 20_000;
|
|
17
|
+
const MAX_ITEMS = 20;
|
|
18
|
+
const MAX_REVIEWS = 20;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Types Google accepts as `itemReviewed` for review snippets
|
|
22
|
+
* (developers.google.com/search/docs/appearance/structured-data/review-snippet).
|
|
23
|
+
* Product first: it's the default and the only type eligible for pros and cons.
|
|
24
|
+
*/
|
|
25
|
+
export const ITEM_TYPES: Array<[string, string]> = [
|
|
26
|
+
["Product", "Product"],
|
|
27
|
+
["SoftwareApplication", "Software application"],
|
|
28
|
+
["Book", "Book"],
|
|
29
|
+
["Course", "Course"],
|
|
30
|
+
["Movie", "Movie"],
|
|
31
|
+
["Game", "Game"],
|
|
32
|
+
["Event", "Event"],
|
|
33
|
+
["Recipe", "Recipe"],
|
|
34
|
+
["LocalBusiness", "Local business"],
|
|
35
|
+
["Organization", "Organization"],
|
|
36
|
+
["CreativeWorkSeries", "Creative work series"],
|
|
37
|
+
["CreativeWorkSeason", "Creative work season"],
|
|
38
|
+
["Episode", "Episode"],
|
|
39
|
+
["MediaObject", "Media object"],
|
|
40
|
+
["HowTo", "How-to"],
|
|
41
|
+
];
|
|
42
|
+
const ITEM_TYPE_SET = new Set(ITEM_TYPES.map(([t]) => t));
|
|
43
|
+
/** schema.org `brand` applies to Product, Service, Organization and Person. */
|
|
44
|
+
const BRAND_TYPES = new Set(["Product", "Organization", "LocalBusiness"]);
|
|
45
|
+
/** Reviewing these on their own site is "self-serving" and ineligible for review snippets. */
|
|
46
|
+
const SELF_SERVING_TYPES = new Set(["Organization", "LocalBusiness"]);
|
|
47
|
+
|
|
48
|
+
export interface ReviewStyle {
|
|
49
|
+
accent: string;
|
|
50
|
+
css: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A cleaned review, ready to render or turn into schema. */
|
|
54
|
+
export interface Review {
|
|
55
|
+
itemName: string;
|
|
56
|
+
itemType: string;
|
|
57
|
+
brand?: string;
|
|
58
|
+
itemUrl?: string;
|
|
59
|
+
/** Absolute http(s) URL or a root-relative path (resolved against the site for schema). */
|
|
60
|
+
image?: string;
|
|
61
|
+
/** 0–5 in 0.1 steps; null when missing (the box shows no badge and there's no schema). */
|
|
62
|
+
rating: number | null;
|
|
63
|
+
prosHeading: string;
|
|
64
|
+
pros: string[];
|
|
65
|
+
consHeading: string;
|
|
66
|
+
cons: string[];
|
|
67
|
+
summary?: string;
|
|
68
|
+
headingLevel: 2 | 3 | 4;
|
|
69
|
+
/** Book details (schema only). */
|
|
70
|
+
bookAuthor?: string;
|
|
71
|
+
isbn?: string;
|
|
72
|
+
bookPublisher?: string;
|
|
73
|
+
genre?: string;
|
|
74
|
+
copyrightYear?: number;
|
|
75
|
+
/** Software details (schema only): "macOS, Windows". */
|
|
76
|
+
operatingSystem?: string;
|
|
77
|
+
applicationCategory?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Cleaning ─────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“" };
|
|
83
|
+
|
|
84
|
+
/** Decode the HTML entities WordPress content commonly holds. Unknown named entities are left alone. */
|
|
85
|
+
export function decodeEntities(text: string): string {
|
|
86
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
|
|
87
|
+
if (code[0] === "#") {
|
|
88
|
+
const n = code[1] === "x" || code[1] === "X" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
|
|
89
|
+
return Number.isFinite(n) && n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : "";
|
|
90
|
+
}
|
|
91
|
+
return ENTITIES[code.toLowerCase()] ?? whole;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const clean = (value: unknown, max: number): string => {
|
|
96
|
+
if (typeof value !== "string" && typeof value !== "number") return "";
|
|
97
|
+
const text = String(value)
|
|
98
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip control characters.
|
|
99
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.trim();
|
|
102
|
+
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** HTML to plain text: drop tags (and script/style bodies), decode entities, collapse whitespace. */
|
|
106
|
+
export function htmlToText(html: string): string {
|
|
107
|
+
return decodeEntities(
|
|
108
|
+
html
|
|
109
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
|
|
110
|
+
.replace(/<br\s*\/?>/gi, " ")
|
|
111
|
+
.replace(/<[^>]*>/g, ""),
|
|
112
|
+
)
|
|
113
|
+
.replace(/\s+/g, " ")
|
|
114
|
+
.trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List item text from a WordPress `<ul><li>…</li></ul>` field. Nested markup
|
|
119
|
+
* is reduced to text; without any <li>, each line (or <br>/<p>) is an item.
|
|
120
|
+
*/
|
|
121
|
+
export function listFromHtml(html: unknown): string[] {
|
|
122
|
+
if (typeof html !== "string" || !html.trim()) return [];
|
|
123
|
+
const source = html.slice(0, 50_000);
|
|
124
|
+
const items = [...source.matchAll(/<li\b[^>]*>([\s\S]*?)(?=<\/li\s*>|<li\b|<\/[uo]l\s*>|$)/gi)].map((m) => htmlToText(m[1]));
|
|
125
|
+
const list = items.length ? items : source.split(/<br\s*\/?>|<\/p\s*>|\n/i).map(htmlToText);
|
|
126
|
+
return list.map((t) => clean(t, 300)).filter(Boolean).slice(0, MAX_ITEMS);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One item per line (editor textarea), or an array of strings. Leading bullets ("- ", "• ", "* ") are dropped. */
|
|
130
|
+
export function listFromLines(value: unknown): string[] {
|
|
131
|
+
const lines = Array.isArray(value) ? value : typeof value === "string" ? value.split(/\r?\n/) : [];
|
|
132
|
+
return lines
|
|
133
|
+
.map((line) => clean(typeof line === "string" ? line.replace(/^\s*(?:[-*•–]\s+)/, "") : line, 300))
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.slice(0, MAX_ITEMS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A rating on the 0–5 scale, clamped and rounded to one decimal (4.7 stays
|
|
140
|
+
* 4.7, 4.25 becomes 4.3); null when missing or not a number. Half steps saved
|
|
141
|
+
* before 0.11.0 ("4.5", "4") read the same as before.
|
|
142
|
+
*/
|
|
143
|
+
export function parseRating(value: unknown): number | null {
|
|
144
|
+
if (value === null || value === undefined) return null;
|
|
145
|
+
const text = typeof value === "string" ? value.trim().replace(",", ".") : null;
|
|
146
|
+
if (text === "") return null;
|
|
147
|
+
const n = typeof value === "number" ? value : text !== null ? Number(text) : Number.NaN;
|
|
148
|
+
if (!Number.isFinite(n)) return null;
|
|
149
|
+
return Math.round(Math.min(BEST_RATING, Math.max(WORST_RATING, n)) * 10) / 10;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** "4.7", "5" — no trailing ".0". */
|
|
153
|
+
export function formatRating(rating: number): string {
|
|
154
|
+
return Number.isInteger(rating) ? String(rating) : rating.toFixed(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The block's stored rating value: one decimal, always with the ".0"
|
|
159
|
+
* ("4.7", "5.0"), or "" when there's no rating. The editor's rating menu uses
|
|
160
|
+
* these values; whole numbers need the ".0" because the menu lists integer-like
|
|
161
|
+
* values out of order ("0" … "5" before "4.9").
|
|
162
|
+
*/
|
|
163
|
+
export function ratingValue(value: unknown): string {
|
|
164
|
+
const rating = parseRating(value);
|
|
165
|
+
return rating === null ? "" : rating.toFixed(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The editor's rating menu: 5.0 down to 0.0 in 0.1 steps. */
|
|
169
|
+
export const RATING_OPTIONS: Array<{ value: string; label: string }> = Array.from({ length: BEST_RATING * 10 + 1 }, (_, i) => (BEST_RATING * 10 - i) / 10).map((n) => ({
|
|
170
|
+
value: n.toFixed(1),
|
|
171
|
+
label: `${formatRating(n)} out of ${BEST_RATING}`,
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rewrite each `coywolf-review` block's rating to its menu value ("4" →
|
|
176
|
+
* "4.0", 4.7 → "4.7"), so reviews saved with the old half-step menu (or
|
|
177
|
+
* imported) show their rating in the editor. Returns null when nothing changed.
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeReviewRatings<T>(data: T): T | null {
|
|
180
|
+
let changed = false;
|
|
181
|
+
const visit = (value: unknown, depth: number): unknown => {
|
|
182
|
+
if (depth > 12 || !value || typeof value !== "object") return value;
|
|
183
|
+
if (Array.isArray(value)) {
|
|
184
|
+
let out: unknown[] | null = null;
|
|
185
|
+
value.forEach((v, i) => {
|
|
186
|
+
const next = visit(v, depth + 1);
|
|
187
|
+
if (next !== v) {
|
|
188
|
+
out ??= value.slice();
|
|
189
|
+
out[i] = next;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return out ?? value;
|
|
193
|
+
}
|
|
194
|
+
const obj = value as Record<string, unknown>;
|
|
195
|
+
if (obj._type === BLOCK_TYPE) {
|
|
196
|
+
const next = ratingValue(obj.rating);
|
|
197
|
+
// Leave missing or unreadable values alone.
|
|
198
|
+
if (!next || next === obj.rating) return obj;
|
|
199
|
+
changed = true;
|
|
200
|
+
return { ...obj, rating: next };
|
|
201
|
+
}
|
|
202
|
+
let out: Record<string, unknown> | null = null;
|
|
203
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
204
|
+
const next = visit(v, depth + 1);
|
|
205
|
+
if (next !== v) {
|
|
206
|
+
out ??= { ...obj };
|
|
207
|
+
out[k] = next;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out ?? obj;
|
|
211
|
+
};
|
|
212
|
+
const result = visit(data, 0) as T;
|
|
213
|
+
return changed ? result : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** An absolute http(s) URL (no credentials), or undefined. */
|
|
217
|
+
export function httpUrl(value: unknown): string | undefined {
|
|
218
|
+
if (typeof value !== "string") return undefined;
|
|
219
|
+
const text = value.trim();
|
|
220
|
+
if (!/^https?:\/\/[^\s]+$/i.test(text) || text.length > 2000) return undefined;
|
|
221
|
+
try {
|
|
222
|
+
const url = new URL(text);
|
|
223
|
+
if (url.username || url.password) return undefined;
|
|
224
|
+
return url.href;
|
|
225
|
+
} catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** An absolute http(s) URL or a root-relative path. */
|
|
231
|
+
function imageRef(value: unknown): string | undefined {
|
|
232
|
+
if (typeof value === "string" && /^\/(?!\/)[^\s]*$/.test(value.trim()) && value.length <= 2000) return value.trim();
|
|
233
|
+
// media_picker values may be objects with a url.
|
|
234
|
+
if (value && typeof value === "object" && "url" in value) return imageRef((value as { url: unknown }).url);
|
|
235
|
+
return httpUrl(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Clean a `coywolf-review` block (or the same shape from a theme) into a
|
|
240
|
+
* Review. Returns null when there's nothing to show (no rating, no pros, no
|
|
241
|
+
* cons, no summary).
|
|
242
|
+
*/
|
|
243
|
+
export function normalizeReview(input: unknown): Review | null {
|
|
244
|
+
if (!input || typeof input !== "object") return null;
|
|
245
|
+
const v = input as Record<string, unknown>;
|
|
246
|
+
const itemType = typeof v.itemType === "string" && ITEM_TYPE_SET.has(v.itemType) ? v.itemType : "Product";
|
|
247
|
+
const level = Number(v.headingLevel);
|
|
248
|
+
const review: Review = {
|
|
249
|
+
itemName: clean(v.itemName ?? v.name, 200),
|
|
250
|
+
itemType,
|
|
251
|
+
rating: parseRating(v.rating),
|
|
252
|
+
prosHeading: clean(v.prosHeading, 80) || DEFAULT_PROS_HEADING,
|
|
253
|
+
pros: listFromLines(v.pros),
|
|
254
|
+
consHeading: clean(v.consHeading, 80) || DEFAULT_CONS_HEADING,
|
|
255
|
+
cons: listFromLines(v.cons),
|
|
256
|
+
headingLevel: level === 3 || level === 4 ? level : 2,
|
|
257
|
+
};
|
|
258
|
+
const brand = clean(v.brand, 100);
|
|
259
|
+
if (brand) review.brand = brand;
|
|
260
|
+
const itemUrl = httpUrl(v.itemUrl ?? v.url);
|
|
261
|
+
if (itemUrl) review.itemUrl = itemUrl;
|
|
262
|
+
const image = imageRef(v.image);
|
|
263
|
+
if (image) review.image = image;
|
|
264
|
+
const summary = clean(v.summary, 2000);
|
|
265
|
+
if (summary) review.summary = summary;
|
|
266
|
+
if (itemType === "Book") {
|
|
267
|
+
const author = clean(v.bookAuthor, 200);
|
|
268
|
+
if (author) review.bookAuthor = author;
|
|
269
|
+
const isbn = clean(v.isbn, 20).replace(/[^0-9Xx-]/g, "");
|
|
270
|
+
if (isbn) review.isbn = isbn;
|
|
271
|
+
const publisher = clean(v.bookPublisher, 200);
|
|
272
|
+
if (publisher) review.bookPublisher = publisher;
|
|
273
|
+
const genre = clean(v.genre, 100);
|
|
274
|
+
if (genre) review.genre = genre;
|
|
275
|
+
const year = Number(clean(v.copyrightYear, 4));
|
|
276
|
+
if (Number.isInteger(year) && year >= 1000 && year <= 9999) review.copyrightYear = year;
|
|
277
|
+
}
|
|
278
|
+
if (itemType === "SoftwareApplication") {
|
|
279
|
+
const os = clean(v.operatingSystem, 300);
|
|
280
|
+
if (os) review.operatingSystem = os;
|
|
281
|
+
const category = clean(v.applicationCategory, 100);
|
|
282
|
+
if (category) review.applicationCategory = category;
|
|
283
|
+
}
|
|
284
|
+
if (review.rating === null && !review.pros.length && !review.cons.length && !review.summary) return null;
|
|
285
|
+
return review;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The fields of a legacy WordPress review marker (Coywolf Custom Blocks:
|
|
290
|
+
* `{ name, brand, rating, strengths, shortcomings }`, pros and cons as <ul>
|
|
291
|
+
* HTML, plus the book fields `author`, `isbn`, `publisher`, `genre`,
|
|
292
|
+
* `copyright` and the software fields `os`, `category`) in the block's shape,
|
|
293
|
+
* for <Review> and `page.coywolf.reviews`.
|
|
294
|
+
*/
|
|
295
|
+
export function reviewFromAttrs(attrs: unknown): Record<string, unknown> {
|
|
296
|
+
if (!attrs || typeof attrs !== "object") return {};
|
|
297
|
+
const a = attrs as Record<string, unknown>;
|
|
298
|
+
const text = (x: unknown) => (typeof x === "string" ? htmlToText(x) : typeof x === "number" ? String(x) : "");
|
|
299
|
+
return {
|
|
300
|
+
itemName: text(a.name),
|
|
301
|
+
itemType: typeof a.itemType === "string" ? a.itemType : "Product",
|
|
302
|
+
brand: text(a.brand),
|
|
303
|
+
itemUrl: typeof a.url === "string" ? a.url : undefined,
|
|
304
|
+
image: typeof a.image === "string" ? a.image : undefined,
|
|
305
|
+
rating: a.rating,
|
|
306
|
+
pros: listFromHtml(a.strengths),
|
|
307
|
+
cons: listFromHtml(a.shortcomings),
|
|
308
|
+
...(typeof a.summary === "string" ? { summary: htmlToText(a.summary) } : {}),
|
|
309
|
+
bookAuthor: text(a.author),
|
|
310
|
+
isbn: text(a.isbn),
|
|
311
|
+
bookPublisher: text(a.publisher),
|
|
312
|
+
genre: text(a.genre),
|
|
313
|
+
copyrightYear: text(a.copyright),
|
|
314
|
+
operatingSystem: text(a.os).replace(/"/g, "").replace(/\s*,\s*/g, ", "),
|
|
315
|
+
applicationCategory: text(a.category),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Every `coywolf-review` block in an entry's data (any Portable Text field), in document order. */
|
|
320
|
+
export function findReviewBlocks(data: unknown, limit = MAX_REVIEWS): Record<string, unknown>[] {
|
|
321
|
+
const out: Record<string, unknown>[] = [];
|
|
322
|
+
const visit = (value: unknown, depth: number) => {
|
|
323
|
+
if (out.length >= limit || depth > 12 || !value || typeof value !== "object") return;
|
|
324
|
+
if (Array.isArray(value)) {
|
|
325
|
+
for (const v of value) visit(v, depth + 1);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const obj = value as Record<string, unknown>;
|
|
329
|
+
if (obj._type === BLOCK_TYPE) {
|
|
330
|
+
out.push(obj);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
for (const v of Object.values(obj)) visit(v, depth + 1);
|
|
334
|
+
};
|
|
335
|
+
visit(data, 0);
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Style settings ───────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
export function isAccent(value: unknown): value is string {
|
|
342
|
+
return typeof value === "string" && /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Make custom CSS safe to put inside a <style> element: no `</style` (so it
|
|
347
|
+
* can't close the element), no HTML comment markers, no NUL, at most 20 KB.
|
|
348
|
+
* Rules aren't rewritten; by convention they target `.cw-review …`.
|
|
349
|
+
*/
|
|
350
|
+
export function sanitizeCustomCss(value: unknown): string {
|
|
351
|
+
if (typeof value !== "string") return "";
|
|
352
|
+
let css = value.replace(/\u0000/g, "").slice(0, MAX_CUSTOM_CSS);
|
|
353
|
+
let previous: string;
|
|
354
|
+
do {
|
|
355
|
+
previous = css;
|
|
356
|
+
css = css.replace(/<\/style/gi, "").replace(/<!--/g, "").replace(/-->/g, "");
|
|
357
|
+
} while (css !== previous);
|
|
358
|
+
return css.trim();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function normalizeStyle(value: unknown): ReviewStyle {
|
|
362
|
+
const v = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
363
|
+
return { accent: isAccent(v.accent) ? v.accent : DEFAULT_ACCENT, css: sanitizeCustomCss(v.css) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Markup ───────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export function escapeHtml(text: string): string {
|
|
369
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The review box. Visually the Coywolf rating summary (badge with the score,
|
|
374
|
+
* "4.5 out of 5" under it, then the two lists); every part has a stable
|
|
375
|
+
* `cw-review__*` class for custom CSS.
|
|
376
|
+
*/
|
|
377
|
+
export function renderReviewHtml(review: Review): string {
|
|
378
|
+
const h = `h${review.headingLevel}`;
|
|
379
|
+
const label = review.itemName ? `Review of ${review.itemName}` : "Review";
|
|
380
|
+
const list = (kind: "pros" | "cons", heading: string, items: string[]) =>
|
|
381
|
+
items.length
|
|
382
|
+
? `<div class="cw-review__col cw-review__${kind}"><${h} class="cw-review__heading">${escapeHtml(heading)}</${h}><ul class="cw-review__list">${items
|
|
383
|
+
.map((item) => `<li class="cw-review__item">${escapeHtml(item)}</li>`)
|
|
384
|
+
.join("")}</ul></div>`
|
|
385
|
+
: "";
|
|
386
|
+
const score = review.rating === null ? null : formatRating(review.rating);
|
|
387
|
+
const badge =
|
|
388
|
+
score === null
|
|
389
|
+
? ""
|
|
390
|
+
: `<div class="cw-review__rating"><span class="cw-review__badge" aria-hidden="true">${score}</span><span class="cw-review__caption"><span class="cw-review__sr">Rated </span>${score} out of ${BEST_RATING}</span></div>`;
|
|
391
|
+
const columns = list("pros", review.prosHeading, review.pros) + list("cons", review.consHeading, review.cons);
|
|
392
|
+
return `<section class="cw-review" aria-label="${escapeHtml(label)}">${badge}${columns ? `<div class="cw-review__columns">${columns}</div>` : ""}${
|
|
393
|
+
review.summary ? `<p class="cw-review__summary">${escapeHtml(review.summary)}</p>` : ""
|
|
394
|
+
}</section>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The box's CSS. Light/dark colors use light-dark(), so the box follows the
|
|
399
|
+
* page's own `color-scheme` (a light-only theme stays light). Every design value is a custom property read with a fallback
|
|
400
|
+
* (never set by this CSS), so a site can set them on `.cw-review`, on a
|
|
401
|
+
* wrapper, or on :root. The accent setting is the badge's fallback color.
|
|
402
|
+
*/
|
|
403
|
+
export function reviewCss(accent: string = DEFAULT_ACCENT): string {
|
|
404
|
+
const a = isAccent(accent) ? accent : DEFAULT_ACCENT;
|
|
405
|
+
return `.cw-review{box-sizing:border-box;display:flex;flex-wrap:wrap;align-items:center;gap:var(--cw-review-gap,1rem 1.5rem);margin:var(--cw-review-margin,0 0 1.5rem);padding:var(--cw-review-padding,2rem 1rem);background:var(--cw-review-bg,light-dark(#fff,var(--cw-review-bg-dark,#1d1f23)));color:var(--cw-review-color,inherit);border:var(--cw-review-border,1px solid var(--cw-review-border-color,light-dark(#dfe0e3,var(--cw-review-border-color-dark,#3a3d44))));border-radius:var(--cw-review-radius,12px);font-family:var(--cw-review-font,inherit)}
|
|
406
|
+
.cw-review *{box-sizing:border-box}
|
|
407
|
+
.cw-review__rating{flex:1 0 var(--cw-review-rating-width,7rem);margin:0;text-align:center}
|
|
408
|
+
.cw-review__badge{display:inline-block;padding:var(--cw-review-badge-padding,.5rem 1rem);background:var(--cw-review-accent,${a});color:var(--cw-review-badge-color,#fff);border-radius:var(--cw-review-badge-radius,10%);font-size:var(--cw-review-badge-size,3.5rem);font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
|
|
409
|
+
.cw-review__caption{display:block;margin-top:.75rem;font-size:var(--cw-review-caption-size,1rem);color:var(--cw-review-caption-color,inherit)}
|
|
410
|
+
.cw-review__sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
|
|
411
|
+
.cw-review__columns{flex:6 1 var(--cw-review-stack-at,26rem);display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--cw-review-column-min,13rem)),1fr));gap:var(--cw-review-column-gap,1rem 1.5rem);align-self:start}
|
|
412
|
+
.cw-review__col{min-width:0}
|
|
413
|
+
.cw-review .cw-review__heading{margin:0;padding:0;font-size:var(--cw-review-heading-size,1.2rem);line-height:1.3;text-transform:var(--cw-review-heading-transform,uppercase);color:var(--cw-review-heading-color,inherit)}
|
|
414
|
+
.cw-review .cw-review__list{margin:.75rem 0 0 1.25rem;padding:0;list-style:var(--cw-review-list-style,square)}
|
|
415
|
+
.cw-review .cw-review__item{margin:0;padding:.2rem 0;font-size:var(--cw-review-list-size,1rem);font-weight:500;color:var(--cw-review-list-color,light-dark(#555,var(--cw-review-list-color-dark,#c9ccd1)))}
|
|
416
|
+
.cw-review .cw-review__summary{flex:1 1 100%;margin:0;color:var(--cw-review-summary-color,inherit)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Built-in CSS followed by the site's custom CSS (so it overrides). */
|
|
420
|
+
export function pageCss(style: ReviewStyle): string {
|
|
421
|
+
const custom = sanitizeCustomCss(style.css);
|
|
422
|
+
return reviewCss(style.accent) + (custom ? `\n/* Custom CSS (Reviews page) */\n${custom}` : "");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A sample review for the admin preview. */
|
|
426
|
+
export const SAMPLE_REVIEW: Review = {
|
|
427
|
+
itemName: "Example Coffee Grinder",
|
|
428
|
+
itemType: "Product",
|
|
429
|
+
brand: "Example",
|
|
430
|
+
rating: 4.5,
|
|
431
|
+
prosHeading: DEFAULT_PROS_HEADING,
|
|
432
|
+
pros: ["Consistent grind from espresso to French press", "Quiet, quick, and easy to clean", "Small enough for any counter"],
|
|
433
|
+
consHeading: DEFAULT_CONS_HEADING,
|
|
434
|
+
cons: ["The hopper only holds 8 oz of beans"],
|
|
435
|
+
headingLevel: 2,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// ── Schema ───────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
export type Node = Record<string, unknown>;
|
|
441
|
+
|
|
442
|
+
export interface ReviewSchemaContext {
|
|
443
|
+
/** The page URL (canonical), without a fragment. */
|
|
444
|
+
pageUrl: string;
|
|
445
|
+
/** Site origin, for root-relative image paths and the self-serving check. */
|
|
446
|
+
origin: string;
|
|
447
|
+
/** Reference (or node) for the review's author: the Article's author(s), else the publisher. */
|
|
448
|
+
author: unknown;
|
|
449
|
+
/** Reference (or node) for the publisher. */
|
|
450
|
+
publisher?: unknown;
|
|
451
|
+
/** Publisher name, for the self-serving check. */
|
|
452
|
+
publisherName?: string;
|
|
453
|
+
datePublished?: string | null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Is this review of the site's own organization or business (ineligible, "self-serving")? */
|
|
457
|
+
export function isSelfServing(review: Review, ctx: Pick<ReviewSchemaContext, "origin" | "publisherName">): boolean {
|
|
458
|
+
if (!SELF_SERVING_TYPES.has(review.itemType)) return false;
|
|
459
|
+
try {
|
|
460
|
+
if (review.itemUrl && new URL(review.itemUrl).hostname.replace(/^www\./, "") === new URL(ctx.origin).hostname.replace(/^www\./, "")) return true;
|
|
461
|
+
} catch {
|
|
462
|
+
// ignore
|
|
463
|
+
}
|
|
464
|
+
return !!ctx.publisherName && review.itemName.toLowerCase() === ctx.publisherName.trim().toLowerCase();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const itemList = (items: string[]): Node => ({
|
|
468
|
+
"@type": "ItemList",
|
|
469
|
+
itemListElement: items.map((name, i) => ({ "@type": "ListItem", position: i + 1, name })),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
function absoluteImage(image: string | undefined, origin: string): string | undefined {
|
|
473
|
+
if (!image) return undefined;
|
|
474
|
+
if (/^https?:/i.test(image)) return image;
|
|
475
|
+
try {
|
|
476
|
+
return new URL(image, origin).href;
|
|
477
|
+
} catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The Schema.org node for one review, or null when it isn't eligible (no item
|
|
484
|
+
* name, no rating, or self-serving). `n` is its 1-based position on the page.
|
|
485
|
+
*
|
|
486
|
+
* Products are top-level Product nodes with the Review nested as `review`:
|
|
487
|
+
* Google reads pros and cons (positiveNotes/negativeNotes) only from "the
|
|
488
|
+
* Review nested within a Product", and a nested review needs no
|
|
489
|
+
* itemReviewed. Other types are top-level Review nodes with the item in
|
|
490
|
+
* `itemReviewed`, which is what the review snippet docs show and avoids
|
|
491
|
+
* validating, say, an Event or SoftwareApplication as its own rich result.
|
|
492
|
+
* Either way the Review is `${pageUrl}#review-N` and the item `#review-N-item`.
|
|
493
|
+
*/
|
|
494
|
+
export function reviewSchema(review: Review, n: number, ctx: ReviewSchemaContext): Node | null {
|
|
495
|
+
if (!review.itemName || review.rating === null || isSelfServing(review, ctx)) return null;
|
|
496
|
+
const reviewId = `${ctx.pageUrl}#review-${n}`;
|
|
497
|
+
const itemId = `${ctx.pageUrl}#review-${n}-item`;
|
|
498
|
+
const item: Node = { "@type": review.itemType, "@id": itemId, name: review.itemName };
|
|
499
|
+
if (review.brand && BRAND_TYPES.has(review.itemType)) item.brand = { "@type": "Brand", name: review.brand };
|
|
500
|
+
if (review.itemUrl) item.url = review.itemUrl;
|
|
501
|
+
const image = absoluteImage(review.image, ctx.origin);
|
|
502
|
+
if (image) item.image = image;
|
|
503
|
+
if (review.itemType === "Book") {
|
|
504
|
+
if (review.bookAuthor) item.author = { "@type": "Person", name: review.bookAuthor };
|
|
505
|
+
if (review.isbn) item.isbn = review.isbn;
|
|
506
|
+
if (review.bookPublisher) item.publisher = { "@type": "Organization", name: review.bookPublisher };
|
|
507
|
+
if (review.genre) item.genre = review.genre;
|
|
508
|
+
if (review.copyrightYear) item.copyrightYear = review.copyrightYear;
|
|
509
|
+
}
|
|
510
|
+
if (review.itemType === "SoftwareApplication") {
|
|
511
|
+
if (review.operatingSystem) item.operatingSystem = review.operatingSystem;
|
|
512
|
+
if (review.applicationCategory) item.applicationCategory = review.applicationCategory;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const node: Node = {
|
|
516
|
+
"@type": "Review",
|
|
517
|
+
"@id": reviewId,
|
|
518
|
+
reviewRating: { "@type": "Rating", ratingValue: review.rating, bestRating: BEST_RATING, worstRating: WORST_RATING },
|
|
519
|
+
author: ctx.author,
|
|
520
|
+
...(ctx.publisher ? { publisher: ctx.publisher } : {}),
|
|
521
|
+
...(ctx.datePublished && !Number.isNaN(Date.parse(ctx.datePublished)) ? { datePublished: ctx.datePublished } : {}),
|
|
522
|
+
mainEntityOfPage: { "@id": `${ctx.pageUrl}#webpage` },
|
|
523
|
+
};
|
|
524
|
+
// Pros and cons: Product reviews only, and Google needs at least two statements in all.
|
|
525
|
+
if (review.itemType === "Product" && review.pros.length + review.cons.length >= 2) {
|
|
526
|
+
if (review.pros.length) node.positiveNotes = itemList(review.pros);
|
|
527
|
+
if (review.cons.length) node.negativeNotes = itemList(review.cons);
|
|
528
|
+
}
|
|
529
|
+
if (review.summary) node.reviewBody = review.summary;
|
|
530
|
+
|
|
531
|
+
if (review.itemType === "Product") return { ...item, review: node };
|
|
532
|
+
return { ...node, itemReviewed: item };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const reviewKey = (r: Review) => `${r.itemType} ${r.itemName.toLowerCase()}`;
|
|
536
|
+
|
|
537
|
+
/** Drop repeats (same item type and name), keeping the first; at most 20. */
|
|
538
|
+
export function dedupeReviews(reviews: Review[]): Review[] {
|
|
539
|
+
const seen = new Set<string>();
|
|
540
|
+
return reviews
|
|
541
|
+
.filter((r) => {
|
|
542
|
+
const key = reviewKey(r);
|
|
543
|
+
if (seen.has(key)) return false;
|
|
544
|
+
seen.add(key);
|
|
545
|
+
return true;
|
|
546
|
+
})
|
|
547
|
+
.slice(0, MAX_REVIEWS);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Add review nodes to a Schema & Social @graph. The item each review is about
|
|
552
|
+
* is added to the Article's (or WebPage's) `about`; the review's author is
|
|
553
|
+
* the Article's author, its publisher the graph's publisher.
|
|
554
|
+
*/
|
|
555
|
+
export function attachReviews(graph: { "@graph": Node[] }, reviews: Review[], opts: { origin: string; publisherName?: string }): void {
|
|
556
|
+
if (!reviews.length) return;
|
|
557
|
+
const nodes = graph["@graph"];
|
|
558
|
+
const byId = (suffix: string) => nodes.find((n) => typeof n["@id"] === "string" && (n["@id"] as string).endsWith(suffix));
|
|
559
|
+
const article = byId("#article");
|
|
560
|
+
const webpage = byId("#webpage");
|
|
561
|
+
const owner = article ?? webpage;
|
|
562
|
+
if (!owner) return;
|
|
563
|
+
const pageUrl = String(owner["@id"]).replace(/#[^#]*$/, "");
|
|
564
|
+
const website = nodes.find((n) => n["@type"] === "WebSite");
|
|
565
|
+
const publisher = owner.publisher ?? website?.publisher;
|
|
566
|
+
const author = article?.author ?? publisher;
|
|
567
|
+
if (!author) return;
|
|
568
|
+
const publisherNode = publisher && typeof publisher === "object" && "@id" in publisher ? nodes.find((n) => n["@id"] === (publisher as Node)["@id"]) : undefined;
|
|
569
|
+
const ctx: ReviewSchemaContext = {
|
|
570
|
+
pageUrl,
|
|
571
|
+
origin: opts.origin,
|
|
572
|
+
author,
|
|
573
|
+
publisher,
|
|
574
|
+
publisherName: opts.publisherName ?? (typeof publisherNode?.name === "string" ? publisherNode.name : undefined),
|
|
575
|
+
datePublished: (article?.datePublished ?? webpage?.datePublished) as string | undefined,
|
|
576
|
+
};
|
|
577
|
+
const existing = new Set(nodes.map((n) => String(n["@id"] ?? "")));
|
|
578
|
+
const added: Node[] = [];
|
|
579
|
+
dedupeReviews(reviews).forEach((review, i) => {
|
|
580
|
+
const node = reviewSchema(review, i + 1, ctx);
|
|
581
|
+
if (node && !existing.has(String(node["@id"]))) added.push(node);
|
|
582
|
+
});
|
|
583
|
+
if (!added.length) return;
|
|
584
|
+
nodes.splice(nodes.indexOf(owner) + 1, 0, ...added);
|
|
585
|
+
const itemRefs = added.map((n) => ({ "@id": String(n["@type"] === "Review" ? (n.itemReviewed as Node)["@id"] : n["@id"]) }));
|
|
586
|
+
const about = owner.about === undefined ? [] : Array.isArray(owner.about) ? owner.about : [owner.about];
|
|
587
|
+
const all = [...about, ...itemRefs];
|
|
588
|
+
owner.about = all.length === 1 ? all[0] : all;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Standalone JSON-LD documents (one per review) for when Schema & Social's
|
|
593
|
+
* graph is off. The author is the page's byline name (else the site), the
|
|
594
|
+
* publisher the site.
|
|
595
|
+
*/
|
|
596
|
+
export function reviewDocuments(
|
|
597
|
+
reviews: Review[],
|
|
598
|
+
page: { pageUrl: string; origin: string; siteName: string; authorName?: string | null; datePublished?: string | null },
|
|
599
|
+
): Node[] {
|
|
600
|
+
const publisher: Node = { "@type": "Organization", name: page.siteName, url: `${page.origin}/` };
|
|
601
|
+
const authorName = page.authorName?.trim().slice(0, 100);
|
|
602
|
+
const author: Node = authorName ? { "@type": "Person", name: authorName } : publisher;
|
|
603
|
+
const ctx: ReviewSchemaContext = {
|
|
604
|
+
pageUrl: page.pageUrl,
|
|
605
|
+
origin: page.origin,
|
|
606
|
+
author,
|
|
607
|
+
publisher: page.siteName ? publisher : undefined,
|
|
608
|
+
publisherName: page.siteName,
|
|
609
|
+
datePublished: page.datePublished,
|
|
610
|
+
};
|
|
611
|
+
const out: Node[] = [];
|
|
612
|
+
dedupeReviews(reviews).forEach((review, i) => {
|
|
613
|
+
const node = reviewSchema(review, i + 1, ctx);
|
|
614
|
+
if (!node) return;
|
|
615
|
+
// No graph, so no #webpage node to point at.
|
|
616
|
+
const strip = (n: Node) => {
|
|
617
|
+
if (n.mainEntityOfPage) n.mainEntityOfPage = page.pageUrl;
|
|
618
|
+
};
|
|
619
|
+
strip(node);
|
|
620
|
+
if (node.review) strip(node.review as Node);
|
|
621
|
+
out.push({ "@context": "https://schema.org", ...node });
|
|
622
|
+
});
|
|
623
|
+
return out;
|
|
624
|
+
}
|