@sarimarcus/content-sites-core 0.22.1 → 0.22.2

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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.22.2 (2026-10-04)
6
+
7
+ - `./schema`: `offer`, `placeNode`, `siteOrganizationNode`, `siteWebsiteNode`, `isFreeEntry` and `renderSchemaPolicy` (with the `SchemaPolicy` type) replace node skeletons repeated across builders and site adapters (SLA-2918).
8
+ - `./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).
9
+ - `./url`: `outboundClassifier` and `outboundHostKey`, the one Rule 38 host lookup; `./config` `outboundLinkRel` uses it (SLA-2918).
10
+
11
+ ### Deprecated
12
+
13
+ - `./schema` `touristTripBuilder` and `TouristTripOptions`: tourism-only; import them from `@sarimarcus/content-sites-tourism/schema`. Removed in the next minor (SLA-2918).
14
+
5
15
  ## 0.22.1 (2026-10-04)
6
16
 
7
17
  - No changes; lockstep release with ui (SLA-2920).
@@ -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 store = JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8'));
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
- let host;
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++;
@@ -1,17 +1,19 @@
1
1
  import { createPersonSchema } from "./context.js";
2
- import { geoCoordinates, postalAddress } from "./values.js";
2
+ import { offer } from "./nodes.js";
3
+ import { geoCoordinates, isFreeEntry, parsePriceFromString, postalAddress } from "./values.js";
3
4
  function eventPrice(data) {
4
- const isFree = data.tags?.includes('free') || data.price?.toLowerCase().includes('free');
5
- const priceMatch = data.price?.match(/(\d+)/);
6
- return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
5
+ const isFree = isFreeEntry(data);
6
+ return { isFree, price: isFree ? 0 : parsePriceFromString(data.price) };
7
7
  }
8
- // Offers open three months before the start date.
9
- function validFrom(startDate) {
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 from = new Date(new Date(startDate));
13
- from.setMonth(from.getMonth() - 3);
14
- return from.toISOString();
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 from = validFrom(data.startDate);
58
+ const validFrom = offerValidFrom(data.startDate);
36
59
  const pageRoute = ctx.route('event', data.slug);
37
- const singleOffer = price !== null ? {
38
- '@type': 'Offer',
39
- price,
40
- priceCurrency: currency,
41
- availability: 'https://schema.org/InStock',
42
- url: data.officialWebsite || pageRoute,
43
- validFrom: from,
44
- } : undefined;
45
- const event = {
46
- '@type': 'Event',
47
- name: data.name,
48
- description: data.description,
49
- image: ctx.resolveImageUrl(data.image, data.slug),
50
- startDate: data.startDate || undefined,
51
- endDate: data.endDate || undefined,
52
- previousStartDate: data.previousStartDate || undefined,
53
- eventStatus: EVENT_STATUS(data.previousStartDate),
54
- eventAttendanceMode: OFFLINE,
55
- isAccessibleForFree: isFree,
56
- location: eventLocation(options, data),
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
- performer: options.performer(data),
59
- offers: offersArray && (data.offers?.length ?? 0) > 0
60
- ? data.offers.map((o) => ({
61
- '@type': 'Offer',
62
- price: o.price,
63
- priceCurrency: o.priceCurrency || currency,
64
- availability: `https://schema.org/${o.availability || 'InStock'}`,
65
- url: o.url || data.officialWebsite || pageRoute,
66
- validFrom: from,
67
- }))
68
- : singleOffer,
69
- mainEntityOfPage: { '@id': ctx.pageUrl },
70
- url: pageRoute,
71
- };
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
- '@type': 'Event',
114
- name: item.name,
115
- description: item.description,
116
- image: ctx.resolveImageUrl(item.image, item.slug),
117
- url: ctx.abs(item.url),
118
- startDate: item.startDate,
119
- endDate: item.endDate,
120
- previousStartDate: item.previousStartDate || undefined,
121
- eventStatus: EVENT_STATUS(item.previousStartDate),
122
- eventAttendanceMode: OFFLINE,
123
- isAccessibleForFree: isFree,
124
- location: eventLocation(options, item),
107
+ item: eventNode(ctx, options, item, {
108
+ head: { url: ctx.abs(item.url) },
109
+ isFree,
125
110
  organizer: options.itemOrganizer(item),
126
- performer,
127
- offers: price !== null ? {
128
- '@type': 'Offer',
129
- price,
130
- priceCurrency: currency,
131
- availability: 'https://schema.org/InStock',
132
- url: item.officialWebsite || ctx.route('event', item.slug),
133
- validFrom: from,
134
- } : undefined,
135
- },
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
  });
@@ -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';
@@ -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
+ }
@@ -23,5 +23,8 @@ export interface TouristTripOptions {
23
23
  offerDescription?: string;
24
24
  currency?: string;
25
25
  }
