@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,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the Schema & Social module: the JSON-LD @graph, the
|
|
3
|
+
* BreadcrumbList, the robots directives, and og:locale. No I/O here (the
|
|
4
|
+
* module resolves settings, bylines and media first), so everything is unit
|
|
5
|
+
* testable with `node --test src/schema/graph.test.mjs`.
|
|
6
|
+
*
|
|
7
|
+
* Only type imports: this file must run under Node's type stripping.
|
|
8
|
+
*/
|
|
9
|
+
import type { BreadcrumbItem, PublicPageContext } from "emdash";
|
|
10
|
+
|
|
11
|
+
// ── Stored shapes ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** One property row from a property picker: a text/URL value, or sub-fields for structured properties. */
|
|
14
|
+
export interface PropertyRow {
|
|
15
|
+
prop: string;
|
|
16
|
+
value: string | Record<string, string>;
|
|
17
|
+
/** Person rows only: output only when the person is a page's main subject (their profile page), not on every article. */
|
|
18
|
+
profileOnly?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Site Details: who publishes the site. */
|
|
22
|
+
export interface SiteDetails {
|
|
23
|
+
publisherType: "organization" | "person";
|
|
24
|
+
/** Byline whose Person properties describe the publisher, when publisherType is "person". */
|
|
25
|
+
personBylineId?: string | null;
|
|
26
|
+
/** Organization property rows, in order. */
|
|
27
|
+
orgRows: PropertyRow[];
|
|
28
|
+
/** Organization type (Organization or a subtype such as NewsMediaOrganization). */
|
|
29
|
+
organizationType?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Page and Article types for a collection (or "_home" / "_custom" for non-content pages). */
|
|
33
|
+
export interface TypeChoice {
|
|
34
|
+
pageType?: string;
|
|
35
|
+
/** Article subtype, or "none" for no Article node. */
|
|
36
|
+
articleType?: string;
|
|
37
|
+
}
|
|
38
|
+
export type TypeMap = Record<string, TypeChoice>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a single entry is about (its WebPage's mainEntity): "byline:<id>" for
|
|
42
|
+
* a person, or "publisher" for the site publisher. Missing means none.
|
|
43
|
+
*/
|
|
44
|
+
export type MainSubject = `byline:${string}` | "publisher";
|
|
45
|
+
|
|
46
|
+
/** The byline id in a "byline:<id>" main subject, or null. */
|
|
47
|
+
export function mainSubjectByline(subject: string | null | undefined): string | null {
|
|
48
|
+
return typeof subject === "string" && subject.startsWith("byline:") && subject.length > 7 ? subject.slice(7) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const HOME_KEY = "_home";
|
|
52
|
+
export const CUSTOM_KEY = "_custom";
|
|
53
|
+
export const NO_ARTICLE = "none";
|
|
54
|
+
|
|
55
|
+
/** Image facts the module looked up (media dimensions and alt). */
|
|
56
|
+
export interface ImageInfo {
|
|
57
|
+
url: string;
|
|
58
|
+
width?: number | null;
|
|
59
|
+
height?: number | null;
|
|
60
|
+
alt?: string | null;
|
|
61
|
+
mimeType?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Node = Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
// ── URL helpers ──────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const ABSOLUTE = /^https?:\/\//i;
|
|
69
|
+
|
|
70
|
+
/** Origin without a trailing slash, e.g. "https://example.com". */
|
|
71
|
+
export function originOf(siteUrl: string): string {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(siteUrl).origin;
|
|
74
|
+
} catch {
|
|
75
|
+
return siteUrl.replace(/\/+$/, "");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Join a root-relative path to the origin; absolute URLs pass through; anything else is dropped. */
|
|
80
|
+
export function absolute(url: string | null | undefined, origin: string): string | undefined {
|
|
81
|
+
if (!url) return undefined;
|
|
82
|
+
const value = url.trim();
|
|
83
|
+
if (!value || /\s/.test(value)) return undefined;
|
|
84
|
+
if (ABSOLUTE.test(value)) return value;
|
|
85
|
+
if (value.startsWith("//")) return undefined;
|
|
86
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return /^(mailto|tel):/i.test(value) ? value : undefined;
|
|
87
|
+
return `${origin}${value.startsWith("/") ? "" : "/"}${value}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The page's own URL for @id anchors: canonical, else the request URL without query or hash. */
|
|
91
|
+
export function pageUrl(page: Pick<PublicPageContext, "canonical" | "url">): string {
|
|
92
|
+
if (page.canonical) return page.canonical;
|
|
93
|
+
try {
|
|
94
|
+
const u = new URL(page.url);
|
|
95
|
+
return `${u.origin}${u.pathname}`;
|
|
96
|
+
} catch {
|
|
97
|
+
return page.url;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A same-site EmDash media URL, as the column and value to look it up by. */
|
|
102
|
+
export interface MediaRef {
|
|
103
|
+
by: "storage_key" | "id";
|
|
104
|
+
value: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* EmDash serves media at /_emdash/api/media/file/<storage key> and
|
|
109
|
+
* /_emdash/api/media/asset/<id>/<filename>. Returns how to look up a
|
|
110
|
+
* same-site media URL (relative, or absolute on `origin`), or null. URLs with
|
|
111
|
+
* a query string are skipped: a resized variant's dimensions differ from the
|
|
112
|
+
* stored ones.
|
|
113
|
+
*/
|
|
114
|
+
export function mediaRefFromUrl(url: string | null | undefined, origin?: string): MediaRef | null {
|
|
115
|
+
if (!url) return null;
|
|
116
|
+
let parsed: URL;
|
|
117
|
+
try {
|
|
118
|
+
parsed = new URL(url, origin || "https://site.invalid");
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (ABSOLUTE.test(url) && origin && parsed.origin !== originOf(origin)) return null;
|
|
123
|
+
if (parsed.search) return null;
|
|
124
|
+
const decode = (s: string) => {
|
|
125
|
+
try {
|
|
126
|
+
return decodeURIComponent(s);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const file = /^\/_emdash\/api\/media\/file\/([^/]+)$/.exec(parsed.pathname);
|
|
132
|
+
if (file) {
|
|
133
|
+
const value = decode(file[1]);
|
|
134
|
+
return value ? { by: "storage_key", value } : null;
|
|
135
|
+
}
|
|
136
|
+
const asset = /^\/_emdash\/api\/media\/asset\/([^/]+)\/[^/]+$/.exec(parsed.pathname);
|
|
137
|
+
if (asset) {
|
|
138
|
+
const value = decode(asset[1]);
|
|
139
|
+
return value ? { by: "id", value } : null;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Property rows → schema properties ────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Properties whose value is another typed object, keyed to the type that wraps a text value (as its name). */
|
|
147
|
+
const OBJECT_PROPS: Record<string, string> = {
|
|
148
|
+
founder: "Person",
|
|
149
|
+
parentOrganization: "Organization",
|
|
150
|
+
subOrganization: "Organization",
|
|
151
|
+
memberOf: "Organization",
|
|
152
|
+
member: "Person",
|
|
153
|
+
sponsor: "Organization",
|
|
154
|
+
funder: "Organization",
|
|
155
|
+
brand: "Brand",
|
|
156
|
+
birthPlace: "Place",
|
|
157
|
+
homeLocation: "Place",
|
|
158
|
+
workLocation: "Place",
|
|
159
|
+
nationality: "Country",
|
|
160
|
+
worksFor: "Organization",
|
|
161
|
+
affiliation: "Organization",
|
|
162
|
+
alumniOf: "Organization",
|
|
163
|
+
colleague: "Person",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const URL_PROPS = new Set(["@id", "url", "sameAs", "logo", "image", "ethicsPolicy", "publishingPrinciples", "masthead", "missionCoveragePrioritiesPolicy", "diversityPolicy", "diversityStaffingReport", "correctionsPolicy", "verificationFactCheckingPolicy", "unnamedSourcesPolicy", "actionableFeedbackPolicy", "ownershipFundingInfo", "noBylinesPolicy"]);
|
|
167
|
+
const NUMBER_PROPS = new Set(["numberOfEmployees"]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shape ordered prop/value rows into schema properties, as Coywolf SEO does
|
|
171
|
+
* on WordPress: a property repeated across rows becomes an array; logo (and
|
|
172
|
+
* an Organization's image) becomes an ImageObject; entity references are
|
|
173
|
+
* wrapped in their type; address/contactPoint get PostalAddress/ContactPoint.
|
|
174
|
+
* Relative URLs are made absolute against `origin`.
|
|
175
|
+
*/
|
|
176
|
+
export function shapeRows(rows: PropertyRow[] | null | undefined, parentType: "Organization" | "Person", origin: string): Node {
|
|
177
|
+
const out: Node = {};
|
|
178
|
+
for (const row of rows ?? []) {
|
|
179
|
+
if (!row || typeof row.prop !== "string" || !row.prop) continue;
|
|
180
|
+
const prop = row.prop;
|
|
181
|
+
let shaped: unknown;
|
|
182
|
+
if (row.value && typeof row.value === "object") {
|
|
183
|
+
const fields: Record<string, string> = {};
|
|
184
|
+
for (const [key, raw] of Object.entries(row.value)) {
|
|
185
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
186
|
+
if (!value) continue;
|
|
187
|
+
fields[key] = key === "url" || key === "@id" ? (absolute(value, origin) ?? "") : key === "addressCountry" ? countryCode(value) : value;
|
|
188
|
+
if (!fields[key]) delete fields[key];
|
|
189
|
+
}
|
|
190
|
+
if (!Object.keys(fields).length) continue;
|
|
191
|
+
const type = prop === "address" ? "PostalAddress" : prop === "contactPoint" ? "ContactPoint" : OBJECT_PROPS[prop];
|
|
192
|
+
if (!type) continue; // Unknown structured value: never output raw objects.
|
|
193
|
+
shaped = { "@type": type, ...fields };
|
|
194
|
+
} else {
|
|
195
|
+
const text = typeof row.value === "string" ? row.value.trim() : "";
|
|
196
|
+
if (!text) continue;
|
|
197
|
+
const value = URL_PROPS.has(prop) ? absolute(text, origin) : text;
|
|
198
|
+
if (!value) continue;
|
|
199
|
+
if (prop === "logo" || (prop === "image" && parentType === "Organization")) {
|
|
200
|
+
shaped = { "@type": "ImageObject", url: value };
|
|
201
|
+
} else if (OBJECT_PROPS[prop]) {
|
|
202
|
+
shaped = { "@type": OBJECT_PROPS[prop], name: value };
|
|
203
|
+
} else if (NUMBER_PROPS.has(prop) && /^\d+$/.test(value)) {
|
|
204
|
+
shaped = { "@type": "QuantitativeValue", value: Number(value) };
|
|
205
|
+
} else {
|
|
206
|
+
shaped = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!(prop in out)) out[prop] = shaped;
|
|
210
|
+
else if (prop === "@id") continue; // Single-valued; the first row wins.
|
|
211
|
+
else {
|
|
212
|
+
const current = out[prop];
|
|
213
|
+
out[prop] = Array.isArray(current) ? [...current, shaped] : [current, shaped];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Common country names and three-letter codes people type, as the two-letter ISO 3166-1 codes Google expects. */
|
|
220
|
+
const COUNTRY_ALIASES: Record<string, string> = {
|
|
221
|
+
usa: "US", "united states": "US", "united states of america": "US", us: "US", "u.s.": "US", "u.s.a.": "US", america: "US",
|
|
222
|
+
uk: "GB", "united kingdom": "GB", "great britain": "GB", gbr: "GB", england: "GB", scotland: "GB", wales: "GB",
|
|
223
|
+
can: "CA", canada: "CA", aus: "AU", australia: "AU", nzl: "NZ", "new zealand": "NZ", irl: "IE", ireland: "IE",
|
|
224
|
+
deu: "DE", germany: "DE", fra: "FR", france: "FR", esp: "ES", spain: "ES", ita: "IT", italy: "IT", nld: "NL", netherlands: "NL",
|
|
225
|
+
mex: "MX", mexico: "MX", bra: "BR", brazil: "BR", ind: "IN", india: "IN", jpn: "JP", japan: "JP",
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** A PostalAddress country as a two-letter code: aliases are mapped, two-letter codes uppercased, anything else kept as typed. */
|
|
229
|
+
export function countryCode(value: string): string {
|
|
230
|
+
const alias = COUNTRY_ALIASES[value.toLowerCase()];
|
|
231
|
+
if (alias) return alias;
|
|
232
|
+
return /^[a-z]{2}$/i.test(value) ? value.toUpperCase() : value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The @id row's value, if any. */
|
|
236
|
+
export function idFromRows(rows: PropertyRow[] | null | undefined, origin: string): string | undefined {
|
|
237
|
+
const row = (rows ?? []).find((r) => r?.prop === "@id" && typeof r.value === "string" && r.value.trim());
|
|
238
|
+
return row ? absolute(row.value as string, origin) : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── People and publisher ─────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** What EmDash knows about a byline, used when its stored rows don't say. */
|
|
244
|
+
export interface BylineFacts {
|
|
245
|
+
id: string;
|
|
246
|
+
slug: string;
|
|
247
|
+
displayName: string;
|
|
248
|
+
bio?: string | null;
|
|
249
|
+
websiteUrl?: string | null;
|
|
250
|
+
avatarUrl?: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A Person node from a byline: stored property rows first, then the byline's
|
|
255
|
+
* own name, website (or author page), bio and avatar. `defaultId` anchors it
|
|
256
|
+
* unless the rows set an @id. Rows marked profile-only are left out unless
|
|
257
|
+
* `profile` is set (the person is the page's main subject).
|
|
258
|
+
*/
|
|
259
|
+
export function personNode(opts: {
|
|
260
|
+
byline?: BylineFacts | null;
|
|
261
|
+
rows?: PropertyRow[] | null;
|
|
262
|
+
origin: string;
|
|
263
|
+
defaultId: string;
|
|
264
|
+
authorUrl?: string | null;
|
|
265
|
+
profile?: boolean;
|
|
266
|
+
}): Node | null {
|
|
267
|
+
const rows = opts.profile ? opts.rows : opts.rows?.filter((r) => !r?.profileOnly);
|
|
268
|
+
const shaped = shapeRows(rows, "Person", opts.origin);
|
|
269
|
+
const node: Node = { "@type": "Person", ...shaped };
|
|
270
|
+
node["@id"] = (shaped["@id"] as string | undefined) ?? opts.defaultId;
|
|
271
|
+
const b = opts.byline;
|
|
272
|
+
if (!node.name) {
|
|
273
|
+
if (!b?.displayName) return null;
|
|
274
|
+
node.name = b.displayName;
|
|
275
|
+
}
|
|
276
|
+
if (!node.url) {
|
|
277
|
+
const url = absolute(b?.websiteUrl, opts.origin) ?? absolute(opts.authorUrl, opts.origin);
|
|
278
|
+
if (url) node.url = url;
|
|
279
|
+
}
|
|
280
|
+
if (!node.description && b?.bio?.trim()) node.description = b.bio.trim();
|
|
281
|
+
if (!node.image) {
|
|
282
|
+
const avatar = absolute(b?.avatarUrl, opts.origin);
|
|
283
|
+
if (avatar) node.image = avatar;
|
|
284
|
+
}
|
|
285
|
+
return node;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Author page URL from a pattern like "/author/{slug}/", or null when no pattern is set. */
|
|
289
|
+
export function authorPath(pattern: string | null | undefined, slug: string): string | null {
|
|
290
|
+
if (!pattern?.trim() || !slug) return null;
|
|
291
|
+
return pattern.trim().replaceAll("{slug}", encodeURIComponent(slug));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The @id for an author byline. The byline chosen as the publisher Person
|
|
296
|
+
* shares the publisher's @id, so the graph has one Person for them. Others
|
|
297
|
+
* anchor on their author page (when the site has an author URL pattern) or
|
|
298
|
+
* the site root.
|
|
299
|
+
*/
|
|
300
|
+
export function authorId(opts: {
|
|
301
|
+
bylineId: string;
|
|
302
|
+
slug: string;
|
|
303
|
+
origin: string;
|
|
304
|
+
authorUrl?: string | null;
|
|
305
|
+
details?: SiteDetails | null;
|
|
306
|
+
personRows?: PropertyRow[] | null;
|
|
307
|
+
}): string {
|
|
308
|
+
if (opts.details?.publisherType === "person" && opts.details.personBylineId && opts.details.personBylineId === opts.bylineId)
|
|
309
|
+
return publisherId(opts.details, opts.origin, opts.personRows);
|
|
310
|
+
return opts.authorUrl ? `${opts.authorUrl}#person` : `${opts.origin}/#person-${encodeURIComponent(opts.slug)}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A robots max-snippet / max-video-preview setting: blank, missing or invalid means -1 (no limit). */
|
|
314
|
+
export function parseLimit(value: unknown): number {
|
|
315
|
+
if (value === null || value === undefined) return -1;
|
|
316
|
+
if (typeof value === "string" && !value.trim()) return -1;
|
|
317
|
+
const n = Number(value);
|
|
318
|
+
if (!Number.isFinite(n)) return -1;
|
|
319
|
+
return Math.max(-1, Math.trunc(n));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function publisherId(details: SiteDetails | null | undefined, origin: string, personRows?: PropertyRow[] | null): string {
|
|
323
|
+
if (details?.publisherType === "person") return idFromRows(personRows, origin) ?? `${origin}/#person`;
|
|
324
|
+
return idFromRows(details?.orgRows, origin) ?? `${origin}/#organization`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The publisher node (Organization or Person) from Site Details. Falls back
|
|
329
|
+
* to the site name, URL and logo so the graph's publisher references always
|
|
330
|
+
* resolve, as EmDash's own JSON-LD names the site as publisher.
|
|
331
|
+
*/
|
|
332
|
+
export function publisherNode(opts: {
|
|
333
|
+
details: SiteDetails | null | undefined;
|
|
334
|
+
origin: string;
|
|
335
|
+
siteName: string;
|
|
336
|
+
siteLogo?: ImageInfo | null;
|
|
337
|
+
person?: { byline: BylineFacts | null; rows: PropertyRow[] | null } | null;
|
|
338
|
+
authorUrl?: string | null;
|
|
339
|
+
}): Node {
|
|
340
|
+
const { details, origin } = opts;
|
|
341
|
+
const id = publisherId(details, origin, opts.person?.rows);
|
|
342
|
+
if (details?.publisherType === "person") {
|
|
343
|
+
const node = personNode({
|
|
344
|
+
byline: opts.person?.byline,
|
|
345
|
+
rows: opts.person?.rows,
|
|
346
|
+
origin,
|
|
347
|
+
defaultId: id,
|
|
348
|
+
authorUrl: opts.authorUrl,
|
|
349
|
+
});
|
|
350
|
+
if (node) return { ...node, "@id": id };
|
|
351
|
+
return { "@type": "Organization", "@id": id, name: opts.siteName, url: `${origin}/` };
|
|
352
|
+
}
|
|
353
|
+
const shaped = shapeRows(details?.orgRows, "Organization", origin);
|
|
354
|
+
const node: Node = { "@type": details?.organizationType || "Organization", ...shaped, "@id": id };
|
|
355
|
+
if (!node.name) node.name = opts.siteName;
|
|
356
|
+
if (!node.url) node.url = `${origin}/`;
|
|
357
|
+
if (!node.logo && opts.siteLogo?.url) node.logo = imageObject(opts.siteLogo, origin);
|
|
358
|
+
return node;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function imageObject(image: ImageInfo, origin: string, id?: string): Node {
|
|
362
|
+
const url = absolute(image.url, origin) ?? image.url;
|
|
363
|
+
const node: Node = { "@type": "ImageObject" };
|
|
364
|
+
if (id) node["@id"] = id;
|
|
365
|
+
node.url = url;
|
|
366
|
+
node.contentUrl = url;
|
|
367
|
+
if (image.width && image.width > 0) node.width = image.width;
|
|
368
|
+
if (image.height && image.height > 0) node.height = image.height;
|
|
369
|
+
if (image.alt?.trim()) node.caption = image.alt.trim();
|
|
370
|
+
return node;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Fill in width/height/caption on ImageObject values (logo, image) once they've been looked up. */
|
|
374
|
+
export function enrichImageObjects(node: Node, lookup: (url: string) => ImageInfo | null | undefined): Node {
|
|
375
|
+
for (const key of ["logo", "image"]) {
|
|
376
|
+
const value = node[key];
|
|
377
|
+
const list = Array.isArray(value) ? value : [value];
|
|
378
|
+
for (const item of list) {
|
|
379
|
+
if (!item || typeof item !== "object" || (item as Node)["@type"] !== "ImageObject") continue;
|
|
380
|
+
const obj = item as Node;
|
|
381
|
+
const info = typeof obj.url === "string" ? lookup(obj.url) : null;
|
|
382
|
+
if (!info) continue;
|
|
383
|
+
if (info.width && obj.width === undefined) obj.width = info.width;
|
|
384
|
+
if (info.height && obj.height === undefined) obj.height = info.height;
|
|
385
|
+
if (info.alt?.trim() && obj.caption === undefined) obj.caption = info.alt.trim();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return node;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Breadcrumbs ──────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
function humanize(segment: string): string {
|
|
394
|
+
let text = segment;
|
|
395
|
+
try {
|
|
396
|
+
text = decodeURIComponent(segment);
|
|
397
|
+
} catch {
|
|
398
|
+
// Keep the raw segment.
|
|
399
|
+
}
|
|
400
|
+
text = text.replace(/[-_]+/g, " ").trim();
|
|
401
|
+
return text ? text.charAt(0).toUpperCase() + text.slice(1) : segment;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The breadcrumb trail for a page. `page.breadcrumbs` is used verbatim when
|
|
406
|
+
* the theme provides it (`[]` means no breadcrumbs); otherwise it's derived
|
|
407
|
+
* from the path: Home, each parent segment (humanized), then the page title.
|
|
408
|
+
*/
|
|
409
|
+
export function breadcrumbTrail(page: PublicPageContext, origin: string, homeLabel = "Home"): BreadcrumbItem[] {
|
|
410
|
+
if (page.breadcrumbs !== undefined) return page.breadcrumbs;
|
|
411
|
+
const segments = (page.path || "/").split("/").filter(Boolean);
|
|
412
|
+
if (!segments.length) return [];
|
|
413
|
+
const trailing = (page.path || "").endsWith("/");
|
|
414
|
+
const trail: BreadcrumbItem[] = [{ name: homeLabel, url: `${origin}/` }];
|
|
415
|
+
segments.forEach((segment, i) => {
|
|
416
|
+
const last = i === segments.length - 1;
|
|
417
|
+
const path = `/${segments.slice(0, i + 1).join("/")}${trailing || !last ? "/" : ""}`;
|
|
418
|
+
const name = last ? page.pageTitle || page.seo?.ogTitle || humanize(segment) : humanize(segment);
|
|
419
|
+
trail.push({ name, url: last ? pageUrl(page) : `${origin}${path}` });
|
|
420
|
+
});
|
|
421
|
+
return trail;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A BreadcrumbList node, or null for an empty trail (or a derived one-item trail). */
|
|
425
|
+
export function breadcrumbNode(trail: BreadcrumbItem[], id: string, origin: string, explicit: boolean): Node | null {
|
|
426
|
+
const items = trail.filter((c) => c && typeof c.name === "string" && c.name.trim());
|
|
427
|
+
if (items.length < (explicit ? 1 : 2)) return null;
|
|
428
|
+
return {
|
|
429
|
+
"@type": "BreadcrumbList",
|
|
430
|
+
"@id": id,
|
|
431
|
+
itemListElement: items.map((crumb, i) => {
|
|
432
|
+
const element: Node = { "@type": "ListItem", position: i + 1, name: crumb.name.trim() };
|
|
433
|
+
const url = absolute(crumb.url, origin);
|
|
434
|
+
if (url) element.item = url;
|
|
435
|
+
return element;
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── The graph ────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
export interface GraphInput {
|
|
443
|
+
page: PublicPageContext;
|
|
444
|
+
origin: string;
|
|
445
|
+
siteName: string;
|
|
446
|
+
tagline?: string | null;
|
|
447
|
+
/** BCP 47 language of the page, e.g. "en-US". */
|
|
448
|
+
language?: string | null;
|
|
449
|
+
/** Collection/page defaults. */
|
|
450
|
+
types: TypeMap;
|
|
451
|
+
/** Per-entry override for this page, if any. */
|
|
452
|
+
override?: TypeChoice | null;
|
|
453
|
+
/** Search URL template (e.g. "/?s={search_term_string}"); empty for no SearchAction. */
|
|
454
|
+
searchUrl?: string | null;
|
|
455
|
+
publisher: Node;
|
|
456
|
+
/** Author Person nodes for the entry, in credit order. */
|
|
457
|
+
authors: Node[];
|
|
458
|
+
/**
|
|
459
|
+
* What the page is about (its WebPage's mainEntity): a Person built with its
|
|
460
|
+
* profile-only properties, or the publisher node. Null for none.
|
|
461
|
+
*/
|
|
462
|
+
mainEntity?: Node | null;
|
|
463
|
+
/** The page's primary image (og:image), looked up for dimensions and alt. */
|
|
464
|
+
image?: ImageInfo | null;
|
|
465
|
+
/** Include a BreadcrumbList (the schema.breadcrumbs feature). */
|
|
466
|
+
breadcrumbs: boolean;
|
|
467
|
+
homeLabel?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function isHome(page: PublicPageContext): boolean {
|
|
471
|
+
return !page.content && (page.path === "/" || page.path === "");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The WebPage subtype and Article subtype (or "none") for a page. */
|
|
475
|
+
export function resolveTypes(page: PublicPageContext, types: TypeMap, override?: TypeChoice | null): { pageType: string; articleType: string } {
|
|
476
|
+
const key = page.content ? page.content.collection : isHome(page) ? HOME_KEY : CUSTOM_KEY;
|
|
477
|
+
const defaults = types[key] ?? {};
|
|
478
|
+
const pageType = override?.pageType || defaults.pageType || "WebPage";
|
|
479
|
+
// EmDash's own JSON-LD is a BlogPosting for article pages; keep that unless told otherwise.
|
|
480
|
+
const articleType = override?.articleType || defaults.articleType || (page.pageType === "article" ? "BlogPosting" : NO_ARTICLE);
|
|
481
|
+
return { pageType, articleType };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const truncate = (text: string, max: number) => (text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`);
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Build the @graph for a page: WebSite, publisher, WebPage (typed), the
|
|
488
|
+
* primary ImageObject, author Person(s), the Article (typed) and, when
|
|
489
|
+
* enabled, the BreadcrumbList. Keeps everything EmDash's own JSON-LD gives
|
|
490
|
+
* (headline, description, image, dates, author, publisher).
|
|
491
|
+
*/
|
|
492
|
+
export function buildGraph(input: GraphInput): Node {
|
|
493
|
+
const { page, origin } = input;
|
|
494
|
+
const url = pageUrl(page);
|
|
495
|
+
const websiteId = `${origin}/#website`;
|
|
496
|
+
const webpageId = `${url}#webpage`;
|
|
497
|
+
const publisherRef = { "@id": String(input.publisher["@id"]) };
|
|
498
|
+
const title = page.seo?.ogTitle ?? page.pageTitle ?? page.title ?? input.siteName;
|
|
499
|
+
const description = page.seo?.ogDescription || page.description || undefined;
|
|
500
|
+
const language = input.language || undefined;
|
|
501
|
+
const published = page.articleMeta?.publishedTime || undefined;
|
|
502
|
+
const modified = page.articleMeta?.modifiedTime || published;
|
|
503
|
+
const { pageType, articleType } = resolveTypes(page, input.types, input.override);
|
|
504
|
+
const home = isHome(page);
|
|
505
|
+
const publisher: Node = { ...input.publisher };
|
|
506
|
+
|
|
507
|
+
const website: Node = {
|
|
508
|
+
"@type": "WebSite",
|
|
509
|
+
"@id": websiteId,
|
|
510
|
+
url: `${origin}/`,
|
|
511
|
+
name: input.siteName,
|
|
512
|
+
...(input.tagline ? { description: input.tagline } : {}),
|
|
513
|
+
publisher: publisherRef,
|
|
514
|
+
...(language ? { inLanguage: language } : {}),
|
|
515
|
+
};
|
|
516
|
+
const searchTarget = input.searchUrl?.includes("{search_term_string}") ? absolute(input.searchUrl, origin) : undefined;
|
|
517
|
+
if (searchTarget) {
|
|
518
|
+
website.potentialAction = {
|
|
519
|
+
"@type": "SearchAction",
|
|
520
|
+
target: { "@type": "EntryPoint", urlTemplate: searchTarget },
|
|
521
|
+
"query-input": "required name=search_term_string",
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const webpage: Node = {
|
|
526
|
+
"@type": pageType,
|
|
527
|
+
"@id": webpageId,
|
|
528
|
+
url,
|
|
529
|
+
name: title,
|
|
530
|
+
isPartOf: { "@id": websiteId },
|
|
531
|
+
...(description ? { description } : {}),
|
|
532
|
+
...(language ? { inLanguage: language } : {}),
|
|
533
|
+
...(published ? { datePublished: published } : {}),
|
|
534
|
+
...(modified ? { dateModified: modified } : {}),
|
|
535
|
+
};
|
|
536
|
+
if (home) webpage.about = publisherRef;
|
|
537
|
+
// Profile pages: Google reads dateCreated / dateModified on the ProfilePage.
|
|
538
|
+
if (pageType === "ProfilePage" && published) webpage.dateCreated = published;
|
|
539
|
+
|
|
540
|
+
const nodes: Node[] = [webpage];
|
|
541
|
+
|
|
542
|
+
// The page's main subject: one node in the graph, referenced from the WebPage.
|
|
543
|
+
const mainId = typeof input.mainEntity?.["@id"] === "string" ? (input.mainEntity["@id"] as string) : null;
|
|
544
|
+
if (input.mainEntity && mainId) {
|
|
545
|
+
webpage.mainEntity = { "@id": mainId };
|
|
546
|
+
if (mainId === publisherRef["@id"]) absorb(publisher, input.mainEntity);
|
|
547
|
+
else nodes.push({ ...input.mainEntity });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let imageRef: Node | undefined;
|
|
551
|
+
if (input.image?.url) {
|
|
552
|
+
const image = imageObject(input.image, origin, `${url}#primaryimage`);
|
|
553
|
+
nodes.push(image);
|
|
554
|
+
imageRef = { "@id": image["@id"] };
|
|
555
|
+
webpage.primaryImageOfPage = imageRef;
|
|
556
|
+
webpage.image = imageRef;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (input.breadcrumbs) {
|
|
560
|
+
const explicit = page.breadcrumbs !== undefined;
|
|
561
|
+
const crumbs = breadcrumbNode(breadcrumbTrail(page, origin, input.homeLabel), `${url}#breadcrumb`, origin, explicit);
|
|
562
|
+
if (crumbs) {
|
|
563
|
+
webpage.breadcrumb = { "@id": crumbs["@id"] };
|
|
564
|
+
nodes.push(crumbs);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (articleType !== NO_ARTICLE) {
|
|
569
|
+
const article: Node = {
|
|
570
|
+
"@type": articleType,
|
|
571
|
+
"@id": `${url}#article`,
|
|
572
|
+
url,
|
|
573
|
+
headline: truncate(String(title), 110),
|
|
574
|
+
...(description ? { description } : {}),
|
|
575
|
+
...(imageRef ? { image: imageRef } : {}),
|
|
576
|
+
...(published ? { datePublished: published } : {}),
|
|
577
|
+
...(modified ? { dateModified: modified } : {}),
|
|
578
|
+
isPartOf: { "@id": webpageId },
|
|
579
|
+
mainEntityOfPage: { "@id": webpageId },
|
|
580
|
+
publisher: publisherRef,
|
|
581
|
+
...(language ? { inLanguage: language } : {}),
|
|
582
|
+
};
|
|
583
|
+
const authors = input.authors.filter((a) => a && a["@id"]);
|
|
584
|
+
const publisherIsAuthor = (a: Node) => a["@id"] === publisherRef["@id"];
|
|
585
|
+
if (authors.length) {
|
|
586
|
+
const refs = authors.map((a) => ({ "@id": String(a["@id"]) }));
|
|
587
|
+
article.author = refs.length === 1 ? refs[0] : refs;
|
|
588
|
+
for (const a of authors) {
|
|
589
|
+
// The main subject already has a (fuller) node with this @id.
|
|
590
|
+
if (a["@id"] === mainId && !publisherIsAuthor(a)) absorb(nodes.find((n) => n["@id"] === mainId) ?? a, a);
|
|
591
|
+
else if (!publisherIsAuthor(a)) nodes.push(a);
|
|
592
|
+
// The publisher person also wrote this: one Person node, with the author's extra properties.
|
|
593
|
+
else absorb(publisher, a);
|
|
594
|
+
}
|
|
595
|
+
} else if (page.articleMeta?.author) {
|
|
596
|
+
article.author = { "@type": "Person", name: page.articleMeta.author };
|
|
597
|
+
}
|
|
598
|
+
nodes.push(article);
|
|
599
|
+
// The Article carries the description and dates; the WebPage doesn't repeat them.
|
|
600
|
+
delete webpage.description;
|
|
601
|
+
delete webpage.datePublished;
|
|
602
|
+
delete webpage.dateModified;
|
|
603
|
+
delete webpage.dateCreated;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
nodes.push(website, publisher);
|
|
607
|
+
return { "@context": "https://schema.org", "@graph": linkNestedEntities(dedupeById(nodes)) };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Copy properties `extra` has and `target` lacks (two views of the same entity). */
|
|
611
|
+
function absorb(target: Node, extra: Node): void {
|
|
612
|
+
for (const [k, v] of Object.entries(extra)) if (target[k] === undefined) target[k] = v;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Replace nested copies of entities that already have their own node with a
|
|
617
|
+
* reference: same `@id`, or (for people and organizations) same `url`. E.g.
|
|
618
|
+
* the publisher's founder who is also the article's author becomes
|
|
619
|
+
* `{ "@id": <author> }` instead of a second Person.
|
|
620
|
+
*/
|
|
621
|
+
export function linkNestedEntities(nodes: Node[]): Node[] {
|
|
622
|
+
const byId = new Map<string, Node>();
|
|
623
|
+
const byUrl = new Map<string, string>();
|
|
624
|
+
const kind = (t: unknown) => (typeof t === "string" && /Person$/.test(t) ? "person" : typeof t === "string" && /Organization$|Corporation|Business|NGO|Group$/.test(t) ? "org" : null);
|
|
625
|
+
for (const n of nodes) {
|
|
626
|
+
const id = n["@id"];
|
|
627
|
+
if (typeof id !== "string") continue;
|
|
628
|
+
byId.set(id, n);
|
|
629
|
+
const k = kind(n["@type"]);
|
|
630
|
+
if (k && typeof n.url === "string") byUrl.set(`${k} ${n.url}`, id);
|
|
631
|
+
}
|
|
632
|
+
const visit = (value: unknown, top: boolean): unknown => {
|
|
633
|
+
if (Array.isArray(value)) return value.map((v) => visit(v, false));
|
|
634
|
+
if (!value || typeof value !== "object") return value;
|
|
635
|
+
const obj = value as Node;
|
|
636
|
+
if (!top) {
|
|
637
|
+
const id = typeof obj["@id"] === "string" ? obj["@id"] : null;
|
|
638
|
+
if (id && byId.has(id) && Object.keys(obj).length > 1) return { "@id": id };
|
|
639
|
+
const k = kind(obj["@type"]);
|
|
640
|
+
const match = k && typeof obj.url === "string" ? byUrl.get(`${k} ${obj.url}`) : undefined;
|
|
641
|
+
if (match) return { "@id": match };
|
|
642
|
+
}
|
|
643
|
+
const out: Node = {};
|
|
644
|
+
for (const [key, v] of Object.entries(obj)) out[key] = visit(v, false);
|
|
645
|
+
return out;
|
|
646
|
+
};
|
|
647
|
+
return nodes.map((n) => visit(n, true) as Node);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function dedupeById(nodes: Node[]): Node[] {
|
|
651
|
+
const seen = new Set<string>();
|
|
652
|
+
return nodes.filter((n) => {
|
|
653
|
+
const id = n["@id"];
|
|
654
|
+
if (typeof id !== "string") return true;
|
|
655
|
+
if (seen.has(id)) return false;
|
|
656
|
+
seen.add(id);
|
|
657
|
+
return true;
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** A standalone BreadcrumbList document, for when breadcrumbs are on but the graph is off. */
|
|
662
|
+
export function breadcrumbDocument(page: PublicPageContext, origin: string, homeLabel?: string): Node | null {
|
|
663
|
+
const node = breadcrumbNode(breadcrumbTrail(page, origin, homeLabel), `${pageUrl(page)}#breadcrumb`, origin, page.breadcrumbs !== undefined);
|
|
664
|
+
return node ? { "@context": "https://schema.org", ...node } : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Robots ───────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
export interface RobotsSettings {
|
|
670
|
+
/** "large" (default), "standard", "none", or "" to omit. */
|
|
671
|
+
maxImagePreview?: string | null;
|
|
672
|
+
/** Characters; -1 = no limit (default). null/undefined omits. */
|
|
673
|
+
maxSnippet?: number | null;
|
|
674
|
+
/** Seconds; -1 = no limit (default). null/undefined omits. */
|
|
675
|
+
maxVideoPreview?: number | null;
|
|
676
|
+
/** Add nofollow site-wide. */
|
|
677
|
+
nofollow?: boolean;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Merge the page's robots value (EmDash sets "noindex, nofollow" for entries
|
|
682
|
+
* marked No index) with the site-wide directives:
|
|
683
|
+
* "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1".
|
|
684
|
+
*/
|
|
685
|
+
export function robotsContent(pageRobots: string | null | undefined, settings: RobotsSettings): string {
|
|
686
|
+
const tokens = (pageRobots ?? "")
|
|
687
|
+
.split(",")
|
|
688
|
+
.map((t) => t.trim().toLowerCase())
|
|
689
|
+
.filter(Boolean);
|
|
690
|
+
const has = (t: string) => tokens.includes(t);
|
|
691
|
+
const keyed = (k: string) => tokens.some((t) => t.startsWith(`${k}:`));
|
|
692
|
+
const noindex = has("noindex") || has("none");
|
|
693
|
+
const nofollow = has("nofollow") || has("none") || !!settings.nofollow;
|
|
694
|
+
const out = [noindex ? "noindex" : "index", nofollow ? "nofollow" : "follow"];
|
|
695
|
+
for (const t of tokens) if (!["index", "noindex", "follow", "nofollow", "all", "none"].includes(t)) out.push(t);
|
|
696
|
+
if (!noindex) {
|
|
697
|
+
const image = settings.maxImagePreview ?? "large";
|
|
698
|
+
if (image && ["none", "standard", "large"].includes(image) && !keyed("max-image-preview")) out.push(`max-image-preview:${image}`);
|
|
699
|
+
if (typeof settings.maxSnippet === "number" && Number.isFinite(settings.maxSnippet) && !keyed("max-snippet"))
|
|
700
|
+
out.push(`max-snippet:${Math.max(-1, Math.trunc(settings.maxSnippet))}`);
|
|
701
|
+
if (typeof settings.maxVideoPreview === "number" && Number.isFinite(settings.maxVideoPreview) && !keyed("max-video-preview"))
|
|
702
|
+
out.push(`max-video-preview:${Math.max(-1, Math.trunc(settings.maxVideoPreview))}`);
|
|
703
|
+
}
|
|
704
|
+
return out.join(", ");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── og:locale ────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
/** Default territory for languages whose usual one isn't the language code uppercased. */
|
|
710
|
+
const DEFAULT_REGION: Record<string, string> = {
|
|
711
|
+
en: "US", ja: "JP", zh: "CN", ko: "KR", sv: "SE", da: "DK", nb: "NO", nn: "NO", el: "GR", cs: "CZ",
|
|
712
|
+
uk: "UA", he: "IL", ar: "AR", hi: "IN", vi: "VN", et: "EE", sl: "SI", sr: "RS", ca: "ES", ga: "IE",
|
|
713
|
+
fa: "IR", ms: "MY", bn: "IN", ur: "PK", sq: "AL", ka: "GE", hy: "AM", kk: "KZ", eu: "ES", gl: "ES",
|
|
714
|
+
cy: "GB", fil: "PH", tl: "PH", sw: "KE", ta: "IN", te: "IN", af: "ZA", zu: "ZA",
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/** Open Graph locale ("en_US") from a BCP 47 tag ("en", "en-US", "pt-br"). */
|
|
718
|
+
export function ogLocale(locale: string | null | undefined): string | null {
|
|
719
|
+
if (!locale) return null;
|
|
720
|
+
const parts = locale.trim().replace(/_/g, "-").split("-").filter(Boolean);
|
|
721
|
+
const lang = parts[0]?.toLowerCase();
|
|
722
|
+
if (!lang || !/^[a-z]{2,3}$/.test(lang)) return null;
|
|
723
|
+
const region = parts.slice(1).find((p) => /^[a-z]{2}$/i.test(p))?.toUpperCase() ?? DEFAULT_REGION[lang] ?? lang.toUpperCase();
|
|
724
|
+
return `${lang}_${region}`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── Videos ───────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
/** A video a theme passes in the page context (`page.coywolf.videos`). */
|
|
730
|
+
export interface ThemeVideo {
|
|
731
|
+
name?: string;
|
|
732
|
+
description?: string;
|
|
733
|
+
thumbnailUrl?: string | string[];
|
|
734
|
+
embedUrl?: string;
|
|
735
|
+
contentUrl?: string;
|
|
736
|
+
uploadDate?: string;
|
|
737
|
+
duration?: string;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/;
|
|
741
|
+
|
|
742
|
+
/** Clean a theme-provided video into a VideoObject node, or null if it has nothing usable. */
|
|
743
|
+
export function themeVideoNode(input: unknown, origin: string): Node | null {
|
|
744
|
+
if (!input || typeof input !== "object") return null;
|
|
745
|
+
const v = input as Record<string, unknown>;
|
|
746
|
+
const text = (x: unknown, max: number) => (typeof x === "string" ? x.trim().slice(0, max) : "");
|
|
747
|
+
const url = (x: unknown) => (typeof x === "string" ? absolute(x.trim(), origin) : undefined);
|
|
748
|
+
const embedUrl = url(v.embedUrl);
|
|
749
|
+
const contentUrl = url(v.contentUrl);
|
|
750
|
+
if (!embedUrl && !contentUrl) return null;
|
|
751
|
+
const thumbs = (Array.isArray(v.thumbnailUrl) ? v.thumbnailUrl : [v.thumbnailUrl]).map(url).filter((t): t is string => !!t);
|
|
752
|
+
const node: Node = { "@type": "VideoObject" };
|
|
753
|
+
const name = text(v.name, 200);
|
|
754
|
+
if (name) node.name = name;
|
|
755
|
+
const description = text(v.description, 1000) || name;
|
|
756
|
+
if (description) node.description = description;
|
|
757
|
+
if (thumbs.length) node.thumbnailUrl = thumbs;
|
|
758
|
+
if (typeof v.uploadDate === "string" && !Number.isNaN(Date.parse(v.uploadDate))) node.uploadDate = v.uploadDate;
|
|
759
|
+
if (typeof v.duration === "string" && ISO_DURATION.test(v.duration)) node.duration = v.duration;
|
|
760
|
+
if (embedUrl) node.embedUrl = embedUrl;
|
|
761
|
+
if (contentUrl) node.contentUrl = contentUrl;
|
|
762
|
+
return node;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Add VideoObject nodes to a graph and link them from the Article (or the
|
|
767
|
+
* WebPage when there's no Article). Duplicates (same embed or content URL)
|
|
768
|
+
* are dropped; each video gets an @id under the page URL.
|
|
769
|
+
*/
|
|
770
|
+
export function attachVideos(graph: { "@graph": Node[] }, videos: Node[]): void {
|
|
771
|
+
if (!videos.length) return;
|
|
772
|
+
const nodes = graph["@graph"];
|
|
773
|
+
const article = nodes.find((n) => typeof n["@id"] === "string" && (n["@id"] as string).endsWith("#article"));
|
|
774
|
+
const webpage = nodes.find((n) => typeof n["@id"] === "string" && (n["@id"] as string).endsWith("#webpage"));
|
|
775
|
+
const owner = article ?? webpage;
|
|
776
|
+
if (!owner) return;
|
|
777
|
+
const base = String(owner["@id"]).replace(/#[^#]*$/, "");
|
|
778
|
+
const seen = new Set<string>();
|
|
779
|
+
const refs: Node[] = [];
|
|
780
|
+
for (const video of videos) {
|
|
781
|
+
const key = String(video.embedUrl ?? video.contentUrl ?? "");
|
|
782
|
+
if (!key || seen.has(key)) continue;
|
|
783
|
+
seen.add(key);
|
|
784
|
+
const uid = /([0-9a-f]{32})/.exec(key)?.[1];
|
|
785
|
+
const id = `${base}#video-${uid ?? refs.length + 1}`;
|
|
786
|
+
const { "@context": _omit, ...rest } = video;
|
|
787
|
+
nodes.splice(nodes.indexOf(owner) + 1 + refs.length, 0, { ...rest, "@id": id });
|
|
788
|
+
refs.push({ "@id": id });
|
|
789
|
+
}
|
|
790
|
+
if (refs.length) owner.video = refs.length === 1 ? refs[0] : refs;
|
|
791
|
+
}
|