@sarimarcus/content-sites-core 0.26.0 → 0.28.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,14 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.28.0 (2026-10-05)
6
+
7
+ - `./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).
8
+
9
+ ## 0.27.0 (2026-10-05)
10
+
11
+ - No changes; lockstep release with ui (SLA-2975).
12
+
5
13
  ## 0.26.0 (2026-10-05)
6
14
 
7
15
  - `./url`: `publicImageUrl(image, basePath = '/')` — a `public/` asset path under the base path; absolute, protocol-relative and relative values pass through, no image stays undefined. It replaces the copies in the sites' affiliate utils (SLA-2921).
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-core",
3
- "version": "0.26.0",
3
+ "version": "0.28.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",
@@ -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';