@sarimarcus/content-sites-core 0.28.0 → 0.29.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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.29.0 (2026-10-06)
6
+
7
+ - No changes; lockstep release with ui and tourism (SLA-2993, SLA-2995).
8
+
9
+ ## 0.28.1 (2026-10-06)
10
+
11
+ - `./schema` structured data no longer publishes false facts (SLA-2983):
12
+ - `isFreeEntry` / `parsePriceFromString`: price or fee text is free only when it leads with "free" and names no amount and no condition. Paid text with a free concession ("€15 general … free under 10") gives `isAccessibleForFree: false` and its first price; a conditional leading "free" ("Free for EU citizens; €4.50 for others") omits `isAccessibleForFree` and gives no price, so an event emits no Offer priced at a side cost. A `free` tag still wins.
13
+ - `aggregateRating` returns nothing without a real `ratingCount`, instead of inventing `reviewCount: 1`.
14
+ - `itemListBuilder` and touristTrip stop URLs resolve through the RouteMap (`ctx.route`), so Dark Skies lists link `/lodges/` and `/events/` instead of the redirecting `/hotel/` and `/event/`.
15
+ Output changes on the affected pages only; no API change, no migration.
16
+
5
17
  ## 0.28.0 (2026-10-05)
6
18
 
7
19
  - `./articles`: `selectFeaturedArticles(sorted, snapshot, { slugOf, now, shelfSize = 3, maxAgeDays = 21 })` splits a newest-first article list into a hero, a trending shelf and the rest, from the committed `src/data/featured-articles.json` snapshot the factory writes from GSC data. A snapshot older than `maxAgeDays`, or naming no current article, falls back to the newest article as hero with no shelf. It replaces the sites' `getFeaturedAndRest` copies (SLA-2972).
@@ -28,7 +28,7 @@ export interface ItemListOptions {
28
28
  /** `data.itemType` → schema type. Given: each item is a typed nested entity; absent: name/url on the ListItem. */
29
29
  itemTypes?: Record<string, string>;
30
30
  }
31
- /** ItemList of `data.items` linked at `/${data.itemType}/${slug}`. */
31
+ /** ItemList of `data.items` linked at the `data.itemType` route. */
32
32
  export declare function itemListBuilder(options?: ItemListOptions): SchemaBuilder;
33
33
  export declare function howToBuilder(): SchemaBuilder;
34
34
  export interface AboutPageOptions {
@@ -108,7 +108,7 @@ export function collectionPageBuilder(options = {}) {
108
108
  };
109
109
  };
110
110
  }
