@sarimarcus/content-sites-core 0.22.1 → 0.23.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 +18 -0
- package/README.md +1 -1
- package/dist/config/outboundLinkRel.js +3 -10
- package/dist/images/index.js +1 -1
- package/dist/schema/events.js +49 -70
- package/dist/schema/index.d.ts +5 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/nodes.d.ts +28 -0
- package/dist/schema/nodes.js +49 -0
- package/dist/schema/places.d.ts +4 -1
- package/dist/schema/places.js +13 -38
- package/dist/schema/policy.d.ts +17 -0
- package/dist/schema/policy.js +15 -0
- package/dist/schema/values.d.ts +5 -0
- package/dist/schema/values.js +7 -0
- package/dist/url/index.d.ts +2 -0
- package/dist/url/index.js +1 -0
- package/dist/url/outbound.d.ts +13 -0
- package/dist/url/outbound.js +22 -0
- package/package.json +1 -1
- package/src/config/outboundLinkRel.ts +5 -11
- package/src/images/index.ts +1 -1
- package/src/schema/events.ts +65 -71
- package/src/schema/index.ts +5 -1
- package/src/schema/nodes.ts +84 -0
- package/src/schema/places.ts +13 -38
- package/src/schema/policy.ts +25 -0
- package/src/schema/values.ts +8 -0
- package/src/url/index.ts +2 -0
- package/src/url/outbound.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.23.0 (2026-10-04)
|
|
6
|
+
|
|
7
|
+
- `./images`: `IMAGE_LADDER` gains 160 and 240 (now 160, 240, 320, 480, 640, 960, 1280). Thumbnails shown at 64-160 CSS px used to download the 320 file; they now get a 160 or 240 one, which cuts their bytes by about two thirds on desktop at 1x and about a third on phones at 2x. Every srcset gains the two entries, and an off-ladder width at or under 240 (an avatar's `width={64}`) now snaps to the 160 or 240 file instead of 320 (SLA-2958).
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- A site's build fails with `Missing image variant …-160.webp` until it has the new rungs. Migration: run `npm run images:variants` in the site after the pin bump and commit `image-variants/` with the pins (SLA-2958).
|
|
12
|
+
|
|
13
|
+
## 0.22.2 (2026-10-04)
|
|
14
|
+
|
|
15
|
+
- `./schema`: `offer`, `placeNode`, `siteOrganizationNode`, `siteWebsiteNode`, `isFreeEntry` and `renderSchemaPolicy` (with the `SchemaPolicy` type) replace node skeletons repeated across builders and site adapters (SLA-2918).
|
|
16
|
+
- `./schema`: an event offer's `validFrom` is now three months before the start in UTC, clamped to the target month's last day. It used to follow the build machine's timezone (a Paris build emitted `…T23:00Z`/`…T01:00Z`, a UTC build `…T00:00Z`) and overflowed short months (31 May → early March). Event pages and event lists change only in this field (SLA-2918).
|
|
17
|
+
- `./url`: `outboundClassifier` and `outboundHostKey`, the one Rule 38 host lookup; `./config` `outboundLinkRel` uses it (SLA-2918).
|
|
18
|
+
|
|
19
|
+
### Deprecated
|
|
20
|
+
|
|
21
|
+
- `./schema` `touristTripBuilder` and `TouristTripOptions`: tourism-only; import them from `@sarimarcus/content-sites-tourism/schema`. Removed in the next minor (SLA-2918).
|
|
22
|
+
|
|
5
23
|
## 0.22.1 (2026-10-04)
|
|
6
24
|
|
|
7
25
|
- No changes; lockstep release with ui (SLA-2920).
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ for byte, key order included. It also holds the opening-hours model (`generateOp
|
|
|
24
24
|
`assertedOpenDays`, `hoursStringOpenDays`) that `scripts/validate-opening-hours.mjs` imports. The factory scripts
|
|
25
25
|
load `dist/`; `scripts/lib/core-dist.mjs` rebuilds it when it is missing or older than `src/`.
|
|
26
26
|
|
|
27
|
-
`images` holds the one responsive width ladder (`IMAGE_LADDER`: 320, 480, 640, 960, 1280; WebP only) and the image
|
|
27
|
+
`images` holds the one responsive width ladder (`IMAGE_LADDER`: 160, 240, 320, 480, 640, 960, 1280; WebP only) and the image
|
|
28
28
|
service `defineSiteConfig` installs. The service never encodes: it serves committed variants from the site's
|
|
29
29
|
`image-variants/` (keyed by the source's content hash, listed in `manifest.json`) and the source bytes for the native
|
|
30
30
|
width, and caps each srcset at the source width. A call site asks for a responsive image with
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { outboundClassifier } from "../url/outbound.js";
|
|
4
5
|
function walk(dir, out = []) {
|
|
5
6
|
let entries;
|
|
6
7
|
try {
|
|
@@ -38,8 +39,7 @@ export function outboundLinkRel({ root }) {
|
|
|
38
39
|
name: 'outbound-link-rel',
|
|
39
40
|
hooks: {
|
|
40
41
|
'astro:build:done': async ({ dir, logger }) => {
|
|
41
|
-
const
|
|
42
|
-
const classes = new Map(Object.entries(store.domains).map(([d, e]) => [d.replace(/^www\./, '').toLowerCase(), e.class]));
|
|
42
|
+
const classify = outboundClassifier(JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8')));
|
|
43
43
|
const outDir = fileURLToPath(dir);
|
|
44
44
|
let files = 0;
|
|
45
45
|
let anchors = 0;
|
|
@@ -50,14 +50,7 @@ export function outboundLinkRel({ root }) {
|
|
|
50
50
|
const href = attrs.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? attrs.match(/href\s*=\s*'([^']*)'/i)?.[1];
|
|
51
51
|
if (!href || !/^https?:\/\//i.test(href))
|
|
52
52
|
return tag;
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
host = new URL(href).hostname.toLowerCase().replace(/^www\./, '');
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return tag;
|
|
59
|
-
}
|
|
60
|
-
const cls = classes.get(host) ?? 'other';
|
|
53
|
+
const cls = classify(href);
|
|
61
54
|
if (cls !== 'competitor' && cls !== 'affiliate')
|
|
62
55
|
return tag;
|
|
63
56
|
anchors++;
|
package/dist/images/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The one width ladder every responsive image uses. WebP only. Not frozen: Astro sorts `widths` in place. */
|
|
2
|
-
export const IMAGE_LADDER = [320, 480, 640, 960, 1280];
|
|
2
|
+
export const IMAGE_LADDER = [160, 240, 320, 480, 640, 960, 1280];
|
|
3
3
|
/** Where a site keeps its committed variants, relative to the site root. */
|
|
4
4
|
export const VARIANTS_DIR = 'image-variants';
|
|
5
5
|
export const MANIFEST_FILE = 'manifest.json';
|
package/dist/schema/events.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { createPersonSchema } from "./context.js";
|
|
2
|
-
import {
|
|
2
|
+
import { offer } from "./nodes.js";
|
|
3
|
+
import { geoCoordinates, isFreeEntry, parsePriceFromString, postalAddress } from "./values.js";
|
|
3
4
|
function eventPrice(data) {
|
|
4
|
-
const isFree =
|
|
5
|
-
|
|
6
|
-
return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
|
|
5
|
+
const isFree = isFreeEntry(data);
|
|
6
|
+
return { isFree, price: isFree ? 0 : parsePriceFromString(data.price) };
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
function
|
|
8
|
+
/** Offers open three months before the start date, in UTC; a day the target month lacks clamps to its last day. */
|
|
9
|
+
function offerValidFrom(startDate) {
|
|
10
10
|
if (!startDate)
|
|
11
11
|
return null;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const start = new Date(startDate);
|
|
13
|
+
const year = start.getUTCFullYear();
|
|
14
|
+
const month = start.getUTCMonth() - 3;
|
|
15
|
+
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
16
|
+
return new Date(Date.UTC(year, month, Math.min(start.getUTCDate(), lastDay), start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds())).toISOString();
|
|
15
17
|
}
|
|
16
18
|
function eventLocation(policy, data) {
|
|
17
19
|
const name = data.location || policy.defaultLocationName;
|
|
@@ -27,48 +29,43 @@ function eventLocation(policy, data) {
|
|
|
27
29
|
}
|
|
28
30
|
const EVENT_STATUS = (rescheduled) => (rescheduled ? 'https://schema.org/EventRescheduled' : 'https://schema.org/EventScheduled');
|
|
29
31
|
const OFFLINE = 'https://schema.org/OfflineEventAttendanceMode';
|
|
32
|
+
/** An Event node: its dates, status, attendance mode, free entry, location, organizer, performer and offers. */
|
|
33
|
+
function eventNode(ctx, policy, data, parts) {
|
|
34
|
+
return {
|
|
35
|
+
'@type': 'Event',
|
|
36
|
+
name: data.name,
|
|
37
|
+
description: data.description,
|
|
38
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
39
|
+
...parts.head,
|
|
40
|
+
startDate: data.startDate || undefined,
|
|
41
|
+
endDate: data.endDate || undefined,
|
|
42
|
+
previousStartDate: data.previousStartDate || undefined,
|
|
43
|
+
eventStatus: EVENT_STATUS(data.previousStartDate),
|
|
44
|
+
eventAttendanceMode: OFFLINE,
|
|
45
|
+
isAccessibleForFree: parts.isFree,
|
|
46
|
+
location: eventLocation(policy, data),
|
|
47
|
+
organizer: parts.organizer,
|
|
48
|
+
performer: policy.performer(data),
|
|
49
|
+
offers: parts.offers,
|
|
50
|
+
...parts.tail,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
30
53
|
/** Event at route `event`, its author's Person first, and a companion Article (SLA-357). */
|
|
31
54
|
export function eventBuilder(options) {
|
|
32
55
|
const { offersArray = false, emitEvent = () => true, articleSection = 'Events', inLanguage = 'en', currency = 'EUR' } = options;
|
|
33
56
|
return (data, ctx) => {
|
|
34
57
|
const { isFree, price } = eventPrice(data);
|
|
35
|
-
const
|
|
58
|
+
const validFrom = offerValidFrom(data.startDate);
|
|
36
59
|
const pageRoute = ctx.route('event', data.slug);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
price,
|
|
40
|
-
|
|
41
|
-
|
|
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),
|
|
60
|
+
const offers = offersArray && (data.offers?.length ?? 0) > 0
|
|
61
|
+
? data.offers.map((o) => offer(o.price, o.priceCurrency || currency, { availability: o.availability || undefined, url: o.url || data.officialWebsite || pageRoute, validFrom }))
|
|
62
|
+
: price !== null ? offer(price, currency, { url: data.officialWebsite || pageRoute, validFrom }) : undefined;
|
|
63
|
+
const event = eventNode(ctx, options, data, {
|
|
64
|
+
isFree,
|
|
57
65
|
organizer: options.organizer(data),
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
};
|
|
66
|
+
offers,
|
|
67
|
+
tail: { mainEntityOfPage: { '@id': ctx.pageUrl }, url: pageRoute },
|
|
68
|
+
});
|
|
72
69
|
const author = ctx.resolveAuthor(data.author);
|
|
73
70
|
const article = {
|
|
74
71
|
'@type': 'Article',
|
|
@@ -96,9 +93,6 @@ export function eventItemListBuilder(options) {
|
|
|
96
93
|
name: data.name,
|
|
97
94
|
numberOfItems: data.items?.length || 0,
|
|
98
95
|
itemListElement: data.items?.map((item) => {
|
|
99
|
-
const { isFree, price } = eventPrice(item);
|
|
100
|
-
const from = validFrom(item.startDate);
|
|
101
|
-
const performer = options.performer(item);
|
|
102
96
|
if (!isEvent(item)) {
|
|
103
97
|
return {
|
|
104
98
|
'@type': 'ListItem',
|
|
@@ -106,33 +100,18 @@ export function eventItemListBuilder(options) {
|
|
|
106
100
|
item: { '@type': 'WebPage', name: item.name, description: item.description, url: ctx.abs(item.url) },
|
|
107
101
|
};
|
|
108
102
|
}
|
|
103
|
+
const { isFree, price } = eventPrice(item);
|
|
109
104
|
return {
|
|
110
105
|
'@type': 'ListItem',
|
|
111
106
|
position: item.position,
|
|
112
|
-
item: {
|
|
113
|
-
|
|
114
|
-
|
|
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),
|
|
107
|
+
item: eventNode(ctx, options, item, {
|
|
108
|
+
head: { url: ctx.abs(item.url) },
|
|
109
|
+
isFree,
|
|
125
110
|
organizer: options.itemOrganizer(item),
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
priceCurrency: currency,
|
|
131
|
-
availability: 'https://schema.org/InStock',
|
|
132
|
-
url: item.officialWebsite || ctx.route('event', item.slug),
|
|
133
|
-
validFrom: from,
|
|
134
|
-
} : undefined,
|
|
135
|
-
},
|
|
111
|
+
offers: price !== null ? offer(price, currency, {
|
|
112
|
+
url: item.officialWebsite || ctx.route('event', item.slug), validFrom: offerValidFrom(item.startDate),
|
|
113
|
+
}) : undefined,
|
|
114
|
+
}),
|
|
136
115
|
};
|
|
137
116
|
}),
|
|
138
117
|
});
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export type { JsonLdNode, SchemaEntry, OpeningHoursSpecification, IdRef, SchemaAuthorProfile, ResolvedAuthor, RouteMap, SchemaContextInput, SchemaContext, SchemaBuilder, SchemaBuilders, } from './types.ts';
|
|
2
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';
|
|
3
|
+
export { parsePriceFromString, isFreeEntry, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf, dataAddressPolicy, postalAddress, geoCoordinates, aggregateRating, } from './values.ts';
|
|
4
4
|
export type { AddressPolicy, PostalAddressParts } from './values.ts';
|
|
5
5
|
export { DAY_KEYS, HOURS_ALWAYS_OPEN, parseHoursShifts, closedFieldDays, resolveOpeningHours, assertedOpenDays, generateOpeningHours, parseHoursString, hoursStringOpenDays, } from './hours.ts';
|
|
6
6
|
export type { DayKey, OpeningHoursInput, OpeningHoursBucket } from './hours.ts';
|
|
7
|
+
export { renderSchemaPolicy } from './policy.ts';
|
|
8
|
+
export type { SchemaPolicy } from './policy.ts';
|
|
9
|
+
export { offer, placeNode, siteOrganizationNode, siteWebsiteNode } from './nodes.ts';
|
|
10
|
+
export type { OfferFields, SiteNodeFields, OrganizationLogo } from './nodes.ts';
|
|
7
11
|
export { buildBreadcrumbList, buildWebPageNode, assembleGraph, finalizeGraph } from './graph.ts';
|
|
8
12
|
export type { BreadcrumbInput, PageInput, AssembleOptions } from './graph.ts';
|
|
9
13
|
export { faqPageBuilder, articleBuilder, collectionPageBuilder, itemListBuilder, howToBuilder, aboutPageBuilder, contactPageBuilder, personBuilder, aggregateOfferBuilder, touristDestinationBuilder, organizationBuilder, } from './pages.ts';
|
package/dist/schema/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
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";
|
|
2
|
+
export { parsePriceFromString, isFreeEntry, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf, dataAddressPolicy, postalAddress, geoCoordinates, aggregateRating, } from "./values.js";
|
|
3
3
|
export { DAY_KEYS, HOURS_ALWAYS_OPEN, parseHoursShifts, closedFieldDays, resolveOpeningHours, assertedOpenDays, generateOpeningHours, parseHoursString, hoursStringOpenDays, } from "./hours.js";
|
|
4
|
+
export { renderSchemaPolicy } from "./policy.js";
|
|
5
|
+
export { offer, placeNode, siteOrganizationNode, siteWebsiteNode } from "./nodes.js";
|
|
4
6
|
export { buildBreadcrumbList, buildWebPageNode, assembleGraph, finalizeGraph } from "./graph.js";
|
|
5
7
|
export { faqPageBuilder, articleBuilder, collectionPageBuilder, itemListBuilder, howToBuilder, aboutPageBuilder, contactPageBuilder, personBuilder, aggregateOfferBuilder, touristDestinationBuilder, organizationBuilder, } from "./pages.js";
|
|
6
8
|
export { hotelBuilder, activityBuilder, touristTripBuilder } from "./places.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JsonLdNode, SchemaContext } from './types.ts';
|
|
2
|
+
export interface OfferFields {
|
|
3
|
+
/** schema.org ItemAvailability name. */
|
|
4
|
+
availability?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
validFrom?: string | null;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
/** An Offer; `url`, `validFrom` and `description` follow in the order given. */
|
|
10
|
+
export declare function offer(price: number | null, currency: string, { availability, ...rest }?: OfferFields): JsonLdNode;
|
|
11
|
+
/**
|
|
12
|
+
* An entity page's node, preceded by its author's Person: type, name, description and image, then `fields` in their
|
|
13
|
+
* order, then `mainEntityOfPage` and the `route` URL. A field named `image` replaces the resolved image in place.
|
|
14
|
+
*/
|
|
15
|
+
export declare function placeNode(ctx: SchemaContext, data: Record<string, any>, type: string | string[], route: string, fields: JsonLdNode): JsonLdNode | JsonLdNode[];
|
|
16
|
+
export interface SiteNodeFields {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
}
|
|
20
|
+
export interface OrganizationLogo {
|
|
21
|
+
path: string;
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
}
|
|
25
|
+
/** The base graph's Organization; `extra` (sameAs, knowsAbout, …) follows the description. */
|
|
26
|
+
export declare function siteOrganizationNode(ctx: SchemaContext, { name, description }: SiteNodeFields, logo?: OrganizationLogo, extra?: JsonLdNode): JsonLdNode;
|
|
27
|
+
/** The base graph's WebSite, published by the Organization; `extra` (a SearchAction, …) follows. */
|
|
28
|
+
export declare function siteWebsiteNode(ctx: SchemaContext, { name, description }: SiteNodeFields, extra?: JsonLdNode): JsonLdNode;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { withAuthorPersonSchema } from "./context.js";
|
|
2
|
+
/** An Offer; `url`, `validFrom` and `description` follow in the order given. */
|
|
3
|
+
export function offer(price, currency, { availability = 'InStock', ...rest } = {}) {
|
|
4
|
+
return { '@type': 'Offer', price, priceCurrency: currency, availability: `https://schema.org/${availability}`, ...rest };
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* An entity page's node, preceded by its author's Person: type, name, description and image, then `fields` in their
|
|
8
|
+
* order, then `mainEntityOfPage` and the `route` URL. A field named `image` replaces the resolved image in place.
|
|
9
|
+
*/
|
|
10
|
+
export function placeNode(ctx, data, type, route, fields) {
|
|
11
|
+
return withAuthorPersonSchema(ctx, {
|
|
12
|
+
'@type': type,
|
|
13
|
+
name: data.name,
|
|
14
|
+
description: data.description,
|
|
15
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
16
|
+
...fields,
|
|
17
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
18
|
+
url: ctx.route(route, data.slug),
|
|
19
|
+
}, data.author);
|
|
20
|
+
}
|
|
21
|
+
/** The base graph's Organization; `extra` (sameAs, knowsAbout, …) follows the description. */
|
|
22
|
+
export function siteOrganizationNode(ctx, { name, description }, logo = { path: '/favicon.svg' }, extra = {}) {
|
|
23
|
+
const logoUrl = ctx.abs(logo.path);
|
|
24
|
+
return {
|
|
25
|
+
'@type': 'Organization',
|
|
26
|
+
'@id': ctx.orgId,
|
|
27
|
+
name,
|
|
28
|
+
url: ctx.siteBaseUrl,
|
|
29
|
+
logo: {
|
|
30
|
+
'@type': 'ImageObject', url: logoUrl, contentUrl: logoUrl,
|
|
31
|
+
...(logo.width && { width: logo.width }), ...(logo.height && { height: logo.height }),
|
|
32
|
+
},
|
|
33
|
+
description,
|
|
34
|
+
...extra,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** The base graph's WebSite, published by the Organization; `extra` (a SearchAction, …) follows. */
|
|
38
|
+
export function siteWebsiteNode(ctx, { name, description }, extra = {}) {
|
|
39
|
+
return {
|
|
40
|
+
'@type': 'WebSite',
|
|
41
|
+
'@id': ctx.websiteId,
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
url: ctx.siteBaseUrl,
|
|
45
|
+
inLanguage: 'en',
|
|
46
|
+
publisher: { '@id': ctx.orgId },
|
|
47
|
+
...extra,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/schema/places.d.ts
CHANGED
|
@@ -23,5 +23,8 @@ export interface TouristTripOptions {
|
|
|
23
23
|
offerDescription?: string;
|
|
24
24
|
currency?: string;
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* A free self-guided TouristTrip at route `walk`, its stops as the itinerary.
|
|
28
|
+
* @deprecated Tourism-only: import `touristTripBuilder` from `@sarimarcus/content-sites-tourism/schema` (SLA-2918).
|
|
29
|
+
*/
|
|
27
30
|
export declare function touristTripBuilder(options?: TouristTripOptions): SchemaBuilder;
|
package/dist/schema/places.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import { withAuthorPersonSchema } from "./context.js";
|
|
2
1
|
import { generateOpeningHours } from "./hours.js";
|
|
2
|
+
import { offer, placeNode } from "./nodes.js";
|
|
3
3
|
import { aggregateRating, geoCoordinates, normalizeHotelPriceRange, parsePriceFromString, postalAddress, postalCodeOf, } from "./values.js";
|
|
4
4
|
/** Hotel at route `hotel`; no `openingHours` means open around the clock. */
|
|
5
5
|
export function hotelBuilder(options) {
|
|
6
6
|
const { address, defaultCheckinTime = '14:00', defaultCheckoutTime = '12:00' } = options;
|
|
7
7
|
return (data, ctx) => {
|
|
8
8
|
const imageUrl = ctx.resolveImageUrl(data.image, data.slug);
|
|
9
|
-
return
|
|
10
|
-
'@type': 'Hotel',
|
|
11
|
-
name: data.name,
|
|
12
|
-
description: data.description,
|
|
9
|
+
return placeNode(ctx, data, 'Hotel', 'hotel', {
|
|
13
10
|
image: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : undefined,
|
|
14
11
|
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
15
12
|
geo: geoCoordinates(data.coordinates),
|
|
@@ -26,19 +23,13 @@ export function hotelBuilder(options) {
|
|
|
26
23
|
checkoutTime: data.checkoutTime || defaultCheckoutTime,
|
|
27
24
|
openingHoursSpecification: generateOpeningHours(data.openingHours || { alwaysOpen: true }),
|
|
28
25
|
sameAs: data.website ? [data.website] : undefined,
|
|
29
|
-
|
|
30
|
-
url: ctx.route('hotel', data.slug),
|
|
31
|
-
}, data.author);
|
|
26
|
+
});
|
|
32
27
|
};
|
|
33
28
|
}
|
|
34
29
|
/** TouristAttraction at route `activity`; the street falls back to `meetingPoint`. */
|
|
35
30
|
export function activityBuilder(options) {
|
|
36
31
|
const { address, touristType = 'Cultural', defaultLanguages, offers = false, currency = 'EUR' } = options;
|
|
37
|
-
return (data, ctx) =>
|
|
38
|
-
'@type': 'TouristAttraction',
|
|
39
|
-
name: data.name,
|
|
40
|
-
description: data.description,
|
|
41
|
-
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
32
|
+
return (data, ctx) => placeNode(ctx, data, 'TouristAttraction', 'activity', {
|
|
42
33
|
address: postalAddress(address, data, { street: data.address || data.meetingPoint, postalCode: postalCodeOf(data) }),
|
|
43
34
|
geo: geoCoordinates(data.coordinates),
|
|
44
35
|
touristType,
|
|
@@ -47,32 +38,18 @@ export function activityBuilder(options) {
|
|
|
47
38
|
availableLanguage: data.languages || defaultLanguages,
|
|
48
39
|
telephone: data.phone,
|
|
49
40
|
aggregateRating: aggregateRating(data),
|
|
50
|
-
offers: offers && data.priceFrom !== undefined ?
|
|
51
|
-
|
|
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);
|
|
41
|
+
offers: offers && data.priceFrom !== undefined ? offer(data.priceFrom, currency) : undefined,
|
|
42
|
+
});
|
|
59
43
|
}
|
|
60
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* A free self-guided TouristTrip at route `walk`, its stops as the itinerary.
|
|
46
|
+
* @deprecated Tourism-only: import `touristTripBuilder` from `@sarimarcus/content-sites-tourism/schema` (SLA-2918).
|
|
47
|
+
*/
|
|
61
48
|
export function touristTripBuilder(options = {}) {
|
|
62
49
|
const { touristType = 'Cultural', offerDescription = 'Self-guided walking tour — free', currency = 'EUR' } = options;
|
|
63
|
-
return (data, ctx) =>
|
|
64
|
-
'@type': 'TouristTrip',
|
|
65
|
-
name: data.name,
|
|
66
|
-
description: data.description,
|
|
67
|
-
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
50
|
+
return (data, ctx) => placeNode(ctx, data, 'TouristTrip', 'walk', {
|
|
68
51
|
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
|
-
},
|
|
52
|
+
offers: offer(0, currency, { description: offerDescription }),
|
|
76
53
|
itinerary: {
|
|
77
54
|
'@type': 'ItemList',
|
|
78
55
|
numberOfItems: data.stops?.length || 0,
|
|
@@ -88,7 +65,5 @@ export function touristTripBuilder(options = {}) {
|
|
|
88
65
|
},
|
|
89
66
|
})),
|
|
90
67
|
},
|
|
91
|
-
|
|
92
|
-
url: ctx.route('walk', data.slug),
|
|
93
|
-
}, data.author);
|
|
68
|
+
});
|
|
94
69
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AssembleOptions, type PageInput } from './graph.ts';
|
|
2
|
+
import type { JsonLdNode, SchemaBuilders, SchemaContext, SchemaContextInput, SchemaEntry } from './types.ts';
|
|
3
|
+
/** A site's JSON-LD policy: its context options, base nodes, builders and graph hooks. */
|
|
4
|
+
export interface SchemaPolicy {
|
|
5
|
+
context?: Partial<SchemaContextInput>;
|
|
6
|
+
nodes(ctx: SchemaContext): {
|
|
7
|
+
organization: JsonLdNode;
|
|
8
|
+
website: JsonLdNode;
|
|
9
|
+
};
|
|
10
|
+
builders: SchemaBuilders;
|
|
11
|
+
hooks?(ctx: SchemaContext, entries: SchemaEntry[]): Pick<AssembleOptions, 'beforeEntries' | 'afterEntries'>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One page's finalised JSON-LD under a site policy: context, base nodes, entries, hooks. The site adapter and the
|
|
15
|
+
* factory schema goldens both render through this, so the goldens test the wiring that ships.
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderSchemaPolicy(input: SchemaContextInput, policy: SchemaPolicy, page: PageInput): JsonLdNode;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createSchemaContext } from "./context.js";
|
|
2
|
+
import { assembleGraph, finalizeGraph } from "./graph.js";
|
|
3
|
+
/**
|
|
4
|
+
* One page's finalised JSON-LD under a site policy: context, base nodes, entries, hooks. The site adapter and the
|
|
5
|
+
* factory schema goldens both render through this, so the goldens test the wiring that ships.
|
|
6
|
+
*/
|
|
7
|
+
export function renderSchemaPolicy(input, policy, page) {
|
|
8
|
+
const ctx = createSchemaContext({ ...input, ...policy.context });
|
|
9
|
+
return finalizeGraph(ctx, assembleGraph(ctx, {
|
|
10
|
+
...policy.nodes(ctx),
|
|
11
|
+
builders: policy.builders,
|
|
12
|
+
...page,
|
|
13
|
+
...policy.hooks?.(ctx, page.entries),
|
|
14
|
+
}));
|
|
15
|
+
}
|
package/dist/schema/values.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { JsonLdNode } from './types.ts';
|
|
2
2
|
/** First integer in a price string; any "free" reads as 0; null when there is no number. */
|
|
3
3
|
export declare function parsePriceFromString(priceStr: string | undefined): number | null;
|
|
4
|
+
/**
|
|
5
|
+
* Free entry: a `free` tag, or "free" in the price text (`price` for events, `fees` for places). `undefined` when
|
|
6
|
+
* neither is present, so the key drops out of the JSON-LD.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isFreeEntry(data: Record<string, any>, field?: 'price' | 'fees'): boolean | undefined;
|
|
4
9
|
/** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
|
|
5
10
|
export declare function cleanStreetAddress(fullAddress: string | undefined): string;
|
|
6
11
|
/** A €-symbol range (`€`…`€€€€`) from a price string, inferred from `priceFrom` when given. */
|
package/dist/schema/values.js
CHANGED
|
@@ -7,6 +7,13 @@ export function parsePriceFromString(priceStr) {
|
|
|
7
7
|
const match = priceStr.match(/(\d+)/);
|
|
8
8
|
return match ? parseFloat(match[1]) : null;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Free entry: a `free` tag, or "free" in the price text (`price` for events, `fees` for places). `undefined` when
|
|
12
|
+
* neither is present, so the key drops out of the JSON-LD.
|
|
13
|
+
*/
|
|
14
|
+
export function isFreeEntry(data, field = 'price') {
|
|
15
|
+
return data.tags?.includes('free') || data[field]?.toLowerCase().includes('free');
|
|
16
|
+
}
|
|
10
17
|
/** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
|
|
11
18
|
export function cleanStreetAddress(fullAddress) {
|
|
12
19
|
if (!fullAddress)
|
package/dist/url/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { buildImageMap, keyBySlug } from './imageMap.ts';
|
|
|
5
5
|
export { articleFeedItem } from './feed.ts';
|
|
6
6
|
export type { FeedArticle, FeedItem } from './feed.ts';
|
|
7
7
|
export { pageTypeOf } from './pageType.ts';
|
|
8
|
+
export { outboundClassifier, outboundHostKey } from './outbound.ts';
|
|
9
|
+
export type { OutboundDomainStore } from './outbound.ts';
|
package/dist/url/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { normalizeHref, extractProseHrefs, dedupRelated } from "./relatedDedup.j
|
|
|
5
5
|
export { buildImageMap, keyBySlug } from "./imageMap.js";
|
|
6
6
|
export { articleFeedItem } from "./feed.js";
|
|
7
7
|
export { pageTypeOf } from "./pageType.js";
|
|
8
|
+
export { outboundClassifier, outboundHostKey } from "./outbound.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The outbound-domains store as synced into each site (`src/data/outbound-domains.json`). */
|
|
2
|
+
export interface OutboundDomainStore {
|
|
3
|
+
domains: Record<string, {
|
|
4
|
+
class: string;
|
|
5
|
+
}>;
|
|
6
|
+
}
|
|
7
|
+
/** Host key of the store: lowercase, without `www.`. */
|
|
8
|
+
export declare function outboundHostKey(host: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* URL → its Rule 38 class (`competitor`, `affiliate`, `official`, …) from the store, or a plain host → class map;
|
|
11
|
+
* `other` for an unlisted host or a URL that does not parse. Lookup only: each consumer decides what a class renders.
|
|
12
|
+
*/
|
|
13
|
+
export declare function outboundClassifier(store: OutboundDomainStore | Record<string, string>): (url: string) => string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Host key of the store: lowercase, without `www.`. */
|
|
2
|
+
export function outboundHostKey(host) {
|
|
3
|
+
return host.toLowerCase().replace(/^www\./, '');
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* URL → its Rule 38 class (`competitor`, `affiliate`, `official`, …) from the store, or a plain host → class map;
|
|
7
|
+
* `other` for an unlisted host or a URL that does not parse. Lookup only: each consumer decides what a class renders.
|
|
8
|
+
*/
|
|
9
|
+
export function outboundClassifier(store) {
|
|
10
|
+
const entries = 'domains' in store && typeof store.domains === 'object'
|
|
11
|
+
? Object.entries(store.domains).map(([d, e]) => [d, e.class])
|
|
12
|
+
: Object.entries(store);
|
|
13
|
+
const classes = new Map(entries.map(([d, c]) => [outboundHostKey(d), c]));
|
|
14
|
+
return (url) => {
|
|
15
|
+
try {
|
|
16
|
+
return classes.get(outboundHostKey(new URL(url).hostname)) ?? 'other';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return 'other';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import type { AstroIntegration } from 'astro';
|
|
5
|
+
import { outboundClassifier, type OutboundDomainStore } from '../url/outbound.ts';
|
|
5
6
|
|
|
6
7
|
export interface OutboundLinkRelOptions {
|
|
7
8
|
/** Site root; the classification store is read from `src/data/outbound-domains.json` under it. */
|
|
@@ -44,10 +45,9 @@ export function outboundLinkRel({ root }: OutboundLinkRelOptions): AstroIntegrat
|
|
|
44
45
|
name: 'outbound-link-rel',
|
|
45
46
|
hooks: {
|
|
46
47
|
'astro:build:done': async ({ dir, logger }) => {
|
|
47
|
-
const
|
|
48
|
-
domains
|
|
49
|
-
|
|
50
|
-
const classes = new Map(Object.entries(store.domains).map(([d, e]) => [d.replace(/^www\./, '').toLowerCase(), e.class]));
|
|
48
|
+
const classify = outboundClassifier(
|
|
49
|
+
JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8')) as OutboundDomainStore,
|
|
50
|
+
);
|
|
51
51
|
const outDir = fileURLToPath(dir);
|
|
52
52
|
let files = 0;
|
|
53
53
|
let anchors = 0;
|
|
@@ -57,13 +57,7 @@ export function outboundLinkRel({ root }: OutboundLinkRelOptions): AstroIntegrat
|
|
|
57
57
|
const html = original.replace(/<a\b([^>]*)>/gi, (tag, attrs: string) => {
|
|
58
58
|
const href = attrs.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? attrs.match(/href\s*=\s*'([^']*)'/i)?.[1];
|
|
59
59
|
if (!href || !/^https?:\/\//i.test(href)) return tag;
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
host = new URL(href).hostname.toLowerCase().replace(/^www\./, '');
|
|
63
|
-
} catch {
|
|
64
|
-
return tag;
|
|
65
|
-
}
|
|
66
|
-
const cls = classes.get(host) ?? 'other';
|
|
60
|
+
const cls = classify(href);
|
|
67
61
|
if (cls !== 'competitor' && cls !== 'affiliate') return tag;
|
|
68
62
|
anchors++;
|
|
69
63
|
const tokens: string[] = [];
|
package/src/images/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The one width ladder every responsive image uses. WebP only. Not frozen: Astro sorts `widths` in place. */
|
|
2
|
-
export const IMAGE_LADDER: number[] = [320, 480, 640, 960, 1280];
|
|
2
|
+
export const IMAGE_LADDER: number[] = [160, 240, 320, 480, 640, 960, 1280];
|
|
3
3
|
|
|
4
4
|
/** Where a site keeps its committed variants, relative to the site root. */
|
|
5
5
|
export const VARIANTS_DIR = 'image-variants';
|
package/src/schema/events.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createPersonSchema } from './context.ts';
|
|
2
|
-
import {
|
|
2
|
+
import { offer } from './nodes.ts';
|
|
3
|
+
import { geoCoordinates, isFreeEntry, parsePriceFromString, postalAddress, type AddressPolicy } from './values.ts';
|
|
3
4
|
import type { JsonLdNode, SchemaBuilder, SchemaContext } from './types.ts';
|
|
4
5
|
|
|
5
6
|
/** Where events take place and who runs them; every value is the site's. */
|
|
@@ -15,17 +16,21 @@ export interface EventPolicy {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function eventPrice(data: Record<string, any>): { isFree: boolean | undefined; price: number | null } {
|
|
18
|
-
const isFree =
|
|
19
|
-
|
|
20
|
-
return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
|
|
19
|
+
const isFree = isFreeEntry(data);
|
|
20
|
+
return { isFree, price: isFree ? 0 : parsePriceFromString(data.price) };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
function
|
|
23
|
+
/** Offers open three months before the start date, in UTC; a day the target month lacks clamps to its last day. */
|
|
24
|
+
function offerValidFrom(startDate: string | undefined): string | null {
|
|
25
25
|
if (!startDate) return null;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const start = new Date(startDate);
|
|
27
|
+
const year = start.getUTCFullYear();
|
|
28
|
+
const month = start.getUTCMonth() - 3;
|
|
29
|
+
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
30
|
+
return new Date(Date.UTC(
|
|
31
|
+
year, month, Math.min(start.getUTCDate(), lastDay),
|
|
32
|
+
start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds(),
|
|
33
|
+
)).toISOString();
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function eventLocation(policy: EventPolicy, data: Record<string, any>): JsonLdNode | undefined {
|
|
@@ -43,6 +48,38 @@ function eventLocation(policy: EventPolicy, data: Record<string, any>): JsonLdNo
|
|
|
43
48
|
const EVENT_STATUS = (rescheduled: unknown) => (rescheduled ? 'https://schema.org/EventRescheduled' : 'https://schema.org/EventScheduled');
|
|
44
49
|
const OFFLINE = 'https://schema.org/OfflineEventAttendanceMode';
|
|
45
50
|
|
|
51
|
+
interface EventNodeParts {
|
|
52
|
+
isFree: boolean | undefined;
|
|
53
|
+
organizer: JsonLdNode;
|
|
54
|
+
offers: JsonLdNode | JsonLdNode[] | undefined;
|
|
55
|
+
/** Keys placed after the image. */
|
|
56
|
+
head?: JsonLdNode;
|
|
57
|
+
/** Keys placed last. */
|
|
58
|
+
tail?: JsonLdNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An Event node: its dates, status, attendance mode, free entry, location, organizer, performer and offers. */
|
|
62
|
+
function eventNode(ctx: SchemaContext, policy: EventPolicy, data: Record<string, any>, parts: EventNodeParts): JsonLdNode {
|
|
63
|
+
return {
|
|
64
|
+
'@type': 'Event',
|
|
65
|
+
name: data.name,
|
|
66
|
+
description: data.description,
|
|
67
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
68
|
+
...parts.head,
|
|
69
|
+
startDate: data.startDate || undefined,
|
|
70
|
+
endDate: data.endDate || undefined,
|
|
71
|
+
previousStartDate: data.previousStartDate || undefined,
|
|
72
|
+
eventStatus: EVENT_STATUS(data.previousStartDate),
|
|
73
|
+
eventAttendanceMode: OFFLINE,
|
|
74
|
+
isAccessibleForFree: parts.isFree,
|
|
75
|
+
location: eventLocation(policy, data),
|
|
76
|
+
organizer: parts.organizer,
|
|
77
|
+
performer: policy.performer(data),
|
|
78
|
+
offers: parts.offers,
|
|
79
|
+
...parts.tail,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
export interface EventOptions extends EventPolicy {
|
|
47
84
|
/** Map `data.offers[]` to Offers when present (else one Offer from `price`). */
|
|
48
85
|
offersArray?: boolean;
|
|
@@ -57,44 +94,19 @@ export function eventBuilder(options: EventOptions): SchemaBuilder {
|
|
|
57
94
|
const { offersArray = false, emitEvent = () => true, articleSection = 'Events', inLanguage = 'en', currency = 'EUR' } = options;
|
|
58
95
|
return (data, ctx) => {
|
|
59
96
|
const { isFree, price } = eventPrice(data);
|
|
60
|
-
const
|
|
97
|
+
const validFrom = offerValidFrom(data.startDate);
|
|
61
98
|
const pageRoute = ctx.route('event', data.slug);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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),
|
|
99
|
+
const offers = offersArray && (data.offers?.length ?? 0) > 0
|
|
100
|
+
? data.offers.map((o: { price: number; priceCurrency?: string; url?: string; availability?: string }) => offer(
|
|
101
|
+
o.price, o.priceCurrency || currency, { availability: o.availability || undefined, url: o.url || data.officialWebsite || pageRoute, validFrom },
|
|
102
|
+
))
|
|
103
|
+
: price !== null ? offer(price, currency, { url: data.officialWebsite || pageRoute, validFrom }) : undefined;
|
|
104
|
+
const event = eventNode(ctx, options, data, {
|
|
105
|
+
isFree,
|
|
83
106
|
organizer: options.organizer(data),
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
};
|
|
107
|
+
offers,
|
|
108
|
+
tail: { mainEntityOfPage: { '@id': ctx.pageUrl }, url: pageRoute },
|
|
109
|
+
});
|
|
98
110
|
|
|
99
111
|
const author = ctx.resolveAuthor(data.author);
|
|
100
112
|
const article: JsonLdNode = {
|
|
@@ -132,9 +144,6 @@ export function eventItemListBuilder(options: EventItemListOptions): SchemaBuild
|
|
|
132
144
|
name: data.name,
|
|
133
145
|
numberOfItems: data.items?.length || 0,
|
|
134
146
|
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
147
|
if (!isEvent(item)) {
|
|
139
148
|
return {
|
|
140
149
|
'@type': 'ListItem',
|
|
@@ -142,33 +151,18 @@ export function eventItemListBuilder(options: EventItemListOptions): SchemaBuild
|
|
|
142
151
|
item: { '@type': 'WebPage', name: item.name, description: item.description, url: ctx.abs(item.url) },
|
|
143
152
|
};
|
|
144
153
|
}
|
|
154
|
+
const { isFree, price } = eventPrice(item);
|
|
145
155
|
return {
|
|
146
156
|
'@type': 'ListItem',
|
|
147
157
|
position: item.position,
|
|
148
|
-
item: {
|
|
149
|
-
|
|
150
|
-
|
|
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),
|
|
158
|
+
item: eventNode(ctx, options, item, {
|
|
159
|
+
head: { url: ctx.abs(item.url) },
|
|
160
|
+
isFree,
|
|
161
161
|
organizer: options.itemOrganizer(item),
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
priceCurrency: currency,
|
|
167
|
-
availability: 'https://schema.org/InStock',
|
|
168
|
-
url: item.officialWebsite || ctx.route('event', item.slug),
|
|
169
|
-
validFrom: from,
|
|
170
|
-
} : undefined,
|
|
171
|
-
},
|
|
162
|
+
offers: price !== null ? offer(price, currency, {
|
|
163
|
+
url: item.officialWebsite || ctx.route('event', item.slug), validFrom: offerValidFrom(item.startDate),
|
|
164
|
+
}) : undefined,
|
|
165
|
+
}),
|
|
172
166
|
};
|
|
173
167
|
}),
|
|
174
168
|
});
|
package/src/schema/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export {
|
|
|
8
8
|
withAuthorPersonSchema,
|
|
9
9
|
} from './context.ts';
|
|
10
10
|
export {
|
|
11
|
-
parsePriceFromString, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf,
|
|
11
|
+
parsePriceFromString, isFreeEntry, cleanStreetAddress, normalizePriceRange, normalizeHotelPriceRange, postalCodeOf,
|
|
12
12
|
dataAddressPolicy, postalAddress, geoCoordinates, aggregateRating,
|
|
13
13
|
} from './values.ts';
|
|
14
14
|
export type { AddressPolicy, PostalAddressParts } from './values.ts';
|
|
@@ -17,6 +17,10 @@ export {
|
|
|
17
17
|
generateOpeningHours, parseHoursString, hoursStringOpenDays,
|
|
18
18
|
} from './hours.ts';
|
|
19
19
|
export type { DayKey, OpeningHoursInput, OpeningHoursBucket } from './hours.ts';
|
|
20
|
+
export { renderSchemaPolicy } from './policy.ts';
|
|
21
|
+
export type { SchemaPolicy } from './policy.ts';
|
|
22
|
+
export { offer, placeNode, siteOrganizationNode, siteWebsiteNode } from './nodes.ts';
|
|
23
|
+
export type { OfferFields, SiteNodeFields, OrganizationLogo } from './nodes.ts';
|
|
20
24
|
export { buildBreadcrumbList, buildWebPageNode, assembleGraph, finalizeGraph } from './graph.ts';
|
|
21
25
|
export type { BreadcrumbInput, PageInput, AssembleOptions } from './graph.ts';
|
|
22
26
|
export {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { withAuthorPersonSchema } from './context.ts';
|
|
2
|
+
import type { JsonLdNode, SchemaContext } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export interface OfferFields {
|
|
5
|
+
/** schema.org ItemAvailability name. */
|
|
6
|
+
availability?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
validFrom?: string | null;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** An Offer; `url`, `validFrom` and `description` follow in the order given. */
|
|
13
|
+
export function offer(price: number | null, currency: string, { availability = 'InStock', ...rest }: OfferFields = {}): JsonLdNode {
|
|
14
|
+
return { '@type': 'Offer', price, priceCurrency: currency, availability: `https://schema.org/${availability}`, ...rest };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An entity page's node, preceded by its author's Person: type, name, description and image, then `fields` in their
|
|
19
|
+
* order, then `mainEntityOfPage` and the `route` URL. A field named `image` replaces the resolved image in place.
|
|
20
|
+
*/
|
|
21
|
+
export function placeNode(
|
|
22
|
+
ctx: SchemaContext,
|
|
23
|
+
data: Record<string, any>,
|
|
24
|
+
type: string | string[],
|
|
25
|
+
route: string,
|
|
26
|
+
fields: JsonLdNode,
|
|
27
|
+
): JsonLdNode | JsonLdNode[] {
|
|
28
|
+
return withAuthorPersonSchema(ctx, {
|
|
29
|
+
'@type': type,
|
|
30
|
+
name: data.name,
|
|
31
|
+
description: data.description,
|
|
32
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
33
|
+
...fields,
|
|
34
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
35
|
+
url: ctx.route(route, data.slug),
|
|
36
|
+
}, data.author);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SiteNodeFields {
|
|
40
|
+
name: string;
|
|
41
|
+
description: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OrganizationLogo {
|
|
45
|
+
path: string;
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The base graph's Organization; `extra` (sameAs, knowsAbout, …) follows the description. */
|
|
51
|
+
export function siteOrganizationNode(
|
|
52
|
+
ctx: SchemaContext,
|
|
53
|
+
{ name, description }: SiteNodeFields,
|
|
54
|
+
logo: OrganizationLogo = { path: '/favicon.svg' },
|
|
55
|
+
extra: JsonLdNode = {},
|
|
56
|
+
): JsonLdNode {
|
|
57
|
+
const logoUrl = ctx.abs(logo.path);
|
|
58
|
+
return {
|
|
59
|
+
'@type': 'Organization',
|
|
60
|
+
'@id': ctx.orgId,
|
|
61
|
+
name,
|
|
62
|
+
url: ctx.siteBaseUrl,
|
|
63
|
+
logo: {
|
|
64
|
+
'@type': 'ImageObject', url: logoUrl, contentUrl: logoUrl,
|
|
65
|
+
...(logo.width && { width: logo.width }), ...(logo.height && { height: logo.height }),
|
|
66
|
+
},
|
|
67
|
+
description,
|
|
68
|
+
...extra,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The base graph's WebSite, published by the Organization; `extra` (a SearchAction, …) follows. */
|
|
73
|
+
export function siteWebsiteNode(ctx: SchemaContext, { name, description }: SiteNodeFields, extra: JsonLdNode = {}): JsonLdNode {
|
|
74
|
+
return {
|
|
75
|
+
'@type': 'WebSite',
|
|
76
|
+
'@id': ctx.websiteId,
|
|
77
|
+
name,
|
|
78
|
+
description,
|
|
79
|
+
url: ctx.siteBaseUrl,
|
|
80
|
+
inLanguage: 'en',
|
|
81
|
+
publisher: { '@id': ctx.orgId },
|
|
82
|
+
...extra,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/schema/places.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { withAuthorPersonSchema } from './context.ts';
|
|
2
1
|
import { generateOpeningHours } from './hours.ts';
|
|
2
|
+
import { offer, placeNode } from './nodes.ts';
|
|
3
3
|
import {
|
|
4
4
|
aggregateRating, geoCoordinates, normalizeHotelPriceRange, parsePriceFromString, postalAddress, postalCodeOf,
|
|
5
5
|
type AddressPolicy,
|
|
@@ -17,10 +17,7 @@ export function hotelBuilder(options: HotelOptions): SchemaBuilder {
|
|
|
17
17
|
const { address, defaultCheckinTime = '14:00', defaultCheckoutTime = '12:00' } = options;
|
|
18
18
|
return (data, ctx) => {
|
|
19
19
|
const imageUrl = ctx.resolveImageUrl(data.image, data.slug);
|
|
20
|
-
return
|
|
21
|
-
'@type': 'Hotel',
|
|
22
|
-
name: data.name,
|
|
23
|
-
description: data.description,
|
|
20
|
+
return placeNode(ctx, data, 'Hotel', 'hotel', {
|
|
24
21
|
image: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : undefined,
|
|
25
22
|
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
26
23
|
geo: geoCoordinates(data.coordinates),
|
|
@@ -37,9 +34,7 @@ export function hotelBuilder(options: HotelOptions): SchemaBuilder {
|
|
|
37
34
|
checkoutTime: data.checkoutTime || defaultCheckoutTime,
|
|
38
35
|
openingHoursSpecification: generateOpeningHours(data.openingHours || { alwaysOpen: true }),
|
|
39
36
|
sameAs: data.website ? [data.website] : undefined,
|
|
40
|
-
|
|
41
|
-
url: ctx.route('hotel', data.slug),
|
|
42
|
-
}, data.author);
|
|
37
|
+
});
|
|
43
38
|
};
|
|
44
39
|
}
|
|
45
40
|
|
|
@@ -56,11 +51,7 @@ export interface ActivityOptions {
|
|
|
56
51
|
/** TouristAttraction at route `activity`; the street falls back to `meetingPoint`. */
|
|
57
52
|
export function activityBuilder(options: ActivityOptions): SchemaBuilder {
|
|
58
53
|
const { address, touristType = 'Cultural', defaultLanguages, offers = false, currency = 'EUR' } = options;
|
|
59
|
-
return (data, ctx) =>
|
|
60
|
-
'@type': 'TouristAttraction',
|
|
61
|
-
name: data.name,
|
|
62
|
-
description: data.description,
|
|
63
|
-
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
54
|
+
return (data, ctx) => placeNode(ctx, data, 'TouristAttraction', 'activity', {
|
|
64
55
|
address: postalAddress(address, data, { street: data.address || data.meetingPoint, postalCode: postalCodeOf(data) }),
|
|
65
56
|
geo: geoCoordinates(data.coordinates),
|
|
66
57
|
touristType,
|
|
@@ -69,15 +60,8 @@ export function activityBuilder(options: ActivityOptions): SchemaBuilder {
|
|
|
69
60
|
availableLanguage: data.languages || defaultLanguages,
|
|
70
61
|
telephone: data.phone,
|
|
71
62
|
aggregateRating: aggregateRating(data),
|
|
72
|
-
offers: offers && data.priceFrom !== undefined ?
|
|
73
|
-
|
|
74
|
-
price: data.priceFrom,
|
|
75
|
-
priceCurrency: currency,
|
|
76
|
-
availability: 'https://schema.org/InStock',
|
|
77
|
-
} : undefined,
|
|
78
|
-
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
79
|
-
url: ctx.route('activity', data.slug),
|
|
80
|
-
}, data.author);
|
|
63
|
+
offers: offers && data.priceFrom !== undefined ? offer(data.priceFrom, currency) : undefined,
|
|
64
|
+
});
|
|
81
65
|
}
|
|
82
66
|
|
|
83
67
|
export interface TouristTripOptions {
|
|
@@ -86,22 +70,15 @@ export interface TouristTripOptions {
|
|
|
86
70
|
currency?: string;
|
|
87
71
|
}
|
|
88
72
|
|
|
89
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* A free self-guided TouristTrip at route `walk`, its stops as the itinerary.
|
|
75
|
+
* @deprecated Tourism-only: import `touristTripBuilder` from `@sarimarcus/content-sites-tourism/schema` (SLA-2918).
|
|
76
|
+
*/
|
|
90
77
|
export function touristTripBuilder(options: TouristTripOptions = {}): SchemaBuilder {
|
|
91
78
|
const { touristType = 'Cultural', offerDescription = 'Self-guided walking tour — free', currency = 'EUR' } = options;
|
|
92
|
-
return (data, ctx) =>
|
|
93
|
-
'@type': 'TouristTrip',
|
|
94
|
-
name: data.name,
|
|
95
|
-
description: data.description,
|
|
96
|
-
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
79
|
+
return (data, ctx) => placeNode(ctx, data, 'TouristTrip', 'walk', {
|
|
97
80
|
touristType: data.theme || touristType,
|
|
98
|
-
offers: {
|
|
99
|
-
'@type': 'Offer',
|
|
100
|
-
price: 0,
|
|
101
|
-
priceCurrency: currency,
|
|
102
|
-
availability: 'https://schema.org/InStock',
|
|
103
|
-
description: offerDescription,
|
|
104
|
-
},
|
|
81
|
+
offers: offer(0, currency, { description: offerDescription }),
|
|
105
82
|
itinerary: {
|
|
106
83
|
'@type': 'ItemList',
|
|
107
84
|
numberOfItems: data.stops?.length || 0,
|
|
@@ -117,7 +94,5 @@ export function touristTripBuilder(options: TouristTripOptions = {}): SchemaBuil
|
|
|
117
94
|
},
|
|
118
95
|
})),
|
|
119
96
|
},
|
|
120
|
-
|
|
121
|
-
url: ctx.route('walk', data.slug),
|
|
122
|
-
}, data.author);
|
|
97
|
+
});
|
|
123
98
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createSchemaContext } from './context.ts';
|
|
2
|
+
import { assembleGraph, finalizeGraph, type AssembleOptions, type PageInput } from './graph.ts';
|
|
3
|
+
import type { JsonLdNode, SchemaBuilders, SchemaContext, SchemaContextInput, SchemaEntry } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/** A site's JSON-LD policy: its context options, base nodes, builders and graph hooks. */
|
|
6
|
+
export interface SchemaPolicy {
|
|
7
|
+
context?: Partial<SchemaContextInput>;
|
|
8
|
+
nodes(ctx: SchemaContext): { organization: JsonLdNode; website: JsonLdNode };
|
|
9
|
+
builders: SchemaBuilders;
|
|
10
|
+
hooks?(ctx: SchemaContext, entries: SchemaEntry[]): Pick<AssembleOptions, 'beforeEntries' | 'afterEntries'>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One page's finalised JSON-LD under a site policy: context, base nodes, entries, hooks. The site adapter and the
|
|
15
|
+
* factory schema goldens both render through this, so the goldens test the wiring that ships.
|
|
16
|
+
*/
|
|
17
|
+
export function renderSchemaPolicy(input: SchemaContextInput, policy: SchemaPolicy, page: PageInput): JsonLdNode {
|
|
18
|
+
const ctx = createSchemaContext({ ...input, ...policy.context });
|
|
19
|
+
return finalizeGraph(ctx, assembleGraph(ctx, {
|
|
20
|
+
...policy.nodes(ctx),
|
|
21
|
+
builders: policy.builders,
|
|
22
|
+
...page,
|
|
23
|
+
...policy.hooks?.(ctx, page.entries),
|
|
24
|
+
}));
|
|
25
|
+
}
|
package/src/schema/values.ts
CHANGED
|
@@ -8,6 +8,14 @@ export function parsePriceFromString(priceStr: string | undefined): number | nul
|
|
|
8
8
|
return match ? parseFloat(match[1]) : null;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Free entry: a `free` tag, or "free" in the price text (`price` for events, `fees` for places). `undefined` when
|
|
13
|
+
* neither is present, so the key drops out of the JSON-LD.
|
|
14
|
+
*/
|
|
15
|
+
export function isFreeEntry(data: Record<string, any>, field: 'price' | 'fees' = 'price'): boolean | undefined {
|
|
16
|
+
return data.tags?.includes('free') || data[field]?.toLowerCase().includes('free');
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
|
|
12
20
|
export function cleanStreetAddress(fullAddress: string | undefined): string {
|
|
13
21
|
if (!fullAddress) return '';
|
package/src/url/index.ts
CHANGED
|
@@ -6,3 +6,5 @@ export { buildImageMap, keyBySlug } from './imageMap.ts';
|
|
|
6
6
|
export { articleFeedItem } from './feed.ts';
|
|
7
7
|
export type { FeedArticle, FeedItem } from './feed.ts';
|
|
8
8
|
export { pageTypeOf } from './pageType.ts';
|
|
9
|
+
export { outboundClassifier, outboundHostKey } from './outbound.ts';
|
|
10
|
+
export type { OutboundDomainStore } from './outbound.ts';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The outbound-domains store as synced into each site (`src/data/outbound-domains.json`). */
|
|
2
|
+
export interface OutboundDomainStore {
|
|
3
|
+
domains: Record<string, { class: string }>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Host key of the store: lowercase, without `www.`. */
|
|
7
|
+
export function outboundHostKey(host: string): string {
|
|
8
|
+
return host.toLowerCase().replace(/^www\./, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* URL → its Rule 38 class (`competitor`, `affiliate`, `official`, …) from the store, or a plain host → class map;
|
|
13
|
+
* `other` for an unlisted host or a URL that does not parse. Lookup only: each consumer decides what a class renders.
|
|
14
|
+
*/
|
|
15
|
+
export function outboundClassifier(store: OutboundDomainStore | Record<string, string>): (url: string) => string {
|
|
16
|
+
const entries = 'domains' in store && typeof store.domains === 'object'
|
|
17
|
+
? Object.entries((store as OutboundDomainStore).domains).map(([d, e]) => [d, e.class] as const)
|
|
18
|
+
: Object.entries(store as Record<string, string>);
|
|
19
|
+
const classes = new Map(entries.map(([d, c]) => [outboundHostKey(d), c]));
|
|
20
|
+
return (url) => {
|
|
21
|
+
try {
|
|
22
|
+
return classes.get(outboundHostKey(new URL(url).hostname)) ?? 'other';
|
|
23
|
+
} catch {
|
|
24
|
+
return 'other';
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|