@sarimarcus/content-sites-core 0.27.0 → 0.28.1
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/README.md +1 -1
- package/dist/articles/featured.d.ts +32 -0
- package/dist/articles/featured.js +35 -0
- package/dist/articles/index.d.ts +2 -0
- package/dist/articles/index.js +2 -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 +5 -1
- package/src/articles/featured.ts +57 -0
- package/src/articles/index.ts +3 -0
- 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.28.1 (2026-10-06)
|
|
6
|
+
|
|
7
|
+
- `./schema` structured data no longer publishes false facts (SLA-2983):
|
|
8
|
+
- `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.
|
|
9
|
+
- `aggregateRating` returns nothing without a real `ratingCount`, instead of inventing `reviewCount: 1`.
|
|
10
|
+
- `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/`.
|
|
11
|
+
Output changes on the affected pages only; no API change, no migration.
|
|
12
|
+
|
|
13
|
+
## 0.28.0 (2026-10-05)
|
|
14
|
+
|
|
15
|
+
- `./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).
|
|
16
|
+
|
|
5
17
|
## 0.27.0 (2026-10-05)
|
|
6
18
|
|
|
7
19
|
- No changes; lockstep release with ui (SLA-2975).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Non-visual code shared by the content sites: generic utilities, Astro config bui
|
|
|
4
4
|
and the type contracts `ui` and `tourism` build on (for example the card item and tag-link shapes that
|
|
5
5
|
today's ui components import from tourism code).
|
|
6
6
|
|
|
7
|
-
Named entry points: `url`, `affiliate`, `dates`, `geo`, `dom`, `text`, `config`, `types`, `schema`, `interludes` and `
|
|
7
|
+
Named entry points: `url`, `affiliate`, `dates`, `geo`, `dom`, `text`, `config`, `types`, `schema`, `interludes`, `images` and `articles` (`src/internal` is never exported). No `.astro`
|
|
8
8
|
files. Lowest layer: imports nothing from the other packages; its one runtime dependency is `marked` (`readingTime`). Ownership per path is in
|
|
9
9
|
`.planning/platform/catalogue.json` (owner `core`).
|
|
10
10
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Articles-hub selection from a committed trending snapshot (SLA-2972).
|
|
3
|
+
*
|
|
4
|
+
* The factory writes `src/data/featured-articles.json` from GSC data; the hub never reads GSC.
|
|
5
|
+
* A snapshot older than `maxAgeDays`, or one naming no current article, falls back to the
|
|
6
|
+
* newest article, so the hero cannot freeze when nobody refreshes it.
|
|
7
|
+
*/
|
|
8
|
+
export interface FeaturedSnapshot {
|
|
9
|
+
generatedAt: string;
|
|
10
|
+
featured: {
|
|
11
|
+
slug: string;
|
|
12
|
+
}[];
|
|
13
|
+
}
|
|
14
|
+
export interface FeaturedSelection<T> {
|
|
15
|
+
hero: T | null;
|
|
16
|
+
shelf: T[];
|
|
17
|
+
/** Every other article, in the order given: the paginated "Latest" grid. */
|
|
18
|
+
rest: T[];
|
|
19
|
+
source: 'trending' | 'latest';
|
|
20
|
+
}
|
|
21
|
+
export declare const FEATURED_SHELF_SIZE = 3;
|
|
22
|
+
export declare const FEATURED_MAX_AGE_DAYS = 21;
|
|
23
|
+
/**
|
|
24
|
+
* Split newest-first articles into the hero, the trending shelf and the rest.
|
|
25
|
+
* Pass `buildNow()` as `now` so two pages of one build agree.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectFeaturedArticles<T>(sorted: T[], snapshot: FeaturedSnapshot | null | undefined, options: {
|
|
28
|
+
slugOf: (article: T) => string;
|
|
29
|
+
now: Date;
|
|
30
|
+
shelfSize?: number;
|
|
31
|
+
maxAgeDays?: number;
|
|
32
|
+
}): FeaturedSelection<T>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Articles-hub selection from a committed trending snapshot (SLA-2972).
|
|
3
|
+
*
|
|
4
|
+
* The factory writes `src/data/featured-articles.json` from GSC data; the hub never reads GSC.
|
|
5
|
+
* A snapshot older than `maxAgeDays`, or one naming no current article, falls back to the
|
|
6
|
+
* newest article, so the hero cannot freeze when nobody refreshes it.
|
|
7
|
+
*/
|
|
8
|
+
export const FEATURED_SHELF_SIZE = 3;
|
|
9
|
+
export const FEATURED_MAX_AGE_DAYS = 21;
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
function snapshotAgeDays(snapshot, now) {
|
|
12
|
+
const generated = Date.parse(`${snapshot.generatedAt}T00:00:00Z`);
|
|
13
|
+
return Number.isNaN(generated) ? Infinity : Math.floor((now.getTime() - generated) / DAY_MS);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Split newest-first articles into the hero, the trending shelf and the rest.
|
|
17
|
+
* Pass `buildNow()` as `now` so two pages of one build agree.
|
|
18
|
+
*/
|
|
19
|
+
export function selectFeaturedArticles(sorted, snapshot, options) {
|
|
20
|
+
const { slugOf, now, shelfSize = FEATURED_SHELF_SIZE, maxAgeDays = FEATURED_MAX_AGE_DAYS } = options;
|
|
21
|
+
const bySlug = new Map(sorted.map((a) => [slugOf(a), a]));
|
|
22
|
+
const fresh = snapshot && Array.isArray(snapshot.featured) && snapshotAgeDays(snapshot, now) <= maxAgeDays;
|
|
23
|
+
const picked = fresh
|
|
24
|
+
? [...new Set(snapshot.featured.map((f) => f?.slug))].flatMap((slug) => {
|
|
25
|
+
const article = typeof slug === 'string' ? bySlug.get(slug) : undefined;
|
|
26
|
+
return article ? [article] : [];
|
|
27
|
+
}).slice(0, 1 + shelfSize)
|
|
28
|
+
: [];
|
|
29
|
+
if (picked.length === 0) {
|
|
30
|
+
const [hero = null, ...rest] = sorted;
|
|
31
|
+
return { hero, shelf: [], rest, source: 'latest' };
|
|
32
|
+
}
|
|
33
|
+
const taken = new Set(picked);
|
|
34
|
+
return { hero: picked[0], shelf: picked.slice(1), rest: sorted.filter((a) => !taken.has(a)), source: 'trending' };
|
|
35
|
+
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
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",
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
"types": "./dist/dom/index.d.ts",
|
|
51
51
|
"default": "./dist/dom/index.js"
|
|
52
52
|
},
|
|
53
|
+
"./articles": {
|
|
54
|
+
"types": "./dist/articles/index.d.ts",
|
|
55
|
+
"default": "./dist/articles/index.js"
|
|
56
|
+
},
|
|
53
57
|
"./text": {
|
|
54
58
|
"types": "./dist/text/index.d.ts",
|
|
55
59
|
"default": "./dist/text/index.js"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Articles-hub selection from a committed trending snapshot (SLA-2972).
|
|
3
|
+
*
|
|
4
|
+
* The factory writes `src/data/featured-articles.json` from GSC data; the hub never reads GSC.
|
|
5
|
+
* A snapshot older than `maxAgeDays`, or one naming no current article, falls back to the
|
|
6
|
+
* newest article, so the hero cannot freeze when nobody refreshes it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface FeaturedSnapshot {
|
|
10
|
+
generatedAt: string;
|
|
11
|
+
featured: { slug: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FeaturedSelection<T> {
|
|
15
|
+
hero: T | null;
|
|
16
|
+
shelf: T[];
|
|
17
|
+
/** Every other article, in the order given: the paginated "Latest" grid. */
|
|
18
|
+
rest: T[];
|
|
19
|
+
source: 'trending' | 'latest';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const FEATURED_SHELF_SIZE = 3;
|
|
23
|
+
export const FEATURED_MAX_AGE_DAYS = 21;
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 86_400_000;
|
|
26
|
+
|
|
27
|
+
function snapshotAgeDays(snapshot: FeaturedSnapshot, now: Date): number {
|
|
28
|
+
const generated = Date.parse(`${snapshot.generatedAt}T00:00:00Z`);
|
|
29
|
+
return Number.isNaN(generated) ? Infinity : Math.floor((now.getTime() - generated) / DAY_MS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split newest-first articles into the hero, the trending shelf and the rest.
|
|
34
|
+
* Pass `buildNow()` as `now` so two pages of one build agree.
|
|
35
|
+
*/
|
|
36
|
+
export function selectFeaturedArticles<T>(
|
|
37
|
+
sorted: T[],
|
|
38
|
+
snapshot: FeaturedSnapshot | null | undefined,
|
|
39
|
+
options: { slugOf: (article: T) => string; now: Date; shelfSize?: number; maxAgeDays?: number },
|
|
40
|
+
): FeaturedSelection<T> {
|
|
41
|
+
const { slugOf, now, shelfSize = FEATURED_SHELF_SIZE, maxAgeDays = FEATURED_MAX_AGE_DAYS } = options;
|
|
42
|
+
const bySlug = new Map(sorted.map((a) => [slugOf(a), a]));
|
|
43
|
+
const fresh = snapshot && Array.isArray(snapshot.featured) && snapshotAgeDays(snapshot, now) <= maxAgeDays;
|
|
44
|
+
const picked = fresh
|
|
45
|
+
? [...new Set(snapshot.featured.map((f) => f?.slug))].flatMap((slug) => {
|
|
46
|
+
const article = typeof slug === 'string' ? bySlug.get(slug) : undefined;
|
|
47
|
+
return article ? [article] : [];
|
|
48
|
+
}).slice(0, 1 + shelfSize)
|
|
49
|
+
: [];
|
|
50
|
+
|
|
51
|
+
if (picked.length === 0) {
|
|
52
|
+
const [hero = null, ...rest] = sorted;
|
|
53
|
+
return { hero, shelf: [], rest, source: 'latest' };
|
|
54
|
+
}
|
|
55
|
+
const taken = new Set<T>(picked);
|
|
56
|
+
return { hero: picked[0], shelf: picked.slice(1), rest: sorted.filter((a) => !taken.has(a)), source: 'trending' };
|
|
57
|
+
}
|
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
|
}
|