@sarimarcus/content-sites-core 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +9 -1
- package/dist/schema/context.d.ts +11 -0
- package/dist/schema/context.js +95 -0
- package/dist/schema/events.d.ts +31 -0
- package/dist/schema/events.js +139 -0
- package/dist/schema/graph.d.ts +35 -0
- package/dist/schema/graph.js +64 -0
- package/dist/schema/hours.d.ts +47 -0
- package/dist/schema/hours.js +212 -0
- package/dist/schema/index.d.ts +14 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/pages.d.ts +62 -0
- package/dist/schema/pages.js +213 -0
- package/dist/schema/places.d.ts +27 -0
- package/dist/schema/places.js +94 -0
- package/dist/schema/types.d.ts +77 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/values.d.ts +42 -0
- package/dist/schema/values.js +86 -0
- package/package.json +5 -1
- package/src/schema/context.ts +96 -0
- package/src/schema/events.ts +175 -0
- package/src/schema/graph.ts +92 -0
- package/src/schema/hours.ts +234 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/pages.ts +281 -0
- package/src/schema/places.ts +123 -0
- package/src/schema/types.ts +87 -0
- package/src/schema/values.ts +102 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** A JSON-LD node as assembled. `undefined` values are dropped when the graph is serialised. */
|
|
2
|
+
export type JsonLdNode = Record<string, any>;
|
|
3
|
+
/** One `{ type, data }` entry a page hands to its StructuredData component. */
|
|
4
|
+
export interface SchemaEntry<T extends string = string> {
|
|
5
|
+
type: T;
|
|
6
|
+
data: Record<string, any>;
|
|
7
|
+
}
|
|
8
|
+
export interface OpeningHoursSpecification {
|
|
9
|
+
'@type': 'OpeningHoursSpecification';
|
|
10
|
+
dayOfWeek: string | string[];
|
|
11
|
+
opens: string;
|
|
12
|
+
closes: string;
|
|
13
|
+
}
|
|
14
|
+
export interface IdRef {
|
|
15
|
+
'@id': string;
|
|
16
|
+
}
|
|
17
|
+
/** An author record as the site stores it; only these fields reach JSON-LD. */
|
|
18
|
+
export interface SchemaAuthorProfile {
|
|
19
|
+
name: string;
|
|
20
|
+
slug?: string;
|
|
21
|
+
photo?: string;
|
|
22
|
+
socialLinks?: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface ResolvedAuthor {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
url: string;
|
|
28
|
+
image?: string;
|
|
29
|
+
sameAs?: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Path segment per entity kind, e.g. `{ hotel: 'lodges' }`. A kind not listed uses its own name. */
|
|
32
|
+
export type RouteMap = Partial<Record<string, string>>;
|
|
33
|
+
export interface SchemaContextInput {
|
|
34
|
+
/** Site origin, with or without a trailing slash. */
|
|
35
|
+
siteUrl: string;
|
|
36
|
+
/** Base path without a trailing slash (`''` at the root). */
|
|
37
|
+
basePath: string;
|
|
38
|
+
/** The page's own pathname (`Astro.url.pathname`). */
|
|
39
|
+
pagePath: string;
|
|
40
|
+
/** Whether a file exists under the site's `public/` (path with a leading slash). Defaults to "no". */
|
|
41
|
+
fileExists?: (publicPath: string) => boolean;
|
|
42
|
+
/** An image path containing this marker is already an OG image. */
|
|
43
|
+
ogImageMarker?: string;
|
|
44
|
+
/** Author profile by display name. */
|
|
45
|
+
findAuthor?: (name: string) => SchemaAuthorProfile | undefined;
|
|
46
|
+
/** Markdown → plain text, for JSON-LD string values. */
|
|
47
|
+
plainText?: (markdown: string) => string;
|
|
48
|
+
/** `publisher` for a content item, given its author. Defaults to the Organization. */
|
|
49
|
+
publisher?: (authorName: string | undefined, ctx: SchemaContext) => IdRef;
|
|
50
|
+
/** Route segments per entity kind. */
|
|
51
|
+
routes?: RouteMap;
|
|
52
|
+
/** Clock for the future-date clamp and `defaultToNow`. */
|
|
53
|
+
now?: () => Date;
|
|
54
|
+
}
|
|
55
|
+
export interface SchemaContext {
|
|
56
|
+
siteUrl: string;
|
|
57
|
+
basePath: string;
|
|
58
|
+
siteBaseUrl: string;
|
|
59
|
+
pageUrl: string;
|
|
60
|
+
orgId: string;
|
|
61
|
+
websiteId: string;
|
|
62
|
+
/** `${siteUrl}${basePath}${path}` for a root-relative path. */
|
|
63
|
+
abs(path: string): string;
|
|
64
|
+
/** A root-relative path made absolute; an `http…` value passes through. */
|
|
65
|
+
absOrHttp(path: string): string;
|
|
66
|
+
/** Item URL for an entity kind: `${siteUrl}${basePath}/${segment}/${slug}`. */
|
|
67
|
+
route(kind: string, slug: string): string;
|
|
68
|
+
fileExists(publicPath: string): boolean;
|
|
69
|
+
resolveImageUrl(imagePath: string | undefined, slug?: string): string | undefined;
|
|
70
|
+
resolveAuthor(authorName: string | undefined): ResolvedAuthor | null;
|
|
71
|
+
formatDate(dateStr: string | undefined, defaultToNow?: boolean): string | undefined;
|
|
72
|
+
plainText(markdown: string): string;
|
|
73
|
+
publisherFor(authorName: string | undefined): IdRef;
|
|
74
|
+
}
|
|
75
|
+
/** Builds the node(s) for one schema entry; `null` emits nothing. */
|
|
76
|
+
export type SchemaBuilder = (data: Record<string, any>, ctx: SchemaContext) => JsonLdNode | JsonLdNode[] | null;
|
|
77
|
+
export type SchemaBuilders = Record<string, SchemaBuilder>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JsonLdNode } from './types.ts';
|
|
2
|
+
/** First integer in a price string; any "free" reads as 0; null when there is no number. */
|
|
3
|
+
export declare function parsePriceFromString(priceStr: string | undefined): number | null;
|
|
4
|
+
/** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
|
|
5
|
+
export declare function cleanStreetAddress(fullAddress: string | undefined): string;
|
|
6
|
+
/** A €-symbol range (`€`…`€€€€`) from a price string, inferred from `priceFrom` when given. */
|
|
7
|
+
export declare function normalizePriceRange(priceStr: string | undefined, priceFrom?: number): string;
|
|
8
|
+
/** A hotel range as `€120-€200` (from `120-200€`), else `€€€`. */
|
|
9
|
+
export declare function normalizeHotelPriceRange(priceStr: string | undefined): string;
|
|
10
|
+
/** `postalCode`, else the first 5-digit run in `address`. */
|
|
11
|
+
export declare function postalCodeOf(data: {
|
|
12
|
+
postalCode?: string;
|
|
13
|
+
address?: string;
|
|
14
|
+
}): string | undefined;
|
|
15
|
+
/** How an entity's PostalAddress is filled: fixed city values or per-item fields. */
|
|
16
|
+
export interface AddressPolicy {
|
|
17
|
+
locality(data: Record<string, any>): string | undefined;
|
|
18
|
+
region(data: Record<string, any>): string | undefined;
|
|
19
|
+
country(data: Record<string, any>): string | undefined;
|
|
20
|
+
/** Postal code used when the item has none. */
|
|
21
|
+
postalFallback?: string;
|
|
22
|
+
/** Drop an empty street (`""`) instead of emitting it. */
|
|
23
|
+
omitEmptyStreet?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Address read from each item's own `city`, `region` and `country`; empty values are dropped. */
|
|
26
|
+
export declare function dataAddressPolicy(): AddressPolicy;
|
|
27
|
+
export interface PostalAddressParts {
|
|
28
|
+
/** Raw address to clean into `streetAddress`. Absent: no street key. */
|
|
29
|
+
street?: string;
|
|
30
|
+
/** Postal code before the policy fallback. Absent: no postal key. */
|
|
31
|
+
postalCode?: string;
|
|
32
|
+
}
|
|
33
|
+
/** A PostalAddress in the fixed key order: street, locality, region, postal code, country. */
|
|
34
|
+
export declare function postalAddress(policy: AddressPolicy, data: Record<string, any>, parts?: PostalAddressParts): JsonLdNode;
|
|
35
|
+
export declare function geoCoordinates(coordinates: {
|
|
36
|
+
lat: number;
|
|
37
|
+
lng: number;
|
|
38
|
+
} | undefined): JsonLdNode | undefined;
|
|
39
|
+
export declare function aggregateRating(data: {
|
|
40
|
+
rating?: number;
|
|
41
|
+
ratingCount?: number;
|
|
42
|
+
}): JsonLdNode | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** First integer in a price string; any "free" reads as 0; null when there is no number. */
|
|
2
|
+
export function parsePriceFromString(priceStr) {
|
|
3
|
+
if (!priceStr)
|
|
4
|
+
return null;
|
|
5
|
+
if (priceStr.toLowerCase().includes('free'))
|
|
6
|
+
return 0;
|
|
7
|
+
const match = priceStr.match(/(\d+)/);
|
|
8
|
+
return match ? parseFloat(match[1]) : null;
|
|
9
|
+
}
|
|
10
|
+
/** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
|
|
11
|
+
export function cleanStreetAddress(fullAddress) {
|
|
12
|
+
if (!fullAddress)
|
|
13
|
+
return '';
|
|
14
|
+
const parts = fullAddress.split(',').map((p) => p.trim());
|
|
15
|
+
const streetName = parts[0];
|
|
16
|
+
if (parts.length < 2)
|
|
17
|
+
return streetName;
|
|
18
|
+
const cleanedNumber = parts[1].replace(/º\s*/g, '').trim();
|
|
19
|
+
const isNeighbourhoodOrPostal = /^\d{5}/.test(cleanedNumber) || /^[A-ZÁÉÍÓÚÜÑ][a-záéíóúüñ]/.test(cleanedNumber);
|
|
20
|
+
return isNeighbourhoodOrPostal || !cleanedNumber ? streetName : `${streetName}, ${cleanedNumber}`;
|
|
21
|
+
}
|
|
22
|
+
/** A €-symbol range (`€`…`€€€€`) from a price string, inferred from `priceFrom` when given. */
|
|
23
|
+
export function normalizePriceRange(priceStr, priceFrom) {
|
|
24
|
+
if (!priceStr)
|
|
25
|
+
return '€€';
|
|
26
|
+
if (/^€+$/.test(priceStr))
|
|
27
|
+
return priceStr;
|
|
28
|
+
const price = priceFrom || parsePriceFromString(priceStr) || 10;
|
|
29
|
+
if (price < 10)
|
|
30
|
+
return '€';
|
|
31
|
+
if (price < 20)
|
|
32
|
+
return '€€';
|
|
33
|
+
if (price < 40)
|
|
34
|
+
return '€€€';
|
|
35
|
+
return '€€€€';
|
|
36
|
+
}
|
|
37
|
+
/** A hotel range as `€120-€200` (from `120-200€`), else `€€€`. */
|
|
38
|
+
export function normalizeHotelPriceRange(priceStr) {
|
|
39
|
+
if (!priceStr)
|
|
40
|
+
return '€€€';
|
|
41
|
+
if (/^€\d+-€\d+$/.test(priceStr))
|
|
42
|
+
return priceStr;
|
|
43
|
+
const match = priceStr.match(/(\d+)-(\d+)€?/);
|
|
44
|
+
if (match)
|
|
45
|
+
return `€${match[1]}-€${match[2]}`;
|
|
46
|
+
return '€€€';
|
|
47
|
+
}
|
|
48
|
+
/** `postalCode`, else the first 5-digit run in `address`. */
|
|
49
|
+
export function postalCodeOf(data) {
|
|
50
|
+
return data.postalCode || data.address?.match(/(\d{5})/)?.[1];
|
|
51
|
+
}
|
|
52
|
+
/** Address read from each item's own `city`, `region` and `country`; empty values are dropped. */
|
|
53
|
+
export function dataAddressPolicy() {
|
|
54
|
+
return {
|
|
55
|
+
locality: (d) => d.city || undefined,
|
|
56
|
+
region: (d) => d.region || undefined,
|
|
57
|
+
country: (d) => d.country || undefined,
|
|
58
|
+
omitEmptyStreet: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** A PostalAddress in the fixed key order: street, locality, region, postal code, country. */
|
|
62
|
+
export function postalAddress(policy, data, parts = {}) {
|
|
63
|
+
const node = { '@type': 'PostalAddress' };
|
|
64
|
+
if ('street' in parts) {
|
|
65
|
+
const street = cleanStreetAddress(parts.street);
|
|
66
|
+
node.streetAddress = policy.omitEmptyStreet ? street || undefined : street;
|
|
67
|
+
}
|
|
68
|
+
node.addressLocality = policy.locality(data);
|
|
69
|
+
node.addressRegion = policy.region(data);
|
|
70
|
+
if ('postalCode' in parts)
|
|
71
|
+
node.postalCode = parts.postalCode || policy.postalFallback;
|
|
72
|
+
node.addressCountry = policy.country(data);
|
|
73
|
+
return node;
|
|
74
|
+
}
|
|
75
|
+
export function geoCoordinates(coordinates) {
|
|
76
|
+
return coordinates ? { '@type': 'GeoCoordinates', latitude: coordinates.lat, longitude: coordinates.lng } : undefined;
|
|
77
|
+
}
|
|
78
|
+
export function aggregateRating(data) {
|
|
79
|
+
return data.rating ? {
|
|
80
|
+
'@type': 'AggregateRating',
|
|
81
|
+
ratingValue: data.rating,
|
|
82
|
+
bestRating: 5,
|
|
83
|
+
worstRating: 1,
|
|
84
|
+
reviewCount: data.ratingCount ?? 1,
|
|
85
|
+
} : undefined;
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
"./types": {
|
|
58
58
|
"types": "./dist/types/index.d.ts",
|
|
59
59
|
"default": "./dist/types/index.js"
|
|
60
|
+
},
|
|
61
|
+
"./schema": {
|
|
62
|
+
"types": "./dist/schema/index.d.ts",
|
|
63
|
+
"default": "./dist/schema/index.js"
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
66
|
"dependencies": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { normalizeSiteUrl, toAbsoluteSiteUrl } from '../url/index.ts';
|
|
2
|
+
import { slugify } from '../text/index.ts';
|
|
3
|
+
import type { JsonLdNode, ResolvedAuthor, SchemaContext, SchemaContextInput } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/** The site root with a trailing slash (either form) collapses to the bare base URL. */
|
|
6
|
+
export function normalizeHomepageUrl(url: string, siteUrl: string, siteBaseUrl: string): string {
|
|
7
|
+
return (url === `${siteUrl}/` || url === `${siteBaseUrl}/`) ? siteBaseUrl : url;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Every string in a JSON-LD value through `normalizeSiteUrl` + `normalizeHomepageUrl`, keys in their order. */
|
|
11
|
+
export function normalizeStructuredDataUrls<T>(value: T, siteUrl: string, basePath: string): T {
|
|
12
|
+
const siteBaseUrl = `${siteUrl}${basePath || ''}`;
|
|
13
|
+
const walk = (v: unknown): unknown => {
|
|
14
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
15
|
+
if (v && typeof v === 'object') {
|
|
16
|
+
return Object.fromEntries(Object.entries(v).map(([key, entryValue]) => [key, walk(entryValue)]));
|
|
17
|
+
}
|
|
18
|
+
if (typeof v === 'string') return normalizeHomepageUrl(normalizeSiteUrl(v, siteUrl, basePath), siteUrl, siteBaseUrl);
|
|
19
|
+
return v;
|
|
20
|
+
};
|
|
21
|
+
return walk(value) as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** ISO 8601, never in the future: a date-only value becomes UTC midnight, a future one is clamped to `now`. */
|
|
25
|
+
export function formatSchemaDate(dateStr: string | undefined, defaultToNow = false, now: () => Date = () => new Date()): string | undefined {
|
|
26
|
+
if (!dateStr) return defaultToNow ? now().toISOString() : undefined;
|
|
27
|
+
const isoDate = dateStr.includes('T') ? dateStr : `${dateStr}T00:00:00Z`;
|
|
28
|
+
const current = now();
|
|
29
|
+
if (new Date(isoDate) > current) return current.toISOString();
|
|
30
|
+
return isoDate;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createPersonSchema(author: ResolvedAuthor): JsonLdNode {
|
|
34
|
+
return {
|
|
35
|
+
'@type': 'Person',
|
|
36
|
+
'@id': author.id,
|
|
37
|
+
name: author.name,
|
|
38
|
+
url: author.url,
|
|
39
|
+
image: author.image,
|
|
40
|
+
sameAs: author.sameAs,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The node, preceded by its author's Person node when the author resolves. */
|
|
45
|
+
export function withAuthorPersonSchema(ctx: SchemaContext, node: JsonLdNode, authorName: string | undefined): JsonLdNode | JsonLdNode[] {
|
|
46
|
+
const author = ctx.resolveAuthor(authorName);
|
|
47
|
+
return author ? [createPersonSchema(author), node] : node;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSchemaContext(input: SchemaContextInput): SchemaContext {
|
|
51
|
+
const siteUrl = input.siteUrl.replace(/\/$/, '');
|
|
52
|
+
const basePath = input.basePath;
|
|
53
|
+
const siteBaseUrl = `${siteUrl}${basePath || ''}`;
|
|
54
|
+
const fileExists = input.fileExists ?? (() => false);
|
|
55
|
+
const marker = input.ogImageMarker ?? '/og/';
|
|
56
|
+
const routes = input.routes ?? {};
|
|
57
|
+
const now = input.now ?? (() => new Date());
|
|
58
|
+
|
|
59
|
+
const ctx: SchemaContext = {
|
|
60
|
+
siteUrl,
|
|
61
|
+
basePath,
|
|
62
|
+
siteBaseUrl,
|
|
63
|
+
pageUrl: toAbsoluteSiteUrl(input.pagePath, siteUrl, basePath),
|
|
64
|
+
orgId: `${siteBaseUrl}#organization`,
|
|
65
|
+
websiteId: `${siteBaseUrl}#website`,
|
|
66
|
+
abs: (path) => `${siteUrl}${basePath}${path}`,
|
|
67
|
+
absOrHttp: (path) => (path.startsWith('http') ? path : `${siteUrl}${basePath}${path}`),
|
|
68
|
+
route: (kind, slug) => `${siteUrl}${basePath}/${routes[kind] ?? kind}/${slug}`,
|
|
69
|
+
fileExists,
|
|
70
|
+
// /images/{type}/{name}.webp → /images/og/{type}/{name}.jpg, falling back to the slug's jpg (SLA-2817);
|
|
71
|
+
// omitted rather than pointing at a file that is not there.
|
|
72
|
+
resolveImageUrl(imagePath, slug) {
|
|
73
|
+
if (!imagePath) return undefined;
|
|
74
|
+
if (imagePath.startsWith('http')) return imagePath;
|
|
75
|
+
const ogPath = imagePath.includes(marker)
|
|
76
|
+
? imagePath
|
|
77
|
+
: imagePath.replace(/^\/images\//, '/images/og/').replace(/\.(webp|png|jpeg)$/, '.jpg');
|
|
78
|
+
const slugOgPath = slug ? ogPath.replace(/[^/]+$/, `${slug}.jpg`) : undefined;
|
|
79
|
+
const resolved = [ogPath, slugOgPath].find((p) => p && fileExists(p));
|
|
80
|
+
return resolved ? `${siteUrl}${basePath}${resolved}` : undefined;
|
|
81
|
+
},
|
|
82
|
+
resolveAuthor(authorName) {
|
|
83
|
+
if (!authorName) return null;
|
|
84
|
+
const profile = input.findAuthor?.(authorName);
|
|
85
|
+
const url = `${siteUrl}${basePath}/author/${profile?.slug || slugify(authorName)}`;
|
|
86
|
+
const image = profile?.photo
|
|
87
|
+
? (profile.photo.startsWith('http') ? profile.photo : `${siteUrl}${basePath}${profile.photo}`)
|
|
88
|
+
: undefined;
|
|
89
|
+
return { id: `${url}#person`, name: authorName, url, image, sameAs: profile?.socialLinks };
|
|
90
|
+
},
|
|
91
|
+
formatDate: (dateStr, defaultToNow = false) => formatSchemaDate(dateStr, defaultToNow, now),
|
|
92
|
+
plainText: input.plainText ?? ((markdown) => markdown),
|
|
93
|
+
publisherFor: (authorName) => (input.publisher ? input.publisher(authorName, ctx) : { '@id': ctx.orgId }),
|
|
94
|
+
};
|
|
95
|
+
return ctx;
|
|
96
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createPersonSchema } from './context.ts';
|
|
2
|
+
import { geoCoordinates, postalAddress, type AddressPolicy } from './values.ts';
|
|
3
|
+
import type { JsonLdNode, SchemaBuilder, SchemaContext } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/** Where events take place and who runs them; every value is the site's. */
|
|
6
|
+
export interface EventPolicy {
|
|
7
|
+
address: AddressPolicy;
|
|
8
|
+
/** Place name when the item has no `location`. Absent: such an item gets no `location`. */
|
|
9
|
+
defaultLocationName?: string;
|
|
10
|
+
/** Add `geo` from `coordinates` to the Place. */
|
|
11
|
+
geo?: boolean;
|
|
12
|
+
organizer(data: Record<string, any>): JsonLdNode;
|
|
13
|
+
performer(data: Record<string, any>): JsonLdNode | undefined;
|
|
14
|
+
currency?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function eventPrice(data: Record<string, any>): { isFree: boolean | undefined; price: number | null } {
|
|
18
|
+
const isFree = data.tags?.includes('free') || data.price?.toLowerCase().includes('free');
|
|
19
|
+
const priceMatch = data.price?.match(/(\d+)/);
|
|
20
|
+
return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Offers open three months before the start date.
|
|
24
|
+
function validFrom(startDate: string | undefined): string | null {
|
|
25
|
+
if (!startDate) return null;
|
|
26
|
+
const from = new Date(new Date(startDate));
|
|
27
|
+
from.setMonth(from.getMonth() - 3);
|
|
28
|
+
return from.toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function eventLocation(policy: EventPolicy, data: Record<string, any>): JsonLdNode | undefined {
|
|
32
|
+
const name = data.location || policy.defaultLocationName;
|
|
33
|
+
if (!name) return undefined;
|
|
34
|
+
const geo = policy.geo ? geoCoordinates(data.coordinates) : undefined;
|
|
35
|
+
return {
|
|
36
|
+
'@type': 'Place',
|
|
37
|
+
name,
|
|
38
|
+
address: postalAddress(policy.address, data),
|
|
39
|
+
...(geo && { geo }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const EVENT_STATUS = (rescheduled: unknown) => (rescheduled ? 'https://schema.org/EventRescheduled' : 'https://schema.org/EventScheduled');
|
|
44
|
+
const OFFLINE = 'https://schema.org/OfflineEventAttendanceMode';
|
|
45
|
+
|
|
46
|
+
export interface EventOptions extends EventPolicy {
|
|
47
|
+
/** Map `data.offers[]` to Offers when present (else one Offer from `price`). */
|
|
48
|
+
offersArray?: boolean;
|
|
49
|
+
/** Emit the Event node; when false only the companion Article is emitted. Default: always. */
|
|
50
|
+
emitEvent?: (data: Record<string, any>) => boolean;
|
|
51
|
+
articleSection?: string;
|
|
52
|
+
inLanguage?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Event at route `event`, its author's Person first, and a companion Article (SLA-357). */
|
|
56
|
+
export function eventBuilder(options: EventOptions): SchemaBuilder {
|
|
57
|
+
const { offersArray = false, emitEvent = () => true, articleSection = 'Events', inLanguage = 'en', currency = 'EUR' } = options;
|
|
58
|
+
return (data, ctx) => {
|
|
59
|
+
const { isFree, price } = eventPrice(data);
|
|
60
|
+
const from = validFrom(data.startDate);
|
|
61
|
+
const pageRoute = ctx.route('event', data.slug);
|
|
62
|
+
const singleOffer = price !== null ? {
|
|
63
|
+
'@type': 'Offer',
|
|
64
|
+
price,
|
|
65
|
+
priceCurrency: currency,
|
|
66
|
+
availability: 'https://schema.org/InStock',
|
|
67
|
+
url: data.officialWebsite || pageRoute,
|
|
68
|
+
validFrom: from,
|
|
69
|
+
} : undefined;
|
|
70
|
+
|
|
71
|
+
const event: JsonLdNode = {
|
|
72
|
+
'@type': 'Event',
|
|
73
|
+
name: data.name,
|
|
74
|
+
description: data.description,
|
|
75
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
76
|
+
startDate: data.startDate || undefined,
|
|
77
|
+
endDate: data.endDate || undefined,
|
|
78
|
+
previousStartDate: data.previousStartDate || undefined,
|
|
79
|
+
eventStatus: EVENT_STATUS(data.previousStartDate),
|
|
80
|
+
eventAttendanceMode: OFFLINE,
|
|
81
|
+
isAccessibleForFree: isFree,
|
|
82
|
+
location: eventLocation(options, data),
|
|
83
|
+
organizer: options.organizer(data),
|
|
84
|
+
performer: options.performer(data),
|
|
85
|
+
offers: offersArray && (data.offers?.length ?? 0) > 0
|
|
86
|
+
? data.offers.map((o: { price: number; priceCurrency?: string; url?: string; availability?: string }) => ({
|
|
87
|
+
'@type': 'Offer',
|
|
88
|
+
price: o.price,
|
|
89
|
+
priceCurrency: o.priceCurrency || currency,
|
|
90
|
+
availability: `https://schema.org/${o.availability || 'InStock'}`,
|
|
91
|
+
url: o.url || data.officialWebsite || pageRoute,
|
|
92
|
+
validFrom: from,
|
|
93
|
+
}))
|
|
94
|
+
: singleOffer,
|
|
95
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
96
|
+
url: pageRoute,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const author = ctx.resolveAuthor(data.author);
|
|
100
|
+
const article: JsonLdNode = {
|
|
101
|
+
'@type': 'Article',
|
|
102
|
+
'@id': `${pageRoute}#article`,
|
|
103
|
+
headline: data.name,
|
|
104
|
+
description: data.description,
|
|
105
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
106
|
+
datePublished: ctx.formatDate(data.datePublished || data.dateModified),
|
|
107
|
+
dateModified: ctx.formatDate(data.dateModified),
|
|
108
|
+
author: author ? { '@id': author.id } : undefined,
|
|
109
|
+
publisher: ctx.publisherFor(data.author),
|
|
110
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
111
|
+
articleSection,
|
|
112
|
+
inLanguage,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const nodes = emitEvent(data) ? [event, article] : [article];
|
|
116
|
+
return author ? [createPersonSchema(author), ...nodes] : nodes;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface EventItemListOptions extends EventPolicy {
|
|
121
|
+
/** Organizer of a listed item (may differ from the event page's). */
|
|
122
|
+
itemOrganizer(item: Record<string, any>): JsonLdNode;
|
|
123
|
+
/** A listed item is an Event only when this holds (else a WebPage). Default: it has a `startDate`. */
|
|
124
|
+
isEvent?: (item: Record<string, any>) => boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** ItemList of events, each linked at its `url`; an item that cannot be a valid Event becomes a WebPage. */
|
|
128
|
+
export function eventItemListBuilder(options: EventItemListOptions): SchemaBuilder {
|
|
129
|
+
const { isEvent = (item) => Boolean(item.startDate), currency = 'EUR' } = options;
|
|
130
|
+
return (data, ctx: SchemaContext) => ({
|
|
131
|
+
'@type': 'ItemList',
|
|
132
|
+
name: data.name,
|
|
133
|
+
numberOfItems: data.items?.length || 0,
|
|
134
|
+
itemListElement: data.items?.map((item: any) => {
|
|
135
|
+
const { isFree, price } = eventPrice(item);
|
|
136
|
+
const from = validFrom(item.startDate);
|
|
137
|
+
const performer = options.performer(item);
|
|
138
|
+
if (!isEvent(item)) {
|
|
139
|
+
return {
|
|
140
|
+
'@type': 'ListItem',
|
|
141
|
+
position: item.position,
|
|
142
|
+
item: { '@type': 'WebPage', name: item.name, description: item.description, url: ctx.abs(item.url) },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
'@type': 'ListItem',
|
|
147
|
+
position: item.position,
|
|
148
|
+
item: {
|
|
149
|
+
'@type': 'Event',
|
|
150
|
+
name: item.name,
|
|
151
|
+
description: item.description,
|
|
152
|
+
image: ctx.resolveImageUrl(item.image, item.slug),
|
|
153
|
+
url: ctx.abs(item.url),
|
|
154
|
+
startDate: item.startDate,
|
|
155
|
+
endDate: item.endDate,
|
|
156
|
+
previousStartDate: item.previousStartDate || undefined,
|
|
157
|
+
eventStatus: EVENT_STATUS(item.previousStartDate),
|
|
158
|
+
eventAttendanceMode: OFFLINE,
|
|
159
|
+
isAccessibleForFree: isFree,
|
|
160
|
+
location: eventLocation(options, item),
|
|
161
|
+
organizer: options.itemOrganizer(item),
|
|
162
|
+
performer,
|
|
163
|
+
offers: price !== null ? {
|
|
164
|
+
'@type': 'Offer',
|
|
165
|
+
price,
|
|
166
|
+
priceCurrency: currency,
|
|
167
|
+
availability: 'https://schema.org/InStock',
|
|
168
|
+
url: item.officialWebsite || ctx.route('event', item.slug),
|
|
169
|
+
validFrom: from,
|
|
170
|
+
} : undefined,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { normalizeSiteUrl } from '../url/index.ts';
|
|
2
|
+
import { normalizeHomepageUrl, normalizeStructuredDataUrls } from './context.ts';
|
|
3
|
+
import type { JsonLdNode, SchemaBuilders, SchemaContext, SchemaEntry } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export interface BreadcrumbInput {
|
|
6
|
+
name: string;
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildBreadcrumbList(ctx: SchemaContext, breadcrumbs: BreadcrumbInput[]): JsonLdNode {
|
|
11
|
+
return {
|
|
12
|
+
'@type': 'BreadcrumbList',
|
|
13
|
+
'@id': `${ctx.pageUrl}#breadcrumb`,
|
|
14
|
+
itemListElement: breadcrumbs.map((item, index) => ({
|
|
15
|
+
'@type': 'ListItem',
|
|
16
|
+
position: index + 1,
|
|
17
|
+
name: item.name,
|
|
18
|
+
item: normalizeHomepageUrl(normalizeSiteUrl(item.url, ctx.siteUrl, ctx.basePath), ctx.siteUrl, ctx.siteBaseUrl),
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PageInput {
|
|
24
|
+
entries: SchemaEntry[];
|
|
25
|
+
breadcrumbs?: BreadcrumbInput[];
|
|
26
|
+
pageTitle?: string;
|
|
27
|
+
pageDescription?: string;
|
|
28
|
+
datePublished?: string;
|
|
29
|
+
dateModified?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The page's WebPage node: name/description/dates fall back to the first entries that carry them. */
|
|
33
|
+
export function buildWebPageNode(ctx: SchemaContext, page: PageInput, inLanguage = 'en'): JsonLdNode {
|
|
34
|
+
const { entries } = page;
|
|
35
|
+
return {
|
|
36
|
+
'@type': 'WebPage',
|
|
37
|
+
'@id': ctx.pageUrl,
|
|
38
|
+
url: ctx.pageUrl,
|
|
39
|
+
name: page.pageTitle || entries[0]?.data.name || entries[0]?.data.headline,
|
|
40
|
+
description: page.pageDescription || entries[0]?.data.description,
|
|
41
|
+
inLanguage,
|
|
42
|
+
datePublished: ctx.formatDate(page.datePublished
|
|
43
|
+
|| entries.find((s) => s.data.datePublished)?.data.datePublished
|
|
44
|
+
|| entries.find((s) => s.data.dateModified)?.data.dateModified),
|
|
45
|
+
dateModified: ctx.formatDate(page.dateModified || (() => {
|
|
46
|
+
const s = entries.find((e) => e.data.dateModified || e.data.datePublished);
|
|
47
|
+
return s?.data.dateModified || s?.data.datePublished;
|
|
48
|
+
})()),
|
|
49
|
+
isPartOf: { '@id': ctx.websiteId },
|
|
50
|
+
...(page.breadcrumbs ? { breadcrumb: { '@id': `${ctx.pageUrl}#breadcrumb` } } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AssembleOptions extends PageInput {
|
|
55
|
+
/** The site's Organization node (`@id` = `ctx.orgId`). */
|
|
56
|
+
organization: JsonLdNode;
|
|
57
|
+
/** The site's WebSite node (`@id` = `ctx.websiteId`). */
|
|
58
|
+
website: JsonLdNode;
|
|
59
|
+
builders: SchemaBuilders;
|
|
60
|
+
inLanguage?: string;
|
|
61
|
+
/** Runs after the base nodes and breadcrumb, before any entry. */
|
|
62
|
+
beforeEntries?: (graph: JsonLdNode[]) => void;
|
|
63
|
+
/** Runs once every entry is in the graph. */
|
|
64
|
+
afterEntries?: (graph: JsonLdNode[]) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The page graph in emission order: Organization, WebSite, WebPage, BreadcrumbList (when breadcrumbs are given),
|
|
69
|
+
* then each entry's node(s) in call order. A type with no builder, or a builder returning null, emits nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function assembleGraph(ctx: SchemaContext, options: AssembleOptions): JsonLdNode[] {
|
|
72
|
+
const graph: JsonLdNode[] = [options.organization, options.website, buildWebPageNode(ctx, options, options.inLanguage)];
|
|
73
|
+
if (options.breadcrumbs) graph.push(buildBreadcrumbList(ctx, options.breadcrumbs));
|
|
74
|
+
options.beforeEntries?.(graph);
|
|
75
|
+
for (const { type, data } of options.entries) {
|
|
76
|
+
const builder = options.builders[type];
|
|
77
|
+
const result = builder ? builder(data, ctx) : null;
|
|
78
|
+
if (result === null) continue;
|
|
79
|
+
if (Array.isArray(result)) graph.push(...result);
|
|
80
|
+
else graph.push(result);
|
|
81
|
+
}
|
|
82
|
+
options.afterEntries?.(graph);
|
|
83
|
+
return graph;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The serialisable JSON-LD document: `@context` plus the graph with every URL normalised. */
|
|
87
|
+
export function finalizeGraph(ctx: SchemaContext, graph: JsonLdNode[]): JsonLdNode {
|
|
88
|
+
return {
|
|
89
|
+
'@context': 'https://schema.org',
|
|
90
|
+
'@graph': normalizeStructuredDataUrls(graph, ctx.siteUrl, ctx.basePath),
|
|
91
|
+
};
|
|
92
|
+
}
|