26
- /** A free self-guided TouristTrip at route `walk`, its stops as the itinerary. */
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;
@@ -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 withAuthorPersonSchema(ctx, {
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
- mainEntityOfPage: { '@id': ctx.pageUrl },
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) => withAuthorPersonSchema(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
- '@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);
41
+ offers: offers && data.priceFrom !== undefined ? offer(data.priceFrom, currency) : undefined,
42
+ });
59
43
  }
60
- /** A free self-guided TouristTrip at route `walk`, its stops as the itinerary. */
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) => withAuthorPersonSchema(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
- mainEntityOfPage: { '@id': ctx.pageUrl },
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
+ }
@@ -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. */
@@ -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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-core",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 store = JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8')) as {
48
- domains: Record<string, { class: string }>;
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
- let host: string;
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[] = [];
@@ -1,5 +1,6 @@
1
1
  import { createPersonSchema } from './context.ts';
2
- import { geoCoordinates, postalAddress, type AddressPolicy } from './values.ts';
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 = data.tags?.includes('free') || data.price?.toLowerCase().includes('free');
19
- const priceMatch = data.price?.match(/(\d+)/);
20
- return { isFree, price: isFree ? 0 : (priceMatch ? parseFloat(priceMatch[1]) : null) };
19
+ const isFree = isFreeEntry(data);
20
+ return { isFree, price: isFree ? 0 : parsePriceFromString(data.price) };
21
21
  }
22
22
 
23
- // Offers open three months before the start date.
24
- function validFrom(startDate: string | undefined): string | null {
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 from = new Date(new Date(startDate));
27
- from.setMonth(from.getMonth() - 3);
28
- return from.toISOString();
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 from = validFrom(data.startDate);
97
+ const validFrom = offerValidFrom(data.startDate);
61
98
  const pageRoute = ctx.route('event', data.slug);
62
- const singleOffer = price !== null ? {
63
- '@type': 'Offer',
64
- price,
65
- priceCurrency: currency,
66
- availability: 'https://schema.org/InStock',
67
- url: data.officialWebsite || pageRoute,
68
- validFrom: from,
69
- } : undefined;
70
-
71
- const event: JsonLdNode = {
72
- '@type': 'Event',
73
- name: data.name,
74
- description: data.description,
75
- image: ctx.resolveImageUrl(data.image, data.slug),
76
- startDate: data.startDate || undefined,
77
- endDate: data.endDate || undefined,
78
- previousStartDate: data.previousStartDate || undefined,
79
- eventStatus: EVENT_STATUS(data.previousStartDate),
80
- eventAttendanceMode: OFFLINE,
81
- isAccessibleForFree: isFree,
82
- location: eventLocation(options, data),
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
- performer: options.performer(data),
85
- offers: offersArray && (data.offers?.length ?? 0) > 0
86
- ? data.offers.map((o: { price: number; priceCurrency?: string; url?: string; availability?: string }) => ({
87
- '@type': 'Offer',
88
- price: o.price,
89
- priceCurrency: o.priceCurrency || currency,
90
- availability: `https://schema.org/${o.availability || 'InStock'}`,
91
- url: o.url || data.officialWebsite || pageRoute,
92
- validFrom: from,
93
- }))
94
- : singleOffer,
95
- mainEntityOfPage: { '@id': ctx.pageUrl },
96
- url: pageRoute,
97
- };
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
- '@type': 'Event',
150
- name: item.name,
151
- description: item.description,
152
- image: ctx.resolveImageUrl(item.image, item.slug),
153
- url: ctx.abs(item.url),
154
- startDate: item.startDate,
155
- endDate: item.endDate,
156
- previousStartDate: item.previousStartDate || undefined,
157
- eventStatus: EVENT_STATUS(item.previousStartDate),
158
- eventAttendanceMode: OFFLINE,
159
- isAccessibleForFree: isFree,
160
- location: eventLocation(options, item),
158
+ item: eventNode(ctx, options, item, {
159
+ head: { url: ctx.abs(item.url) },
160
+ isFree,
161
161
  organizer: options.itemOrganizer(item),
162
- performer,
163
- offers: price !== null ? {
164
- '@type': 'Offer',
165
- price,
166
- priceCurrency: currency,
167
- availability: 'https://schema.org/InStock',
168
- url: item.officialWebsite || ctx.route('event', item.slug),
169
- validFrom: from,
170
- } : undefined,
171
- },
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
  });
@@ -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
+ }
@@ -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 withAuthorPersonSchema(ctx, {
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
- mainEntityOfPage: { '@id': ctx.pageUrl },
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) => withAuthorPersonSchema(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
- '@type': 'Offer',
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
- /** A free self-guided TouristTrip at route `walk`, its stops as the itinerary. */
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) => withAuthorPersonSchema(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
- mainEntityOfPage: { '@id': ctx.pageUrl },
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
+ }
@@ -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
+ }