@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 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 `images` (`src/internal` is never exported). No `.astro`
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
+ }
@@ -0,0 +1,2 @@
1
+ export { selectFeaturedArticles, FEATURED_SHELF_SIZE, FEATURED_MAX_AGE_DAYS } from './featured.ts';
2
+ export type { FeaturedSnapshot, FeaturedSelection } from './featured.ts';
@@ -0,0 +1,2 @@
1
+ // Public surface of @sarimarcus/content-sites-core/articles.
2
+ export { selectFeaturedArticles, FEATURED_SHELF_SIZE, FEATURED_MAX_AGE_DAYS } from "./featured.js";
@@ -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.27.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
+ }
@@ -0,0 +1,3 @@
1
+ // Public surface of @sarimarcus/content-sites-core/articles.
2
+ export { selectFeaturedArticles, FEATURED_SHELF_SIZE, FEATURED_MAX_AGE_DAYS } from './featured.ts';
3
+ export type { FeaturedSnapshot, FeaturedSelection } from './featured.ts';
@@ -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
  }