@sarimarcus/content-sites-core 0.12.1 → 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 +8 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.14.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- New `./schema` (compiled): JSON-LD types, URL/date/price/address normalisers, opening-hours parsing and specs, graph assembly and generic builders (article, FAQ, collection, item lists, event, how-to, website/organization, about/contact, person, offers, hotel, activity, tourist trip) driven by site policy; the opening-hours validator imports it (SLA-2737).
|
|
8
|
+
|
|
9
|
+
## 0.13.0 (2026-10-01)
|
|
10
|
+
|
|
11
|
+
- No changes; lockstep release with ui (SLA-2819).
|
|
12
|
+
|
|
5
13
|
## 0.12.1 (2026-10-01)
|
|
6
14
|
|
|
7
15
|
- No changes; lockstep release with tourism (SLA-2739).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Non-visual code shared by the content sites: generic utilities, Astro config bui
|
|
|
4
4
|
and the type contracts `ui` and `tourism` build on (for example the card item and tag-link shapes that
|
|
5
5
|
today's ui components import from tourism code).
|
|
6
6
|
|
|
7
|
-
Named entry points: `url`, `dates`, `geo`, `dom`, `text`, `config` and `
|
|
7
|
+
Named entry points: `url`, `dates`, `geo`, `dom`, `text`, `config`, `types` and `schema` (`src/internal` is never exported). No `.astro`
|
|
8
8
|
files. Lowest layer: imports nothing from the other packages; its one runtime dependency is `marked` (`readingTime`). Ownership per path is in
|
|
9
9
|
`.planning/platform/catalogue.json` (owner `core`).
|
|
10
10
|
|
|
@@ -15,3 +15,11 @@ missing or stale `dist/`.
|
|
|
15
15
|
|
|
16
16
|
Core code never imports `astro:content`, never reads `import.meta.env` or `import.meta.glob` (values and glob
|
|
17
17
|
results come in as parameters), and never names a site or vertical. `validate-packages.mjs` enforces all three.
|
|
18
|
+
|
|
19
|
+
`schema` assembles a page's JSON-LD graph: `createSchemaContext` (site URL, base path, page path, and the site's
|
|
20
|
+
author lookup, public-file check, markdown stripper, publisher policy and route map), `assembleGraph` (base nodes,
|
|
21
|
+
breadcrumb, then each entry's builder in call order, with hooks before and after the entries) and `finalizeGraph`.
|
|
22
|
+
Builders are factories whose options carry each site's differences, so a site reproduces its current output byte
|
|
23
|
+
for byte, key order included. It also holds the opening-hours model (`generateOpeningHours`, `parseHoursString`,
|
|
24
|
+
`assertedOpenDays`, `hoursStringOpenDays`) that `scripts/validate-opening-hours.mjs` imports. The factory scripts
|
|
25
|
+
load `dist/`; `scripts/lib/core-dist.mjs` rebuilds it when it is missing or older than `src/`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { JsonLdNode, ResolvedAuthor, SchemaContext, SchemaContextInput } from './types.ts';
|
|
2
|
+
/** The site root with a trailing slash (either form) collapses to the bare base URL. */
|
|
3
|
+
export declare function normalizeHomepageUrl(url: string, siteUrl: string, siteBaseUrl: string): string;
|
|
4
|
+
/** Every string in a JSON-LD value through `normalizeSiteUrl` + `normalizeHomepageUrl`, keys in their order. */
|
|
5
|
+
export declare function normalizeStructuredDataUrls<T>(value: T, siteUrl: string, basePath: string): T;
|
|
6
|
+
/** ISO 8601, never in the future: a date-only value becomes UTC midnight, a future one is clamped to `now`. */
|
|
7
|
+
export declare function formatSchemaDate(dateStr: string | undefined, defaultToNow?: boolean, now?: () => Date): string | undefined;
|
|
8
|
+
export declare function createPersonSchema(author: ResolvedAuthor): JsonLdNode;
|
|
9
|
+
/** The node, preceded by its author's Person node when the author resolves. */
|
|
10
|
+
export declare function withAuthorPersonSchema(ctx: SchemaContext, node: JsonLdNode, authorName: string | undefined): JsonLdNode | JsonLdNode[];
|
|
11
|
+
export declare function createSchemaContext(input: SchemaContextInput): SchemaContext;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { normalizeSiteUrl, toAbsoluteSiteUrl } from "../url/index.js";
|
|
2
|
+
import { slugify } from "../text/index.js";
|
|
3
|
+
/** The site root with a trailing slash (either form) collapses to the bare base URL. */
|
|
4
|
+
export function normalizeHomepageUrl(url, siteUrl, siteBaseUrl) {
|
|
5
|
+
return (url === `${siteUrl}/` || url === `${siteBaseUrl}/`) ? siteBaseUrl : url;
|
|
6
|
+
}
|
|
7
|
+
/** Every string in a JSON-LD value through `normalizeSiteUrl` + `normalizeHomepageUrl`, keys in their order. */
|
|
8
|
+
export function normalizeStructuredDataUrls(value, siteUrl, basePath) {
|
|
9
|
+
const siteBaseUrl = `${siteUrl}${basePath || ''}`;
|
|
10
|
+
const walk = (v) => {
|
|
11
|
+
if (Array.isArray(v))
|
|
12
|
+
return v.map(walk);
|
|
13
|
+
if (v && typeof v === 'object') {
|
|
14
|
+
return Object.fromEntries(Object.entries(v).map(([key, entryValue]) => [key, walk(entryValue)]));
|
|
15
|
+
}
|
|
16
|
+
if (typeof v === 'string')
|
|
17
|
+
return normalizeHomepageUrl(normalizeSiteUrl(v, siteUrl, basePath), siteUrl, siteBaseUrl);
|
|
18
|
+
return v;
|
|
19
|
+
};
|
|
20
|
+
return walk(value);
|
|
21
|
+
}
|
|
22
|
+
/** ISO 8601, never in the future: a date-only value becomes UTC midnight, a future one is clamped to `now`. */
|
|
23
|
+
export function formatSchemaDate(dateStr, defaultToNow = false, now = () => new Date()) {
|
|
24
|
+
if (!dateStr)
|
|
25
|
+
return defaultToNow ? now().toISOString() : undefined;
|
|
26
|
+
const isoDate = dateStr.includes('T') ? dateStr : `${dateStr}T00:00:00Z`;
|
|
27
|
+
const current = now();
|
|
28
|
+
if (new Date(isoDate) > current)
|
|
29
|
+
return current.toISOString();
|
|
30
|
+
return isoDate;
|
|
31
|
+
}
|
|
32
|
+
export function createPersonSchema(author) {
|
|
33
|
+
return {
|
|
34
|
+
'@type': 'Person',
|
|
35
|
+
'@id': author.id,
|
|
36
|
+
name: author.name,
|
|
37
|
+
url: author.url,
|
|
38
|
+
image: author.image,
|
|
39
|
+
sameAs: author.sameAs,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The node, preceded by its author's Person node when the author resolves. */
|
|
43
|
+
export function withAuthorPersonSchema(ctx, node, authorName) {
|
|
44
|
+
const author = ctx.resolveAuthor(authorName);
|
|
45
|
+
return author ? [createPersonSchema(author), node] : node;
|
|
46
|
+
}
|
|
47
|
+
export function createSchemaContext(input) {
|
|
48
|
+
const siteUrl = input.siteUrl.replace(/\/$/, '');
|
|
49
|
+
const basePath = input.basePath;
|
|
50
|
+
const siteBaseUrl = `${siteUrl}${basePath || ''}`;
|
|
51
|
+
const fileExists = input.fileExists ?? (() => false);
|
|
52
|
+
const marker = input.ogImageMarker ?? '/og/';
|
|
53
|
+
const routes = input.routes ?? {};
|
|
54
|
+
const now = input.now ?? (() => new Date());
|
|
55
|
+
const ctx = {
|
|
56
|
+
siteUrl,
|
|
57
|
+
basePath,
|
|
58
|
+
siteBaseUrl,
|
|
59
|
+
pageUrl: toAbsoluteSiteUrl(input.pagePath, siteUrl, basePath),
|
|
60
|
+
orgId: `${siteBaseUrl}#organization`,
|
|
61
|
+
websiteId: `${siteBaseUrl}#website`,
|
|
62
|
+
abs: (path) => `${siteUrl}${basePath}${path}`,
|
|
63
|
+
absOrHttp: (path) => (path.startsWith('http') ? path : `${siteUrl}${basePath}${path}`),
|
|
64
|
+
route: (kind, slug) => `${siteUrl}${basePath}/${routes[kind] ?? kind}/${slug}`,
|
|
65
|
+
fileExists,
|
|
66
|
+
// /images/{type}/{name}.webp → /images/og/{type}/{name}.jpg, falling back to the slug's jpg (SLA-2817);
|
|
67
|
+
// omitted rather than pointing at a file that is not there.
|
|
68
|
+
resolveImageUrl(imagePath, slug) {
|
|
69
|
+
if (!imagePath)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (imagePath.startsWith('http'))
|
|
72
|
+
return imagePath;
|
|
73
|
+
const ogPath = imagePath.includes(marker)
|
|
74
|
+
? imagePath
|
|
75
|
+
: imagePath.replace(/^\/images\//, '/images/og/').replace(/\.(webp|png|jpeg)$/, '.jpg');
|
|
76
|
+
const slugOgPath = slug ? ogPath.replace(/[^/]+$/, `${slug}.jpg`) : undefined;
|
|
77
|
+
const resolved = [ogPath, slugOgPath].find((p) => p && fileExists(p));
|
|
78
|
+
return resolved ? `${siteUrl}${basePath}${resolved}` : undefined;
|
|
79
|
+
},
|
|
80
|
+
resolveAuthor(authorName) {
|
|
81
|
+
if (!authorName)
|
|
82
|
+
return null;
|
|
83
|
+
const profile = input.findAuthor?.(authorName);
|
|
84
|
+
const url = `${siteUrl}${basePath}/author/${profile?.slug || slugify(authorName)}`;
|
|
85
|
+
const image = profile?.photo
|
|
86
|
+
? (profile.photo.startsWith('http') ? profile.photo : `${siteUrl}${basePath}${profile.photo}`)
|
|
87
|
+
: undefined;
|
|
88
|
+
return { id: `${url}#person`, name: authorName, url, image, sameAs: profile?.socialLinks };
|
|
89
|
+
},
|
|
90
|
+
formatDate: (dateStr, defaultToNow = false) => formatSchemaDate(dateStr, defaultToNow, now),
|
|
91
|
+
plainText: input.plainText ?? ((markdown) => markdown),
|
|
92
|
+
publisherFor: (authorName) => (input.publisher ? input.publisher(authorName, ctx) : { '@id': ctx.orgId }),
|
|
93
|
+
};
|
|
94
|
+
return ctx;
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type AddressPolicy } from './values.ts';
|
|
2
|
+
import type { JsonLdNode, SchemaBuilder } from './types.ts';
|
|
3
|
+
/** Where events take place and who runs them; every value is the site's. */
|
|
4
|
+
export interface EventPolicy {
|
|
5
|
+
address: AddressPolicy;
|
|
6
|
+
/** Place name when the item has no `location`. Absent: such an item gets no `location`. */
|
|
7
|
+
defaultLocationName?: string;
|
|
8
|
+
/** Add `geo` from `coordinates` to the Place. */
|
|
9
|
+
geo?: boolean;
|
|
10
|
+
organizer(data: Record<string, any>): JsonLdNode;
|
|
11
|
+
performer(data: Record<string, any>): JsonLdNode | undefined;
|
|
12
|
+
currency?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface EventOptions extends EventPolicy {
|
|
15
|
+
/** Map `data.offers[]` to Offers when present (else one Offer from `price`). */
|
|
16
|
+
offersArray?: boolean;
|
|
17
|
+
/** Emit the Event node; when false only the companion Article is emitted. Default: always. */
|
|
18
|
+
emitEvent?: (data: Record<string, any>) => boolean;
|
|
19
|
+
articleSection?: string;
|
|
20
|
+
inLanguage?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Event at route `event`, its author's Person first, and a companion Article (SLA-357). */
|
|
23
|
+
export declare function eventBuilder(options: EventOptions): SchemaBuilder;
|
|
24
|
+
export interface EventItemListOptions extends EventPolicy {
|
|
25
|
+
/** Organizer of a listed item (may differ from the event page's). */
|
|
26
|
+
itemOrganizer(item: Record<string, any>): JsonLdNode;
|
|
27
|
+
/** A listed item is an Event only when this holds (else a WebPage). Default: it has a `startDate`. */
|
|
28
|
+
isEvent?: (item: Record<string, any>) => boolean;
|
|
29
|
+
}
|
|
30
|
+
/** ItemList of events, each linked at its `url`; an item that cannot be a valid Event becomes a WebPage. */
|
|
31
|
+
export declare function eventItemListBuilder(options: EventItemListOptions): SchemaBuilder;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createPersonSchema } from "./context.js";
|
|
2
|
+
import { geoCoordinates, postalAddress } from "./values.js";
|
|
3
|
+
function eventPrice(data) {
|
|
4
|
+
const isFree = data.tags?.includes('free') || data.price?.toLowerCase().includes('free');
|
|
5
|
+
const priceMatch = data.price?.match(/(\d+)/);
|
|
6
|
+
return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
|
|
7
|
+
}
|
|
8
|
+
// Offers open three months before the start date.
|
|
9
|
+
function validFrom(startDate) {
|
|
10
|
+
if (!startDate)
|
|
11
|
+
return null;
|
|
12
|
+
const from = new Date(new Date(startDate));
|
|
13
|
+
from.setMonth(from.getMonth() - 3);
|
|
14
|
+
return from.toISOString();
|
|
15
|
+
}
|
|
16
|
+
function eventLocation(policy, data) {
|
|
17
|
+
const name = data.location || policy.defaultLocationName;
|
|
18
|
+
if (!name)
|
|
19
|
+
return undefined;
|
|
20
|
+
const geo = policy.geo ? geoCoordinates(data.coordinates) : undefined;
|
|
21
|
+
return {
|
|
22
|
+
'@type': 'Place',
|
|
23
|
+
name,
|
|
24
|
+
address: postalAddress(policy.address, data),
|
|
25
|
+
...(geo && { geo }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const EVENT_STATUS = (rescheduled) => (rescheduled ? 'https://schema.org/EventRescheduled' : 'https://schema.org/EventScheduled');
|
|
29
|
+
const OFFLINE = 'https://schema.org/OfflineEventAttendanceMode';
|
|
30
|
+
/** Event at route `event`, its author's Person first, and a companion Article (SLA-357). */
|
|
31
|
+
export function eventBuilder(options) {
|
|
32
|
+
const { offersArray = false, emitEvent = () => true, articleSection = 'Events', inLanguage = 'en', currency = 'EUR' } = options;
|
|
33
|
+
return (data, ctx) => {
|
|
34
|
+
const { isFree, price } = eventPrice(data);
|
|
35
|
+
const from = validFrom(data.startDate);
|
|
36
|
+
const pageRoute = ctx.route('event', data.slug);
|
|
37
|
+
const singleOffer = price !== null ? {
|
|
38
|
+
'@type': 'Offer',
|
|
39
|
+
price,
|
|
40
|
+
priceCurrency: currency,
|
|
41
|
+
availability: 'https://schema.org/InStock',
|
|
42
|
+
url: data.officialWebsite || pageRoute,
|
|
43
|
+
validFrom: from,
|
|
44
|
+
} : undefined;
|
|
45
|
+
const event = {
|
|
46
|
+
'@type': 'Event',
|
|
47
|
+
name: data.name,
|
|
48
|
+
description: data.description,
|
|
49
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
50
|
+
startDate: data.startDate || undefined,
|
|
51
|
+
endDate: data.endDate || undefined,
|
|
52
|
+
previousStartDate: data.previousStartDate || undefined,
|
|
53
|
+
eventStatus: EVENT_STATUS(data.previousStartDate),
|
|
54
|
+
eventAttendanceMode: OFFLINE,
|
|
55
|
+
isAccessibleForFree: isFree,
|
|
56
|
+
location: eventLocation(options, data),
|
|
57
|
+
organizer: options.organizer(data),
|
|
58
|
+
performer: options.performer(data),
|
|
59
|
+
offers: offersArray && (data.offers?.length ?? 0) > 0
|
|
60
|
+
? data.offers.map((o) => ({
|
|
61
|
+
'@type': 'Offer',
|
|
62
|
+
price: o.price,
|
|
63
|
+
priceCurrency: o.priceCurrency || currency,
|
|
64
|
+
availability: `https://schema.org/${o.availability || 'InStock'}`,
|
|
65
|
+
url: o.url || data.officialWebsite || pageRoute,
|
|
66
|
+
validFrom: from,
|
|
67
|
+
}))
|
|
68
|
+
: singleOffer,
|
|
69
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
70
|
+
url: pageRoute,
|
|
71
|
+
};
|
|
72
|
+
const author = ctx.resolveAuthor(data.author);
|
|
73
|
+
const article = {
|
|
74
|
+
'@type': 'Article',
|
|
75
|
+
'@id': `${pageRoute}#article`,
|
|
76
|
+
headline: data.name,
|
|
77
|
+
description: data.description,
|
|
78
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
79
|
+
datePublished: ctx.formatDate(data.datePublished || data.dateModified),
|
|
80
|
+
dateModified: ctx.formatDate(data.dateModified),
|
|
81
|
+
author: author ? { '@id': author.id } : undefined,
|
|
82
|
+
publisher: ctx.publisherFor(data.author),
|
|
83
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
84
|
+
articleSection,
|
|
85
|
+
inLanguage,
|
|
86
|
+
};
|
|
87
|
+
const nodes = emitEvent(data) ? [event, article] : [article];
|
|
88
|
+
return author ? [createPersonSchema(author), ...nodes] : nodes;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** ItemList of events, each linked at its `url`; an item that cannot be a valid Event becomes a WebPage. */
|
|
92
|
+
export function eventItemListBuilder(options) {
|
|
93
|
+
const { isEvent = (item) => Boolean(item.startDate), currency = 'EUR' } = options;
|
|
94
|
+
return (data, ctx) => ({
|
|
95
|
+
'@type': 'ItemList',
|
|
96
|
+
name: data.name,
|
|
97
|
+
numberOfItems: data.items?.length || 0,
|
|
98
|
+
itemListElement: data.items?.map((item) => {
|
|
99
|
+
const { isFree, price } = eventPrice(item);
|
|
100
|
+
const from = validFrom(item.startDate);
|
|
101
|
+
const performer = options.performer(item);
|
|
102
|
+
if (!isEvent(item)) {
|
|
103
|
+
return {
|
|
104
|
+
'@type': 'ListItem',
|
|
105
|
+
position: item.position,
|
|
106
|
+
item: { '@type': 'WebPage', name: item.name, description: item.description, url: ctx.abs(item.url) },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
'@type': 'ListItem',
|
|
111
|
+
position: item.position,
|
|
112
|
+
item: {
|
|
113
|
+
'@type': 'Event',
|
|
114
|
+
name: item.name,
|
|
115
|
+
description: item.description,
|
|
116
|
+
image: ctx.resolveImageUrl(item.image, item.slug),
|
|
117
|
+
url: ctx.abs(item.url),
|
|
118
|
+
startDate: item.startDate,
|
|
119
|
+
endDate: item.endDate,
|
|
120
|
+
previousStartDate: item.previousStartDate || undefined,
|
|
121
|
+
eventStatus: EVENT_STATUS(item.previousStartDate),
|
|
122
|
+
eventAttendanceMode: OFFLINE,
|
|
123
|
+
isAccessibleForFree: isFree,
|
|
124
|
+
location: eventLocation(options, item),
|
|
125
|
+
organizer: options.itemOrganizer(item),
|
|
126
|
+
performer,
|
|
127
|
+
offers: price !== null ? {
|
|
128
|
+
'@type': 'Offer',
|
|
129
|
+
price,
|
|
130
|
+
priceCurrency: currency,
|
|
131
|
+
availability: 'https://schema.org/InStock',
|
|
132
|
+
url: item.officialWebsite || ctx.route('event', item.slug),
|
|
133
|
+
validFrom: from,
|
|
134
|
+
} : undefined,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JsonLdNode, SchemaBuilders, SchemaContext, SchemaEntry } from './types.ts';
|
|
2
|
+
export interface BreadcrumbInput {
|
|
3
|
+
name: string;
|
|
4
|
+
url: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildBreadcrumbList(ctx: SchemaContext, breadcrumbs: BreadcrumbInput[]): JsonLdNode;
|
|
7
|
+
export interface PageInput {
|
|
8
|
+
entries: SchemaEntry[];
|
|
9
|
+
breadcrumbs?: BreadcrumbInput[];
|
|
10
|
+
pageTitle?: string;
|
|
11
|
+
pageDescription?: string;
|
|
12
|
+
datePublished?: string;
|
|
13
|
+
dateModified?: string;
|
|
14
|
+
}
|
|
15
|
+
/** The page's WebPage node: name/description/dates fall back to the first entries that carry them. */
|
|
16
|
+
export declare function buildWebPageNode(ctx: SchemaContext, page: PageInput, inLanguage?: string): JsonLdNode;
|
|
17
|
+
export interface AssembleOptions extends PageInput {
|
|
18
|
+
/** The site's Organization node (`@id` = `ctx.orgId`). */
|
|
19
|
+
organization: JsonLdNode;
|
|
20
|
+
/** The site's WebSite node (`@id` = `ctx.websiteId`). */
|
|
21
|
+
website: JsonLdNode;
|
|
22
|
+
builders: SchemaBuilders;
|
|
23
|
+
inLanguage?: string;
|
|
24
|
+
/** Runs after the base nodes and breadcrumb, before any entry. */
|
|
25
|
+
beforeEntries?: (graph: JsonLdNode[]) => void;
|
|
26
|
+
/** Runs once every entry is in the graph. */
|
|
27
|
+
afterEntries?: (graph: JsonLdNode[]) => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The page graph in emission order: Organization, WebSite, WebPage, BreadcrumbList (when breadcrumbs are given),
|
|
31
|
+
* then each entry's node(s) in call order. A type with no builder, or a builder returning null, emits nothing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assembleGraph(ctx: SchemaContext, options: AssembleOptions): JsonLdNode[];
|
|
34
|
+
/** The serialisable JSON-LD document: `@context` plus the graph with every URL normalised. */
|
|
35
|
+
export declare function finalizeGraph(ctx: SchemaContext, graph: JsonLdNode[]): JsonLdNode;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { normalizeSiteUrl } from "../url/index.js";
|
|
2
|
+
import { normalizeHomepageUrl, normalizeStructuredDataUrls } from "./context.js";
|
|
3
|
+
export function buildBreadcrumbList(ctx, breadcrumbs) {
|
|
4
|
+
return {
|
|
5
|
+
'@type': 'BreadcrumbList',
|
|
6
|
+
'@id': `${ctx.pageUrl}#breadcrumb`,
|
|
7
|
+
itemListElement: breadcrumbs.map((item, index) => ({
|
|
8
|
+
'@type': 'ListItem',
|
|
9
|
+
position: index + 1,
|
|
10
|
+
name: item.name,
|
|
11
|
+
item: normalizeHomepageUrl(normalizeSiteUrl(item.url, ctx.siteUrl, ctx.basePath), ctx.siteUrl, ctx.siteBaseUrl),
|
|
12
|
+
})),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The page's WebPage node: name/description/dates fall back to the first entries that carry them. */
|
|
16
|
+
export function buildWebPageNode(ctx, page, inLanguage = 'en') {
|
|
17
|
+
const { entries } = page;
|
|
18
|
+
return {
|
|
19
|
+
'@type': 'WebPage',
|
|
20
|
+
'@id': ctx.pageUrl,
|
|
21
|
+
url: ctx.pageUrl,
|
|
22
|
+
name: page.pageTitle || entries[0]?.data.name || entries[0]?.data.headline,
|
|
23
|
+
description: page.pageDescription || entries[0]?.data.description,
|
|
24
|
+
inLanguage,
|
|
25
|
+
datePublished: ctx.formatDate(page.datePublished
|
|
26
|
+
|| entries.find((s) => s.data.datePublished)?.data.datePublished
|
|
27
|
+
|| entries.find((s) => s.data.dateModified)?.data.dateModified),
|
|
28
|
+
dateModified: ctx.formatDate(page.dateModified || (() => {
|
|
29
|
+
const s = entries.find((e) => e.data.dateModified || e.data.datePublished);
|
|
30
|
+
return s?.data.dateModified || s?.data.datePublished;
|
|
31
|
+
})()),
|
|
32
|
+
isPartOf: { '@id': ctx.websiteId },
|
|
33
|
+
...(page.breadcrumbs ? { breadcrumb: { '@id': `${ctx.pageUrl}#breadcrumb` } } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The page graph in emission order: Organization, WebSite, WebPage, BreadcrumbList (when breadcrumbs are given),
|
|
38
|
+
* then each entry's node(s) in call order. A type with no builder, or a builder returning null, emits nothing.
|
|
39
|
+
*/
|
|
40
|
+
export function assembleGraph(ctx, options) {
|
|
41
|
+
const graph = [options.organization, options.website, buildWebPageNode(ctx, options, options.inLanguage)];
|
|
42
|
+
if (options.breadcrumbs)
|
|
43
|
+
graph.push(buildBreadcrumbList(ctx, options.breadcrumbs));
|
|
44
|
+
options.beforeEntries?.(graph);
|
|
45
|
+
for (const { type, data } of options.entries) {
|
|
46
|
+
const builder = options.builders[type];
|
|
47
|
+
const result = builder ? builder(data, ctx) : null;
|
|
48
|
+
if (result === null)
|
|
49
|
+
continue;
|
|
50
|
+
if (Array.isArray(result))
|
|
51
|
+
graph.push(...result);
|
|
52
|
+
else
|
|
53
|
+
graph.push(result);
|
|
54
|
+
}
|
|
55
|
+
options.afterEntries?.(graph);
|
|
56
|
+
return graph;
|
|
57
|
+
}
|
|
58
|
+
/** The serialisable JSON-LD document: `@context` plus the graph with every URL normalised. */
|
|
59
|
+
export function finalizeGraph(ctx, graph) {
|
|
60
|
+
return {
|
|
61
|
+
'@context': 'https://schema.org',
|
|
62
|
+
'@graph': normalizeStructuredDataUrls(graph, ctx.siteUrl, ctx.basePath),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { OpeningHoursSpecification } from './types.ts';
|
|
2
|
+
export declare const DAY_KEYS: readonly ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
|
3
|
+
export type DayKey = (typeof DAY_KEYS)[number];
|
|
4
|
+
/** The value an `openingHours` bucket uses for a day open around the clock. */
|
|
5
|
+
export declare const HOURS_ALWAYS_OPEN = "24h";
|
|
6
|
+
export interface OpeningHoursInput {
|
|
7
|
+
weekdays?: unknown;
|
|
8
|
+
weekends?: unknown;
|
|
9
|
+
alwaysOpen?: unknown;
|
|
10
|
+
closed?: unknown;
|
|
11
|
+
[day: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
/** One resolved bucket: the hours value and the days it applies to after `closed` and per-day overrides. */
|
|
14
|
+
export interface OpeningHoursBucket {
|
|
15
|
+
kind: 'grouped' | 'day' | 'alwaysOpen';
|
|
16
|
+
value: unknown;
|
|
17
|
+
days: DayKey[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Splits a (possibly split-shift) bucket value such as `"13:00-16:00, 20:00-23:30"` into one pair per shift.
|
|
21
|
+
* A shift whose halves are not both clock times is dropped (SLA-2303); `24h` spans the whole day.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseHoursShifts(value: string): Array<{
|
|
24
|
+
opens: string;
|
|
25
|
+
closes: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Days a free-text `closed` field names ("Tuesdays", "Monday and Sunday"), in Monday-first order. */
|
|
28
|
+
export declare function closedFieldDays(closed: unknown): Set<DayKey>;
|
|
29
|
+
/**
|
|
30
|
+
* The day resolution behind `generateOpeningHours`, in emission order: weekdays, weekends, each per-day key,
|
|
31
|
+
* then `alwaysOpen`. A per-day key wins over a grouped bucket and every `closed` day is subtracted.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveOpeningHours(openingHours: OpeningHoursInput): OpeningHoursBucket[];
|
|
34
|
+
/**
|
|
35
|
+
* Days an `openingHours` object declares open, whether or not each bucket's value parses. The opening-hours gate
|
|
36
|
+
* asks this, so a malformed bucket still counts as an open claim; the gate reports the malformed value separately.
|
|
37
|
+
*/
|
|
38
|
+
export declare function assertedOpenDays(openingHours: OpeningHoursInput): Set<DayKey>;
|
|
39
|
+
/** `openingHours` object → schema.org OpeningHoursSpecification list, or undefined when nothing is emitted. */
|
|
40
|
+
export declare function generateOpeningHours(openingHours: OpeningHoursInput | null | undefined): OpeningHoursSpecification[] | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Free-text hours line ("Tue–Sat: 9am–3pm, Sun: 9am–3pm") → OpeningHoursSpecification list. Any time range it
|
|
43
|
+
* cannot fully represent drops the whole string: partial hours read as closed to Google (SLA-2829).
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseHoursString(hours: string | null | undefined): OpeningHoursSpecification[] | undefined;
|
|
46
|
+
/** Days the JSON-LD built from a free-text hours line asserts open (the union of its specs' `dayOfWeek`). */
|
|
47
|
+
export declare function hoursStringOpenDays(hours: unknown): Set<DayKey>;
|