@sarimarcus/content-sites-core 0.13.0 → 0.15.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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export const DAY_KEYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
2
|
+
const WEEKDAY_KEYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
|
3
|
+
const WEEKEND_KEYS = ['saturday', 'sunday'];
|
|
4
|
+
const DAY_LABEL = {
|
|
5
|
+
monday: 'Monday', tuesday: 'Tuesday', wednesday: 'Wednesday', thursday: 'Thursday',
|
|
6
|
+
friday: 'Friday', saturday: 'Saturday', sunday: 'Sunday',
|
|
7
|
+
};
|
|
8
|
+
const SHORT_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
9
|
+
const SHORT_TO_KEY = {
|
|
10
|
+
mon: 'monday', tue: 'tuesday', wed: 'wednesday', thu: 'thursday', fri: 'friday', sat: 'saturday', sun: 'sunday',
|
|
11
|
+
};
|
|
12
|
+
/** The value an `openingHours` bucket uses for a day open around the clock. */
|
|
13
|
+
export const HOURS_ALWAYS_OPEN = '24h';
|
|
14
|
+
/**
|
|
15
|
+
* Splits a (possibly split-shift) bucket value such as `"13:00-16:00, 20:00-23:30"` into one pair per shift.
|
|
16
|
+
* A shift whose halves are not both clock times is dropped (SLA-2303); `24h` spans the whole day.
|
|
17
|
+
*/
|
|
18
|
+
export function parseHoursShifts(value) {
|
|
19
|
+
if (value.trim() === HOURS_ALWAYS_OPEN)
|
|
20
|
+
return [{ opens: '00:00', closes: '23:59' }];
|
|
21
|
+
return value
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((shift) => shift.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((shift) => {
|
|
26
|
+
const [opens, closes] = shift.split('-');
|
|
27
|
+
return { opens: opens?.trim(), closes: closes?.trim() };
|
|
28
|
+
})
|
|
29
|
+
.filter((shift) => Boolean(shift.opens && shift.closes)
|
|
30
|
+
&& /^\d{1,2}:\d{2}$/.test(shift.opens)
|
|
31
|
+
&& /^\d{1,2}:\d{2}$/.test(shift.closes));
|
|
32
|
+
}
|
|
33
|
+
/** Days a free-text `closed` field names ("Tuesdays", "Monday and Sunday"), in Monday-first order. */
|
|
34
|
+
export function closedFieldDays(closed) {
|
|
35
|
+
return new Set(typeof closed === 'string'
|
|
36
|
+
? DAY_KEYS.filter((d) => new RegExp(`\\b${d}s?\\b`, 'i').test(closed))
|
|
37
|
+
: []);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The day resolution behind `generateOpeningHours`, in emission order: weekdays, weekends, each per-day key,
|
|
41
|
+
* then `alwaysOpen`. A per-day key wins over a grouped bucket and every `closed` day is subtracted.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveOpeningHours(openingHours) {
|
|
44
|
+
const closed = closedFieldDays(openingHours.closed);
|
|
45
|
+
const perDayKeys = DAY_KEYS.filter((d) => openingHours[d]);
|
|
46
|
+
const overridden = new Set(perDayKeys);
|
|
47
|
+
const groupDays = (keys) => keys.filter((d) => !closed.has(d) && !overridden.has(d));
|
|
48
|
+
const buckets = [];
|
|
49
|
+
if (openingHours.weekdays)
|
|
50
|
+
buckets.push({ kind: 'grouped', value: openingHours.weekdays, days: groupDays(WEEKDAY_KEYS) });
|
|
51
|
+
if (openingHours.weekends)
|
|
52
|
+
buckets.push({ kind: 'grouped', value: openingHours.weekends, days: groupDays(WEEKEND_KEYS) });
|
|
53
|
+
for (const day of perDayKeys) {
|
|
54
|
+
if (!closed.has(day))
|
|
55
|
+
buckets.push({ kind: 'day', value: openingHours[day], days: [day] });
|
|
56
|
+
}
|
|
57
|
+
if (openingHours.alwaysOpen)
|
|
58
|
+
buckets.push({ kind: 'alwaysOpen', value: openingHours.alwaysOpen, days: groupDays(DAY_KEYS) });
|
|
59
|
+
return buckets;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Days an `openingHours` object declares open, whether or not each bucket's value parses. The opening-hours gate
|
|
63
|
+
* asks this, so a malformed bucket still counts as an open claim; the gate reports the malformed value separately.
|
|
64
|
+
*/
|
|
65
|
+
export function assertedOpenDays(openingHours) {
|
|
66
|
+
const open = new Set();
|
|
67
|
+
for (const bucket of resolveOpeningHours(openingHours))
|
|
68
|
+
bucket.days.forEach((d) => open.add(d));
|
|
69
|
+
return open;
|
|
70
|
+
}
|
|
71
|
+
/** `openingHours` object → schema.org OpeningHoursSpecification list, or undefined when nothing is emitted. */
|
|
72
|
+
export function generateOpeningHours(openingHours) {
|
|
73
|
+
if (!openingHours)
|
|
74
|
+
return undefined;
|
|
75
|
+
const specs = [];
|
|
76
|
+
for (const bucket of resolveOpeningHours(openingHours)) {
|
|
77
|
+
const labels = bucket.days.map((d) => DAY_LABEL[d]);
|
|
78
|
+
if (bucket.kind === 'day') {
|
|
79
|
+
for (const { opens, closes } of parseHoursShifts(bucket.value)) {
|
|
80
|
+
specs.push({ '@type': 'OpeningHoursSpecification', dayOfWeek: labels[0], opens, closes });
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (labels.length === 0)
|
|
85
|
+
continue;
|
|
86
|
+
const dayOfWeek = labels.length === 1 ? labels[0] : labels;
|
|
87
|
+
if (bucket.kind === 'alwaysOpen') {
|
|
88
|
+
specs.push({ '@type': 'OpeningHoursSpecification', dayOfWeek, opens: '00:00', closes: '23:59' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const { opens, closes } of parseHoursShifts(bucket.value)) {
|
|
92
|
+
specs.push({ '@type': 'OpeningHoursSpecification', dayOfWeek, opens, closes });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return specs.length > 0 ? specs : undefined;
|
|
96
|
+
}
|
|
97
|
+
function convertTime(t) {
|
|
98
|
+
const clean = t.trim();
|
|
99
|
+
const m12 = clean.match(/^(\d{1,2})(?::(\d{2}))?(am|pm)$/i);
|
|
100
|
+
if (m12) {
|
|
101
|
+
let h = parseInt(m12[1]);
|
|
102
|
+
const min = m12[2] ? parseInt(m12[2]) : 0;
|
|
103
|
+
const period = m12[3].toLowerCase();
|
|
104
|
+
if (h < 1 || h > 12)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (period === 'pm' && h !== 12)
|
|
107
|
+
h += 12;
|
|
108
|
+
if (period === 'am' && h === 12)
|
|
109
|
+
h = 0;
|
|
110
|
+
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
|
111
|
+
}
|
|
112
|
+
const m24 = clean.match(/^(\d{1,2}):(\d{2})$/);
|
|
113
|
+
if (m24)
|
|
114
|
+
return `${String(parseInt(m24[1])).padStart(2, '0')}:${m24[2]}`;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
// A side missing am/pm inherits the other side's period, flipped if that inverts the range (SLA-2829).
|
|
118
|
+
function convertTimePair(openRaw, closeRaw) {
|
|
119
|
+
const suffixOf = (t) => t.trim().match(/(am|pm)$/i)?.[1]?.toLowerCase();
|
|
120
|
+
const toMinutes = (hhmm) => {
|
|
121
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
122
|
+
return h * 60 + m;
|
|
123
|
+
};
|
|
124
|
+
const openSuffix = suffixOf(openRaw);
|
|
125
|
+
const closeSuffix = suffixOf(closeRaw);
|
|
126
|
+
if ((openSuffix && closeSuffix) || (!openSuffix && !closeSuffix)) {
|
|
127
|
+
return { opens: convertTime(openRaw), closes: convertTime(closeRaw) };
|
|
128
|
+
}
|
|
129
|
+
const missingIsOpen = !openSuffix;
|
|
130
|
+
const missingRaw = missingIsOpen ? openRaw : closeRaw;
|
|
131
|
+
if (parseInt(missingRaw.trim(), 10) > 12) {
|
|
132
|
+
const opens = convertTime(openRaw);
|
|
133
|
+
const closes = convertTime(closeRaw);
|
|
134
|
+
if (opens !== undefined && closes !== undefined && toMinutes(opens) < toMinutes(closes)) {
|
|
135
|
+
return { opens, closes };
|
|
136
|
+
}
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
const known = (missingIsOpen ? closeSuffix : openSuffix);
|
|
140
|
+
for (const guess of [known, known === 'am' ? 'pm' : 'am']) {
|
|
141
|
+
const openStr = missingIsOpen ? `${openRaw.trim()}${guess}` : openRaw;
|
|
142
|
+
const closeStr = missingIsOpen ? closeRaw : `${closeRaw.trim()}${guess}`;
|
|
143
|
+
const opens = convertTime(openStr);
|
|
144
|
+
const closes = convertTime(closeStr);
|
|
145
|
+
if (opens !== undefined && closes !== undefined && toMinutes(opens) < toMinutes(closes)) {
|
|
146
|
+
return { opens, closes };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
// A colon or an am/pm suffix on at least one side, so a date or age range ("Dec 24-26") does not trigger.
|
|
152
|
+
const LOOKS_LIKE_TIME_RANGE = /(?:\d{1,2}:\d{2}|\d{1,2}\s*(?:am|pm))\s*-\s*\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s*-\s*(?:\d{1,2}:\d{2}|\d{1,2}\s*(?:am|pm))/i;
|
|
153
|
+
/**
|
|
154
|
+
* Free-text hours line ("Tue–Sat: 9am–3pm, Sun: 9am–3pm") → OpeningHoursSpecification list. Any time range it
|
|
155
|
+
* cannot fully represent drops the whole string: partial hours read as closed to Google (SLA-2829).
|
|
156
|
+
*/
|
|
157
|
+
export function parseHoursString(hours) {
|
|
158
|
+
if (!hours)
|
|
159
|
+
return undefined;
|
|
160
|
+
const specs = [];
|
|
161
|
+
const parts = hours.replace(/–/g, '-').split(/[,.;]/).map((p) => p.trim()).filter(Boolean);
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
const match = part.match(/^([A-Za-z]+-?[A-Za-z]*)[\s:]+(\d[\d:]*(?:am|pm)?)\s*-\s*(\d[\d:]*(?:am|pm)?)/i);
|
|
164
|
+
if (!match) {
|
|
165
|
+
if (LOOKS_LIKE_TIME_RANGE.test(part))
|
|
166
|
+
return undefined;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (LOOKS_LIKE_TIME_RANGE.test(part.slice(match[0].length)))
|
|
170
|
+
return undefined;
|
|
171
|
+
const { opens, closes } = convertTimePair(match[2], match[3]);
|
|
172
|
+
if (!opens || !closes)
|
|
173
|
+
return undefined;
|
|
174
|
+
const dayRangeMatch = match[1].match(/^([A-Za-z]+)-([A-Za-z]+)$/);
|
|
175
|
+
let dayNames;
|
|
176
|
+
if (dayRangeMatch) {
|
|
177
|
+
const startIdx = SHORT_DAYS.indexOf(dayRangeMatch[1].toLowerCase());
|
|
178
|
+
const endIdx = SHORT_DAYS.indexOf(dayRangeMatch[2].toLowerCase());
|
|
179
|
+
if (startIdx < 0 || endIdx < 0)
|
|
180
|
+
return undefined;
|
|
181
|
+
dayNames = (startIdx <= endIdx
|
|
182
|
+
? SHORT_DAYS.slice(startIdx, endIdx + 1)
|
|
183
|
+
: [...SHORT_DAYS.slice(startIdx), ...SHORT_DAYS.slice(0, endIdx + 1)]).map((d) => DAY_LABEL[SHORT_TO_KEY[d]]);
|
|
184
|
+
if (dayNames.length === 0)
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const key = SHORT_TO_KEY[match[1].toLowerCase()];
|
|
189
|
+
if (!key)
|
|
190
|
+
return undefined;
|
|
191
|
+
dayNames = [DAY_LABEL[key]];
|
|
192
|
+
}
|
|
193
|
+
specs.push({
|
|
194
|
+
'@type': 'OpeningHoursSpecification',
|
|
195
|
+
dayOfWeek: dayNames.length === 1 ? dayNames[0] : dayNames,
|
|
196
|
+
opens,
|
|
197
|
+
closes,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return specs.length > 0 ? specs : undefined;
|
|
201
|
+
}
|
|
202
|
+
/** Days the JSON-LD built from a free-text hours line asserts open (the union of its specs' `dayOfWeek`). */
|
|
203
|
+
export function hoursStringOpenDays(hours) {
|
|
204
|
+
const open = new Set();
|
|
205
|
+
if (typeof hours !== 'string')
|
|
206
|
+
return open;
|
|
207
|
+
for (const spec of parseHoursString(hours) ?? []) {
|
|
208
|
+
for (const label of [].concat(spec.dayOfWeek))
|
|
209
|
+
open.add(label.toLowerCase());
|
|
210
|
+
}
|
|
211
|
+
return open;
|
|
212
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { JsonLdNode, SchemaEntry, OpeningHoursSpecification, IdRef, SchemaAuthorProfile, ResolvedAuthor, RouteMap, SchemaContextInput, SchemaContext, SchemaBuilder, SchemaBuilders, } from './types.ts';
|
|
2
|
+
export { createSchemaContext, normalizeHomepageUrl, normalizeStructuredDataUrls, formatSchemaDate, createPersonSchema, withAuthorPersonSchema, } from './context.ts';
|
|
3
|
+
export { parsePriceFromString, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf, dataAddressPolicy, postalAddress, geoCoordinates, aggregateRating, } from './values.ts';
|
|
4
|
+
export type { AddressPolicy, PostalAddressParts } from './values.ts';
|
|
5
|
+
export { DAY_KEYS, HOURS_ALWAYS_OPEN, parseHoursShifts, closedFieldDays, resolveOpeningHours, assertedOpenDays, generateOpeningHours, parseHoursString, hoursStringOpenDays, } from './hours.ts';
|
|
6
|
+
export type { DayKey, OpeningHoursInput, OpeningHoursBucket } from './hours.ts';
|
|
7
|
+
export { buildBreadcrumbList, buildWebPageNode, assembleGraph, finalizeGraph } from './graph.ts';
|
|
8
|
+
export type { BreadcrumbInput, PageInput, AssembleOptions } from './graph.ts';
|
|
9
|
+
export { faqPageBuilder, articleBuilder, collectionPageBuilder, itemListBuilder, howToBuilder, aboutPageBuilder, contactPageBuilder, personBuilder, aggregateOfferBuilder, touristDestinationBuilder, organizationBuilder, } from './pages.ts';
|
|
10
|
+
export type { ArticleOptions, CollectionPageOptions, ItemListOptions, AboutPageOptions, PersonOptions, AggregateOfferOptions, TouristDestinationFields, } from './pages.ts';
|
|
11
|
+
export { hotelBuilder, activityBuilder, touristTripBuilder } from './places.ts';
|
|
12
|
+
export type { HotelOptions, ActivityOptions, TouristTripOptions } from './places.ts';
|
|
13
|
+
export { eventBuilder, eventItemListBuilder } from './events.ts';
|
|
14
|
+
export type { EventPolicy, EventOptions, EventItemListOptions } from './events.ts';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createSchemaContext, normalizeHomepageUrl, normalizeStructuredDataUrls, formatSchemaDate, createPersonSchema, withAuthorPersonSchema, } from "./context.js";
|
|
2
|
+
export { parsePriceFromString, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf, dataAddressPolicy, postalAddress, geoCoordinates, aggregateRating, } from "./values.js";
|
|
3
|
+
export { DAY_KEYS, HOURS_ALWAYS_OPEN, parseHoursShifts, closedFieldDays, resolveOpeningHours, assertedOpenDays, generateOpeningHours, parseHoursString, hoursStringOpenDays, } from "./hours.js";
|
|
4
|
+
export { buildBreadcrumbList, buildWebPageNode, assembleGraph, finalizeGraph } from "./graph.js";
|
|
5
|
+
export { faqPageBuilder, articleBuilder, collectionPageBuilder, itemListBuilder, howToBuilder, aboutPageBuilder, contactPageBuilder, personBuilder, aggregateOfferBuilder, touristDestinationBuilder, organizationBuilder, } from "./pages.js";
|
|
6
|
+
export { hotelBuilder, activityBuilder, touristTripBuilder } from "./places.js";
|
|
7
|
+
export { eventBuilder, eventItemListBuilder } from "./events.js";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { JsonLdNode, SchemaBuilder } from './types.ts';
|
|
2
|
+
/** FAQPage from `data.questions` (or `data.faq`), with question and answer through `ctx.plainText`. */
|
|
3
|
+
export declare function faqPageBuilder(): SchemaBuilder;
|
|
4
|
+
export interface ArticleOptions {
|
|
5
|
+
/** Append each existing `data.imageVariants` path to `image`. */
|
|
6
|
+
imageVariants?: boolean;
|
|
7
|
+
/** Image used when the article has none. */
|
|
8
|
+
defaultImage?: string;
|
|
9
|
+
/** `articleSection` when `data.category` is empty. */
|
|
10
|
+
defaultSection?: string;
|
|
11
|
+
inLanguage?: string;
|
|
12
|
+
speakableSelectors?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Article (+ its author's Person first, + a FAQPage from `data.questions`). `citation` comes from `data.sources`.
|
|
16
|
+
* The FAQPage here keeps question and answer text as given, unlike `faqPageBuilder`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function articleBuilder(options?: ArticleOptions): SchemaBuilder;
|
|
19
|
+
export interface CollectionPageOptions {
|
|
20
|
+
/** An item typed `Event` is emitted as `WebPage` (its own page carries the full Event). */
|
|
21
|
+
demoteEvent?: boolean;
|
|
22
|
+
/** Add each item's dates and its author's full Person node. */
|
|
23
|
+
itemMeta?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** CollectionPage whose `mainEntity` lists `data.items`; an item without `url` links to its museum/monument route. */
|
|
26
|
+
export declare function collectionPageBuilder(options?: CollectionPageOptions): SchemaBuilder;
|
|
27
|
+
export interface ItemListOptions {
|
|
28
|
+
/** `data.itemType` → schema type. Given: each item is a typed nested entity; absent: name/url on the ListItem. */
|
|
29
|
+
itemTypes?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
/** ItemList of `data.items` linked at `/${data.itemType}/${slug}`. */
|
|
32
|
+
export declare function itemListBuilder(options?: ItemListOptions): SchemaBuilder;
|
|
33
|
+
export declare function howToBuilder(): SchemaBuilder;
|
|
34
|
+
export interface AboutPageOptions {
|
|
35
|
+
/** When `data.author` resolves, emit their Person first and point `mainEntity` at it. */
|
|
36
|
+
authorAsMainEntity?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function aboutPageBuilder(options?: AboutPageOptions): SchemaBuilder;
|
|
39
|
+
export declare function contactPageBuilder(): SchemaBuilder;
|
|
40
|
+
export interface PersonOptions {
|
|
41
|
+
/** Resolve a root-relative `data.url` to an absolute one, so the `@id` names one entity (SLA-2143). */
|
|
42
|
+
absoluteUrl?: boolean;
|
|
43
|
+
/** Emit `mainEntityOfPage` only on the person's own page (SLA-2143). */
|
|
44
|
+
mainEntityOfPageOnOwnPage?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function personBuilder(options?: PersonOptions): SchemaBuilder;
|
|
47
|
+
export interface AggregateOfferOptions {
|
|
48
|
+
currency?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function aggregateOfferBuilder(options?: AggregateOfferOptions): SchemaBuilder;
|
|
51
|
+
/** The site-level TouristDestination node; every field but `url` comes from the site. */
|
|
52
|
+
export interface TouristDestinationFields {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
address?: JsonLdNode;
|
|
56
|
+
containedInPlace?: JsonLdNode;
|
|
57
|
+
geo?: JsonLdNode;
|
|
58
|
+
touristType?: string | string[];
|
|
59
|
+
}
|
|
60
|
+
export declare function touristDestinationBuilder(fields: TouristDestinationFields): SchemaBuilder;
|
|
61
|
+
/** The Organization lives in the base graph, so an `organization` entry emits nothing. */
|
|
62
|
+
export declare function organizationBuilder(): SchemaBuilder;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { toAbsoluteSiteUrl } from "../url/index.js";
|
|
2
|
+
import { createPersonSchema } from "./context.js";
|
|
3
|
+
/** FAQPage from `data.questions` (or `data.faq`), with question and answer through `ctx.plainText`. */
|
|
4
|
+
export function faqPageBuilder() {
|
|
5
|
+
return (data, ctx) => {
|
|
6
|
+
const questions = data.questions || data.faq;
|
|
7
|
+
if (!questions?.length)
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
'@type': 'FAQPage',
|
|
11
|
+
'@id': `${ctx.pageUrl}#faq`,
|
|
12
|
+
mainEntity: questions.map((q) => ({
|
|
13
|
+
'@type': 'Question',
|
|
14
|
+
name: ctx.plainText(q.question),
|
|
15
|
+
acceptedAnswer: { '@type': 'Answer', text: ctx.plainText(q.answer) },
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Article (+ its author's Person first, + a FAQPage from `data.questions`). `citation` comes from `data.sources`.
|
|
22
|
+
* The FAQPage here keeps question and answer text as given, unlike `faqPageBuilder`.
|
|
23
|
+
*/
|
|
24
|
+
export function articleBuilder(options = {}) {
|
|
25
|
+
const { imageVariants = false, defaultImage = '/og-default.jpg', defaultSection = 'Travel Guide', inLanguage = 'en', speakableSelectors = ['h1', '.article-lede'], } = options;
|
|
26
|
+
return (data, ctx) => {
|
|
27
|
+
const articleImageUrl = data.image ? ctx.resolveImageUrl(data.image, data.slug) : ctx.abs(defaultImage);
|
|
28
|
+
const articleUrl = data.url ? ctx.absOrHttp(data.url) : ctx.pageUrl;
|
|
29
|
+
const author = ctx.resolveAuthor(data.author);
|
|
30
|
+
const variants = imageVariants
|
|
31
|
+
? (data.imageVariants ?? []).filter((p) => ctx.fileExists(p)).map((p) => ctx.abs(p))
|
|
32
|
+
: [];
|
|
33
|
+
const article = {
|
|
34
|
+
'@type': 'Article',
|
|
35
|
+
'@id': `${articleUrl}#article`,
|
|
36
|
+
headline: data.headline || data.title,
|
|
37
|
+
description: data.description,
|
|
38
|
+
image: variants.length ? [articleImageUrl, ...variants] : articleImageUrl,
|
|
39
|
+
datePublished: ctx.formatDate(data.datePublished),
|
|
40
|
+
dateModified: ctx.formatDate(data.dateModified || data.datePublished),
|
|
41
|
+
author: author ? { '@id': author.id } : undefined,
|
|
42
|
+
publisher: ctx.publisherFor(data.author),
|
|
43
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
44
|
+
articleSection: data.category || defaultSection,
|
|
45
|
+
inLanguage,
|
|
46
|
+
citation: Array.isArray(data.sources) && data.sources.length > 0
|
|
47
|
+
? data.sources.map((s) => (typeof s === 'string'
|
|
48
|
+
? { '@type': 'WebPage', url: s }
|
|
49
|
+
: { '@type': 'WebPage', url: s.url, ...(s.name ? { name: s.name } : {}) }))
|
|
50
|
+
: undefined,
|
|
51
|
+
speakable: { '@type': 'SpeakableSpecification', cssSelector: speakableSelectors },
|
|
52
|
+
};
|
|
53
|
+
if ((data.questions?.length ?? 0) > 0) {
|
|
54
|
+
return [
|
|
55
|
+
...(author ? [createPersonSchema(author)] : []),
|
|
56
|
+
article,
|
|
57
|
+
{
|
|
58
|
+
'@type': 'FAQPage',
|
|
59
|
+
'@id': `${articleUrl}#faq`,
|
|
60
|
+
mainEntity: data.questions.map((q) => ({
|
|
61
|
+
'@type': 'Question',
|
|
62
|
+
name: q.question,
|
|
63
|
+
acceptedAnswer: { '@type': 'Answer', text: q.answer },
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
return author ? [createPersonSchema(author), article] : article;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** CollectionPage whose `mainEntity` lists `data.items`; an item without `url` links to its museum/monument route. */
|
|
72
|
+
export function collectionPageBuilder(options = {}) {
|
|
73
|
+
const { demoteEvent = true, itemMeta = false } = options;
|
|
74
|
+
return (data, ctx) => {
|
|
75
|
+
const hasItems = data.items && data.items.length > 0;
|
|
76
|
+
return {
|
|
77
|
+
'@type': 'CollectionPage',
|
|
78
|
+
name: data.name,
|
|
79
|
+
description: data.description,
|
|
80
|
+
url: data.url ? ctx.abs(data.url) : ctx.siteBaseUrl,
|
|
81
|
+
mainEntity: hasItems ? {
|
|
82
|
+
'@type': 'ItemList',
|
|
83
|
+
name: data.name,
|
|
84
|
+
numberOfItems: data.items.length,
|
|
85
|
+
itemListElement: data.items.map((item, index) => {
|
|
86
|
+
const itemUrl = item.url
|
|
87
|
+
? ctx.absOrHttp(item.url)
|
|
88
|
+
: ctx.route(data.itemType === 'museum' ? 'museum' : 'monument', item.slug);
|
|
89
|
+
const rawItemType = item.schemaType || (data.itemType === 'museum' ? 'Museum' : data.itemType === 'page' ? 'WebPage' : 'TouristAttraction');
|
|
90
|
+
const itemType = demoteEvent && rawItemType === 'Event' ? 'WebPage' : rawItemType;
|
|
91
|
+
const itemAuthor = itemMeta ? ctx.resolveAuthor(item.author) : null;
|
|
92
|
+
return {
|
|
93
|
+
'@type': 'ListItem',
|
|
94
|
+
position: index + 1,
|
|
95
|
+
item: {
|
|
96
|
+
'@type': itemType,
|
|
97
|
+
name: item.name,
|
|
98
|
+
url: itemUrl,
|
|
99
|
+
description: item.description,
|
|
100
|
+
...(itemMeta && item.datePublished && { datePublished: ctx.formatDate(item.datePublished) }),
|
|
101
|
+
...(itemMeta && item.dateModified && { dateModified: ctx.formatDate(item.dateModified) }),
|
|
102
|
+
...(itemAuthor && { author: createPersonSchema(itemAuthor) }),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}),
|
|
106
|
+
} : undefined,
|
|
107
|
+
dateModified: data.dateModified,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** ItemList of `data.items` linked at `/${data.itemType}/${slug}`. */
|
|
112
|
+
export function itemListBuilder(options = {}) {
|
|
113
|
+
const { itemTypes } = options;
|
|
114
|
+
return (data, ctx) => ({
|
|
115
|
+
'@type': 'ItemList',
|
|
116
|
+
name: data.name,
|
|
117
|
+
description: data.description,
|
|
118
|
+
numberOfItems: data.items?.length || 0,
|
|
119
|
+
itemListElement: data.items?.map((item, index) => {
|
|
120
|
+
const url = ctx.abs(`/${data.itemType}/${item.slug}`);
|
|
121
|
+
return itemTypes
|
|
122
|
+
? { '@type': 'ListItem', position: index + 1, item: { '@type': itemTypes[data.itemType] ?? 'WebPage', name: item.name, url } }
|
|
123
|
+
: { '@type': 'ListItem', position: index + 1, name: item.name, url };
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export function howToBuilder() {
|
|
128
|
+
return (data) => ({
|
|
129
|
+
'@type': 'HowTo',
|
|
130
|
+
name: data.name,
|
|
131
|
+
description: data.description,
|
|
132
|
+
totalTime: data.totalTime,
|
|
133
|
+
step: data.steps?.map((step, i) => ({
|
|
134
|
+
'@type': 'HowToStep',
|
|
135
|
+
position: i + 1,
|
|
136
|
+
name: step.name,
|
|
137
|
+
text: step.text,
|
|
138
|
+
url: step.url || undefined,
|
|
139
|
+
})),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export function aboutPageBuilder(options = {}) {
|
|
143
|
+
return (data, ctx) => {
|
|
144
|
+
const author = options.authorAsMainEntity ? ctx.resolveAuthor(data.author) : null;
|
|
145
|
+
const node = {
|
|
146
|
+
'@type': 'AboutPage',
|
|
147
|
+
name: data.name,
|
|
148
|
+
description: data.description,
|
|
149
|
+
url: ctx.pageUrl,
|
|
150
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
151
|
+
...(author ? { mainEntity: { '@id': author.id } } : {}),
|
|
152
|
+
};
|
|
153
|
+
return author ? [createPersonSchema(author), node] : node;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function contactPageBuilder() {
|
|
157
|
+
return (data, ctx) => ({
|
|
158
|
+
'@type': 'ContactPage',
|
|
159
|
+
name: data.name,
|
|
160
|
+
description: data.description,
|
|
161
|
+
url: ctx.pageUrl,
|
|
162
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function personBuilder(options = {}) {
|
|
166
|
+
return (data, ctx) => {
|
|
167
|
+
const personUrl = options.absoluteUrl
|
|
168
|
+
? (data.url ? (data.url.startsWith('/') ? toAbsoluteSiteUrl(data.url, ctx.siteUrl, ctx.basePath) : data.url) : ctx.pageUrl)
|
|
169
|
+
: data.url || ctx.pageUrl;
|
|
170
|
+
const idBase = options.absoluteUrl ? personUrl : (data.url ? data.url : ctx.pageUrl);
|
|
171
|
+
const ownPage = !options.mainEntityOfPageOnOwnPage || personUrl === ctx.pageUrl;
|
|
172
|
+
return {
|
|
173
|
+
'@type': 'Person',
|
|
174
|
+
'@id': `${idBase}#person`,
|
|
175
|
+
name: data.name,
|
|
176
|
+
description: data.description,
|
|
177
|
+
url: personUrl,
|
|
178
|
+
image: data.image ? ctx.absOrHttp(data.image) : undefined,
|
|
179
|
+
sameAs: data.sameAs,
|
|
180
|
+
knowsAbout: data.expertise,
|
|
181
|
+
jobTitle: data.jobTitle,
|
|
182
|
+
worksFor: { '@id': ctx.orgId },
|
|
183
|
+
...(ownPage ? { mainEntityOfPage: { '@id': ctx.pageUrl } } : {}),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function aggregateOfferBuilder(options = {}) {
|
|
188
|
+
const { currency = 'EUR' } = options;
|
|
189
|
+
return (data, ctx) => ({
|
|
190
|
+
'@type': 'AggregateOffer',
|
|
191
|
+
priceCurrency: data.priceCurrency || currency,
|
|
192
|
+
lowPrice: data.lowPrice,
|
|
193
|
+
highPrice: data.highPrice,
|
|
194
|
+
offerCount: data.offerCount,
|
|
195
|
+
url: data.url ? ctx.absOrHttp(data.url) : ctx.pageUrl,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export function touristDestinationBuilder(fields) {
|
|
199
|
+
return (_data, ctx) => ({
|
|
200
|
+
'@type': 'TouristDestination',
|
|
201
|
+
name: fields.name,
|
|
202
|
+
description: fields.description,
|
|
203
|
+
url: ctx.siteBaseUrl,
|
|
204
|
+
address: fields.address,
|
|
205
|
+
containedInPlace: fields.containedInPlace,
|
|
206
|
+
geo: fields.geo,
|
|
207
|
+
touristType: fields.touristType,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** The Organization lives in the base graph, so an `organization` entry emits nothing. */
|
|
211
|
+
export function organizationBuilder() {
|
|
212
|
+
return () => null;
|
|
213
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AddressPolicy } from './values.ts';
|
|
2
|
+
import type { SchemaBuilder } from './types.ts';
|
|
3
|
+
export interface HotelOptions {
|
|
4
|
+
address: AddressPolicy;
|
|
5
|
+
defaultCheckinTime?: string;
|
|
6
|
+
defaultCheckoutTime?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Hotel at route `hotel`; no `openingHours` means open around the clock. */
|
|
9
|
+
export declare function hotelBuilder(options: HotelOptions): SchemaBuilder;
|
|
10
|
+
export interface ActivityOptions {
|
|
11
|
+
address: AddressPolicy;
|
|
12
|
+
touristType?: string;
|
|
13
|
+
/** `availableLanguage` when the item lists none; absent: omitted. */
|
|
14
|
+
defaultLanguages?: string[];
|
|
15
|
+
/** Emit an Offer from `data.priceFrom`. */
|
|
16
|
+
offers?: boolean;
|
|
17
|
+
currency?: string;
|
|
18
|
+
}
|
|
19
|
+
/** TouristAttraction at route `activity`; the street falls back to `meetingPoint`. */
|
|
20
|
+
export declare function activityBuilder(options: ActivityOptions): SchemaBuilder;
|
|
21
|
+
export interface TouristTripOptions {
|
|
22
|
+
touristType?: string;
|
|
23
|
+
offerDescription?: string;
|
|
24
|
+
currency?: string;
|
|
25
|
+
}
|
|
26
|
+
/** A free self-guided TouristTrip at route `walk`, its stops as the itinerary. */
|
|
27
|
+
export declare function touristTripBuilder(options?: TouristTripOptions): SchemaBuilder;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { withAuthorPersonSchema } from "./context.js";
|
|
2
|
+
import { generateOpeningHours } from "./hours.js";
|
|
3
|
+
import { aggregateRating, geoCoordinates, normalizeHotelPriceRange, parsePriceFromString, postalAddress, postalCodeOf, } from "./values.js";
|
|
4
|
+
/** Hotel at route `hotel`; no `openingHours` means open around the clock. */
|
|
5
|
+
export function hotelBuilder(options) {
|
|
6
|
+
const { address, defaultCheckinTime = '14:00', defaultCheckoutTime = '12:00' } = options;
|
|
7
|
+
return (data, ctx) => {
|
|
8
|
+
const imageUrl = ctx.resolveImageUrl(data.image, data.slug);
|
|
9
|
+
return withAuthorPersonSchema(ctx, {
|
|
10
|
+
'@type': 'Hotel',
|
|
11
|
+
name: data.name,
|
|
12
|
+
description: data.description,
|
|
13
|
+
image: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : undefined,
|
|
14
|
+
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
15
|
+
geo: geoCoordinates(data.coordinates),
|
|
16
|
+
priceRange: normalizeHotelPriceRange(data.priceRange),
|
|
17
|
+
starRating: data.starRating ? { '@type': 'Rating', ratingValue: data.starRating, bestRating: 5 } : undefined,
|
|
18
|
+
aggregateRating: aggregateRating(data),
|
|
19
|
+
amenityFeature: data.amenities?.map((amenity) => ({
|
|
20
|
+
'@type': 'LocationFeatureSpecification',
|
|
21
|
+
name: amenity,
|
|
22
|
+
value: true,
|
|
23
|
+
})),
|
|
24
|
+
telephone: data.phone,
|
|
25
|
+
checkinTime: data.checkinTime || defaultCheckinTime,
|
|
26
|
+
checkoutTime: data.checkoutTime || defaultCheckoutTime,
|
|
27
|
+
openingHoursSpecification: generateOpeningHours(data.openingHours || { alwaysOpen: true }),
|
|
28
|
+
sameAs: data.website ? [data.website] : undefined,
|
|
29
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
30
|
+
url: ctx.route('hotel', data.slug),
|
|
31
|
+
}, data.author);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** TouristAttraction at route `activity`; the street falls back to `meetingPoint`. */
|
|
35
|
+
export function activityBuilder(options) {
|
|
36
|
+
const { address, touristType = 'Cultural', defaultLanguages, offers = false, currency = 'EUR' } = options;
|
|
37
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
38
|
+
'@type': 'TouristAttraction',
|
|
39
|
+
name: data.name,
|
|
40
|
+
description: data.description,
|
|
41
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
42
|
+
address: postalAddress(address, data, { street: data.address || data.meetingPoint, postalCode: postalCodeOf(data) }),
|
|
43
|
+
geo: geoCoordinates(data.coordinates),
|
|
44
|
+
touristType,
|
|
45
|
+
isAccessibleForFree: parsePriceFromString(data.price) === 0,
|
|
46
|
+
publicAccess: true,
|
|
47
|
+
availableLanguage: data.languages || defaultLanguages,
|
|
48
|
+
telephone: data.phone,
|
|
49
|
+
aggregateRating: aggregateRating(data),
|
|
50
|
+
offers: offers && data.priceFrom !== undefined ? {
|
|
51
|
+
'@type': 'Offer',
|
|
52
|
+
price: data.priceFrom,
|
|
53
|
+
priceCurrency: currency,
|
|
54
|
+
availability: 'https://schema.org/InStock',
|
|
55
|
+
} : undefined,
|
|
56
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
57
|
+
url: ctx.route('activity', data.slug),
|
|
58
|
+
}, data.author);
|
|
59
|
+
}
|
|
60
|
+
/** A free self-guided TouristTrip at route `walk`, its stops as the itinerary. */
|
|
61
|
+
export function touristTripBuilder(options = {}) {
|
|
62
|
+
const { touristType = 'Cultural', offerDescription = 'Self-guided walking tour — free', currency = 'EUR' } = options;
|
|
63
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
64
|
+
'@type': 'TouristTrip',
|
|
65
|
+
name: data.name,
|
|
66
|
+
description: data.description,
|
|
67
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
68
|
+
touristType: data.theme || touristType,
|
|
69
|
+
offers: {
|
|
70
|
+
'@type': 'Offer',
|
|
71
|
+
price: 0,
|
|
72
|
+
priceCurrency: currency,
|
|
73
|
+
availability: 'https://schema.org/InStock',
|
|
74
|
+
description: offerDescription,
|
|
75
|
+
},
|
|
76
|
+
itinerary: {
|
|
77
|
+
'@type': 'ItemList',
|
|
78
|
+
numberOfItems: data.stops?.length || 0,
|
|
79
|
+
itemListElement: (data.stops || []).map((stop, i) => ({
|
|
80
|
+
'@type': 'ListItem',
|
|
81
|
+
position: i + 1,
|
|
82
|
+
item: {
|
|
83
|
+
'@type': 'TouristAttraction',
|
|
84
|
+
name: stop.name,
|
|
85
|
+
description: stop.description,
|
|
86
|
+
geo: geoCoordinates(stop.coordinates),
|
|
87
|
+
...(stop.slug && stop.type !== 'landmark' ? { url: ctx.abs(`/${stop.type}/${stop.slug}`) } : {}),
|
|
88
|
+
},
|
|
89
|
+
})),
|
|
90
|
+
},
|
|
91
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
92
|
+
url: ctx.route('walk', data.slug),
|
|
93
|
+
}, data.author);
|
|
94
|
+
}
|