@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 +12 -0
- package/dist/schema/pages.d.ts +1 -1
- package/dist/schema/pages.js +2 -2
- package/dist/schema/places.js +1 -1
- package/dist/schema/values.d.ts +8 -3
- package/dist/schema/values.js +32 -7
- package/package.json +1 -1
- package/src/schema/pages.ts +2 -2
- package/src/schema/places.ts +1 -1
- package/src/schema/values.ts +29 -7
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).
|
package/dist/schema/pages.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/schema/pages.js
CHANGED
|
@@ -108,7 +108,7 @@ export function collectionPageBuilder(options = {}) {
|
|
|
108
108
|
};
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
|
-
/** ItemList of `data.items` linked at
|
|
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.
|
|
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 };
|
package/dist/schema/places.js
CHANGED
|
@@ -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.
|
|
64
|
+
...(stop.slug && stop.type !== 'landmark' ? { url: ctx.route(stop.type, stop.slug) } : {}),
|
|
65
65
|
},
|
|
66
66
|
})),
|
|
67
67
|
},
|
package/dist/schema/values.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { JsonLdNode } from './types.ts';
|
|
2
|
-
/**
|
|
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
|
|
6
|
-
*
|
|
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;
|
package/dist/schema/values.js
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
|
116
|
+
reviewCount: data.ratingCount,
|
|
92
117
|
} : undefined;
|
|
93
118
|
}
|
package/package.json
CHANGED
package/src/schema/pages.ts
CHANGED
|
@@ -143,7 +143,7 @@ export interface ItemListOptions {
|
|
|
143
143
|
itemTypes?: Record<string, string>;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/** ItemList of `data.items` linked at
|
|
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.
|
|
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 };
|
package/src/schema/places.ts
CHANGED
|
@@ -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.
|
|
93
|
+
...(stop.slug && stop.type !== 'landmark' ? { url: ctx.route(stop.type, stop.slug) } : {}),
|
|
94
94
|
},
|
|
95
95
|
})),
|
|
96
96
|
},
|
package/src/schema/values.ts
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
import type { JsonLdNode } from './types.ts';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
130
|
+
reviewCount: data.ratingCount,
|
|
109
131
|
} : undefined;
|
|
110
132
|
}
|