111
- /** ItemList of `data.items` linked at `/${data.itemType}/${slug}`. */
111
+ /** ItemList of `data.items` linked at the `data.itemType` route. */
112
112
  export function itemListBuilder(options = {}) {
113
113
  const { itemTypes } = options;
114
114
  return (data, ctx) => ({
@@ -117,7 +117,7 @@ export function itemListBuilder(options = {}) {
117
117
  description: data.description,
118
118
  numberOfItems: data.items?.length || 0,
119
119
  itemListElement: data.items?.map((item, index) => {
120
- const url = ctx.abs(`/${data.itemType}/${item.slug}`);
120
+ const url = ctx.route(data.itemType, item.slug);
121
121
  return itemTypes
122
122
  ? { '@type': 'ListItem', position: index + 1, item: { '@type': itemTypes[data.itemType] ?? 'WebPage', name: item.name, url } }
123
123
  : { '@type': 'ListItem', position: index + 1, name: item.name, url };
@@ -61,7 +61,7 @@ export function touristTripBuilder(options = {}) {
61
61
  name: stop.name,
62
62
  description: stop.description,
63
63
  geo: geoCoordinates(stop.coordinates),
64
- ...(stop.slug && stop.type !== 'landmark' ? { url: ctx.abs(`/${stop.type}/${stop.slug}`) } : {}),
64
+ ...(stop.slug && stop.type !== 'landmark' ? { url: ctx.route(stop.type, stop.slug) } : {}),
65
65
  },
66
66
  })),
67
67
  },
@@ -1,9 +1,13 @@
1
1
  import type { JsonLdNode } from './types.ts';
2
- /** First integer in a price string; any "free" reads as 0; null when there is no number. */
2
+ /**
3
+ * First integer in a price string; 0 when `isFreeText`; null when there is no number, or when the text leads with a
4
+ * conditional "free", whose first number is a side cost ("Free (…). Shuttle: €2"), not the price.
5
+ */
3
6
  export declare function parsePriceFromString(priceStr: string | undefined): number | null;
4
7
  /**
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.
8
+ * Free entry: a `free` tag, or price text that `isFreeText` accepts (`price` for events, `fees` for places).
9
+ * `undefined`, so the key drops out of the JSON-LD, when neither is present or the text leads with a conditional
10
+ * "free" ("Free for EU citizens; …"), which proves neither.
7
11
  */
8
12
  export declare function isFreeEntry(data: Record<string, any>, field?: 'price' | 'fees'): boolean | undefined;
9
13
  /** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
@@ -41,6 +45,7 @@ export declare function geoCoordinates(coordinates: {
41
45
  lat: number;
42
46
  lng: number;
43
47
  } | undefined): JsonLdNode | undefined;
48
+ /** Only with a real `ratingCount`: a rating without a count is an invalid AggregateRating, and inventing one is false. */
44
49
  export declare function aggregateRating(data: {
45
50
  rating?: number;
46
51
  ratingCount?: number;
@@ -1,18 +1,42 @@
1
- /** First integer in a price string; any "free" reads as 0; null when there is no number. */
1
+ const LEADING_FREE = /^\s*free\b/i;
2
+ const FREE_QUALIFIER = /\b(or|for|with|at|except|paid|fees?|charges?|priced|included|rates?|applies|apply)\b/i;
3
+ /**
4
+ * Price text that says the whole thing is free: it starts with "free" and names no amount and no condition.
5
+ * "€15 … free under 10" and "Free for EU citizens; €4.50 for others" are not free.
6
+ */
7
+ function isFreeText(priceStr) {
8
+ if (!priceStr || !LEADING_FREE.test(priceStr))
9
+ return false;
10
+ return !/\d/.test(priceStr) && !FREE_QUALIFIER.test(priceStr);
11
+ }
12
+ /**
13
+ * First integer in a price string; 0 when `isFreeText`; null when there is no number, or when the text leads with a
14
+ * conditional "free", whose first number is a side cost ("Free (…). Shuttle: €2"), not the price.
15
+ */
2
16
  export function parsePriceFromString(priceStr) {
3
17
  if (!priceStr)
4
18
  return null;
5
- if (priceStr.toLowerCase().includes('free'))
19
+ if (isFreeText(priceStr))
6
20
  return 0;
21
+ if (LEADING_FREE.test(priceStr))
22
+ return null;
7
23
  const match = priceStr.match(/(\d+)/);
8
24
  return match ? parseFloat(match[1]) : null;
9
25
  }
10
26
  /**
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.
27
+ * Free entry: a `free` tag, or price text that `isFreeText` accepts (`price` for events, `fees` for places).
28
+ * `undefined`, so the key drops out of the JSON-LD, when neither is present or the text leads with a conditional
29
+ * "free" ("Free for EU citizens; …"), which proves neither.
13
30
  */
14
31
  export function isFreeEntry(data, field = 'price') {
15
- return data.tags?.includes('free') || data[field]?.toLowerCase().includes('free');
32
+ if (data.tags?.includes('free'))
33
+ return true;
34
+ const text = data[field];
35
+ if (!text)
36
+ return undefined;
37
+ if (isFreeText(text))
38
+ return true;
39
+ return LEADING_FREE.test(text) ? undefined : false;
16
40
  }
17
41
  /** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
18
42
  export function cleanStreetAddress(fullAddress) {
@@ -82,12 +106,13 @@ export function postalAddress(policy, data, parts = {}) {
82
106
  export function geoCoordinates(coordinates) {
83
107
  return coordinates ? { '@type': 'GeoCoordinates', latitude: coordinates.lat, longitude: coordinates.lng } : undefined;
84
108
  }
109
+ /** Only with a real `ratingCount`: a rating without a count is an invalid AggregateRating, and inventing one is false. */
85
110
  export function aggregateRating(data) {
86
- return data.rating ? {
111
+ return data.rating && data.ratingCount ? {
87
112
  '@type': 'AggregateRating',
88
113
  ratingValue: data.rating,
89
114
  bestRating: 5,
90
115
  worstRating: 1,
91
- reviewCount: data.ratingCount ?? 1,
116
+ reviewCount: data.ratingCount,
92
117
  } : undefined;
93
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-core",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -143,7 +143,7 @@ export interface ItemListOptions {
143
143
  itemTypes?: Record<string, string>;
144
144
  }
145
145
 
146
- /** ItemList of `data.items` linked at `/${data.itemType}/${slug}`. */
146
+ /** ItemList of `data.items` linked at the `data.itemType` route. */
147
147
  export function itemListBuilder(options: ItemListOptions = {}): SchemaBuilder {
148
148
  const { itemTypes } = options;
149
149
  return (data, ctx) => ({
@@ -152,7 +152,7 @@ export function itemListBuilder(options: ItemListOptions = {}): SchemaBuilder {
152
152
  description: data.description,
153
153
  numberOfItems: data.items?.length || 0,
154
154
  itemListElement: data.items?.map((item: any, index: number) => {
155
- const url = ctx.abs(`/${data.itemType}/${item.slug}`);
155
+ const url = ctx.route(data.itemType, item.slug);
156
156
  return itemTypes
157
157
  ? { '@type': 'ListItem', position: index + 1, item: { '@type': itemTypes[data.itemType] ?? 'WebPage', name: item.name, url } }
158
158
  : { '@type': 'ListItem', position: index + 1, name: item.name, url };
@@ -90,7 +90,7 @@ export function touristTripBuilder(options: TouristTripOptions = {}): SchemaBuil
90
90
  name: stop.name,
91
91
  description: stop.description,
92
92
  geo: geoCoordinates(stop.coordinates),
93
- ...(stop.slug && stop.type !== 'landmark' ? { url: ctx.abs(`/${stop.type}/${stop.slug}`) } : {}),
93
+ ...(stop.slug && stop.type !== 'landmark' ? { url: ctx.route(stop.type, stop.slug) } : {}),
94
94
  },
95
95
  })),
96
96
  },
@@ -1,19 +1,40 @@
1
1
  import type { JsonLdNode } from './types.ts';
2
2
 
3
- /** First integer in a price string; any "free" reads as 0; null when there is no number. */
3
+ const LEADING_FREE = /^\s*free\b/i;
4
+ const FREE_QUALIFIER = /\b(or|for|with|at|except|paid|fees?|charges?|priced|included|rates?|applies|apply)\b/i;
5
+
6
+ /**
7
+ * Price text that says the whole thing is free: it starts with "free" and names no amount and no condition.
8
+ * "€15 … free under 10" and "Free for EU citizens; €4.50 for others" are not free.
9
+ */
10
+ function isFreeText(priceStr: string | undefined): boolean {
11
+ if (!priceStr || !LEADING_FREE.test(priceStr)) return false;
12
+ return !/\d/.test(priceStr) && !FREE_QUALIFIER.test(priceStr);
13
+ }
14
+
15
+ /**
16
+ * First integer in a price string; 0 when `isFreeText`; null when there is no number, or when the text leads with a
17
+ * conditional "free", whose first number is a side cost ("Free (…). Shuttle: €2"), not the price.
18
+ */
4
19
  export function parsePriceFromString(priceStr: string | undefined): number | null {
5
20
  if (!priceStr) return null;
6
- if (priceStr.toLowerCase().includes('free')) return 0;
21
+ if (isFreeText(priceStr)) return 0;
22
+ if (LEADING_FREE.test(priceStr)) return null;
7
23
  const match = priceStr.match(/(\d+)/);
8
24
  return match ? parseFloat(match[1]) : null;
9
25
  }
10
26
 
11
27
  /**
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.
28
+ * Free entry: a `free` tag, or price text that `isFreeText` accepts (`price` for events, `fees` for places).
29
+ * `undefined`, so the key drops out of the JSON-LD, when neither is present or the text leads with a conditional
30
+ * "free" ("Free for EU citizens; …"), which proves neither.
14
31
  */
15
32
  export function isFreeEntry(data: Record<string, any>, field: 'price' | 'fees' = 'price'): boolean | undefined {
16
- return data.tags?.includes('free') || data[field]?.toLowerCase().includes('free');
33
+ if (data.tags?.includes('free')) return true;
34
+ const text = data[field];
35
+ if (!text) return undefined;
36
+ if (isFreeText(text)) return true;
37
+ return LEADING_FREE.test(text) ? undefined : false;
17
38
  }
18
39
 
19
40
  /** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
@@ -99,12 +120,13 @@ export function geoCoordinates(coordinates: { lat: number; lng: number } | undef
99
120
  return coordinates ? { '@type': 'GeoCoordinates', latitude: coordinates.lat, longitude: coordinates.lng } : undefined;
100
121
  }
101
122
 
123
+ /** Only with a real `ratingCount`: a rating without a count is an invalid AggregateRating, and inventing one is false. */
102
124
  export function aggregateRating(data: { rating?: number; ratingCount?: number }): JsonLdNode | undefined {
103
- return data.rating ? {
125
+ return data.rating && data.ratingCount ? {
104
126
  '@type': 'AggregateRating',
105
127
  ratingValue: data.rating,
106
128
  bestRating: 5,
107
129
  worstRating: 1,
108
- reviewCount: data.ratingCount ?? 1,
130
+ reviewCount: data.ratingCount,
109
131
  } : undefined;
110
132
  }