@sarimarcus/content-sites-ui 0.22.0 → 0.22.2

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,15 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.22.2 (2026-10-04)
6
+
7
+ - `./seo`: `toArticleAuthor` (a byline name to ArticleMetadata's author), `astroSchemaInput` (the Astro half of core `createSchemaContext`'s input) and `COMMON_SOURCE_LABELS` (SLA-2918).
8
+ - `./seo` `AuthoritativeSources`: `domainClass` also accepts the synced outbound-domains store, and classifies through core `outboundClassifier`; output unchanged (SLA-2918).
9
+
10
+ ## 0.22.1 (2026-10-04)
11
+
12
+ - `./affiliate` `resources.ts` declares each widget kind's `connect-src` hosts and the hotel widget's `script-src 'unsafe-eval'`, which the CSP coverage gate now requires. A site rendering a hotel widget needs `'unsafe-eval'` in script-src and `https://suggest.apistp.com` in connect-src; a train widget needs `https://api.omio.com`; activity, train and car need `https://tpo.gg` (SLA-2920).
13
+
5
14
  ## 0.22.0 (2026-10-04)
6
15
 
7
16
  - `./affiliate`: `widgetLoaders` in `resources.ts` declares each Travelpayouts widget kind's loader URL, promo id and campaign id (the CSP coverage gate reads the promo ids from it); `widgetSrc(account, options)` builds a widget loader URL from it; `gygCardItem` and `gygCardLocale` turn a cached GYG activity into an `AffiliateCardItem`. `pageTypeOf` is re-exported from core, unchanged (SLA-2919).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,6 @@
49
49
  "@lucide/astro": "^1.23.0",
50
50
  "astro": "^7.0.6",
51
51
  "tailwindcss": "^4.1.18",
52
- "@sarimarcus/content-sites-core": "0.22.0"
52
+ "@sarimarcus/content-sites-core": "0.22.2"
53
53
  }
54
54
  }
@@ -1,32 +1,36 @@
1
1
  // The third-party hosts each TravelpayoutsWidget kind loads, per CSP directive. A site rendering a kind must allow
2
2
  // these in its Content-Security-Policy (scripts/validate-csp-coverage.mjs checks the built site against this list).
3
3
  // Read from each loader's own code: the tpwdg.com/c91 loader script, then what it injects (scripts, frames,
4
- // stylesheets). The loaders also call analytics endpoints (connect-src: tpo.gg, brand.travelpayouts.com, and
5
- // suggest.apistp.com for the hotel autocomplete); those are not declared here.
4
+ // stylesheets), and the hosts they fetch from (connect-src), traced in a browser (SLA-2920). A quoted CSP keyword
5
+ // ('unsafe-eval') is a requirement too: the hotel loader evals, and without it renders a static fallback card.
6
6
  // Every other component in this folder loads nothing: its outbound links are caller data, not resources.
7
7
  // Plain data and erasable types only: the gate imports this file under Node.
8
8
 
9
9
  import type { WidgetLoader } from '@sarimarcus/content-sites-core/affiliate';
10
10
 
11
11
  export type WidgetKind = 'hotel' | 'activity' | 'train' | 'car';
12
- export type CspDirective = 'script-src' | 'frame-src' | 'style-src';
12
+ export type CspDirective = 'script-src' | 'frame-src' | 'style-src' | 'connect-src';
13
13
  export type WidgetResources = Partial<Record<CspDirective, readonly string[]>>;
14
14
 
15
15
  export const externalResources: Record<WidgetKind, WidgetResources> = {
16
16
  hotel: {
17
- 'script-src': ['https://tpwdg.com'],
17
+ 'script-src': ['https://tpwdg.com', "'unsafe-eval'"],
18
+ 'connect-src': ['https://suggest.apistp.com', 'https://avsplow.com', 'https://sentry.avs.io'],
18
19
  },
19
20
  activity: {
20
21
  'script-src': ['https://tpwdg.com', 'https://widget.getyourguide.com'],
21
22
  'frame-src': ['https://widget.getyourguide.com'],
23
+ 'connect-src': ['https://tpo.gg', 'https://widget.getyourguide.com'],
22
24
  },
23
25
  train: {
24
26
  'script-src': ['https://c91.travelpayouts.com', 'https://www.omio.com'],
25
27
  'style-src': ['https://www.omio.com'],
28
+ 'connect-src': ['https://tpo.gg', 'https://api.omio.com'],
26
29
  },
27
30
  car: {
28
31
  'script-src': ['https://tpwdg.com', 'https://www.discovercars.com'],
29
32
  'frame-src': ['https://widget.discovercars.com'],
33
+ 'connect-src': ['https://tpo.gg'],
30
34
  },
31
35
  };
32
36
 
@@ -47,4 +51,5 @@ export const EMERALD_ORIGIN = 'https://emrldtp.com';
47
51
 
48
52
  export const emeraldResources: Partial<Record<CspDirective, readonly string[]>> = {
49
53
  'script-src': [EMERALD_ORIGIN],
54
+ 'connect-src': [EMERALD_ORIGIN],
50
55
  };
@@ -7,7 +7,7 @@
7
7
  * A source is a URL string, labelled from `domainLabels` (by host, without `www.`) or else by its host, or an object
8
8
  * { url, name?, description?, quote? }: `description` is a curated blurb, `quote` the verbatim cited sentence.
9
9
  *
10
- * Link policy (Rule 38), from `domainClass` (host → class, or a function of the URL): a competitor renders its label
10
+ * Link policy (Rule 38), from `domainClass` (host → class, the outbound-domains store, or a function of the URL): a competitor renders its label
11
11
  * unlinked (the item stays, so footnote numbers hold), an affiliate link carries rel="noopener sponsored", any other
12
12
  * rel="noopener".
13
13
  *
@@ -15,6 +15,7 @@
15
15
  * ("Host — Prices"), so two pages of one domain stay distinguishable. Explicit names, repeat citations of one URL and
16
16
  * domain-root URLs keep their label.
17
17
  */
18
+ import { outboundClassifier } from '@sarimarcus/content-sites-core/url';
18
19
  import type { SourceInput, DomainClassifier } from './types';
19
20
 
20
21
  interface Props {
@@ -55,19 +56,7 @@ const {
55
56
 
56
57
  if (!sources || sources.length === 0) return null;
57
58
 
58
- const hostKey = (host: string) => host.toLowerCase().replace(/^www\./, '');
59
- const classMap = typeof domainClass === 'function'
60
- ? null
61
- : new Map(Object.entries(domainClass).map(([d, c]) => [hostKey(d), c]));
62
-
63
- function classify(url: string): string {
64
- if (typeof domainClass === 'function') return domainClass(url);
65
- try {
66
- return classMap?.get(hostKey(new URL(url).hostname)) ?? 'other';
67
- } catch {
68
- return 'other';
69
- }
70
- }
59
+ const classify = typeof domainClass === 'function' ? domainClass : outboundClassifier(domainClass);
71
60
 
72
61
  function domainLabel(url: string): string {
73
62
  try {
@@ -0,0 +1,30 @@
1
+ import type { ImageMetadata } from 'astro';
2
+ import { url } from '@sarimarcus/content-sites-core/url';
3
+ import type { ArticleAuthor } from './types';
4
+
5
+ /** The fields of a site's author profile that a byline uses. */
6
+ export interface ArticleAuthorProfile {
7
+ slug: string;
8
+ photo?: string;
9
+ credentials?: string;
10
+ }
11
+
12
+ /**
13
+ * A byline name as ArticleMetadata's author: its profile (from `findProfile`) URL, portrait (from `portrait(slug)`),
14
+ * photo URL and credentials. An unknown name keeps only the name; no name gives no author.
15
+ */
16
+ export function toArticleAuthor(
17
+ name: string | undefined,
18
+ findProfile: (name: string) => ArticleAuthorProfile | undefined,
19
+ portrait: (slug: string) => ImageMetadata | null,
20
+ ): ArticleAuthor | undefined {
21
+ if (!name) return undefined;
22
+ const profile = findProfile(name);
23
+ return {
24
+ name,
25
+ href: profile ? url(`/author/${profile.slug}`) : null,
26
+ image: profile?.slug ? portrait(profile.slug) : null,
27
+ photoUrl: profile?.photo ? url(profile.photo) : null,
28
+ credentials: profile?.credentials ?? null,
29
+ };
30
+ }
package/src/seo/index.ts CHANGED
@@ -5,4 +5,8 @@ export { default as Breadcrumb } from './Breadcrumb.astro';
5
5
  export { default as ArticleMetadata } from './ArticleMetadata.astro';
6
6
  export { default as AuthoritativeSources } from './AuthoritativeSources.astro';
7
7
  export { escapeJsonLd } from './jsonLd';
8
+ export { toArticleAuthor } from './author';
9
+ export type { ArticleAuthorProfile } from './author';
10
+ export { astroSchemaInput } from './schemaContext';
11
+ export { COMMON_SOURCE_LABELS } from './sourceLabels';
8
12
  export type { BreadcrumbItem, ArticleAuthor, SourceInput, DomainClassifier } from './types';
@@ -0,0 +1,15 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * The page half of core `createSchemaContext`'s input, from Astro: site URL (else `fallbackSiteUrl`), base path,
6
+ * page path, and `fileExists` on `public/`.
7
+ */
8
+ export function astroSchemaInput(astro: { site?: URL; url: URL }, fallbackSiteUrl: string) {
9
+ return {
10
+ siteUrl: astro.site?.href || fallbackSiteUrl,
11
+ basePath: import.meta.env.BASE_URL.replace(/\/$/, ''),
12
+ pagePath: astro.url.pathname,
13
+ fileExists: (p: string) => fs.existsSync(path.join(process.cwd(), 'public', p)),
14
+ };
15
+ }
@@ -0,0 +1,12 @@
1
+ /** Labels (by host, without `www.`) for the reference sources every site cites; a site spreads its own over these. */
2
+ export const COMMON_SOURCE_LABELS: Readonly<Record<string, string>> = {
3
+ 'wikipedia.org': 'Wikipedia',
4
+ 'en.wikipedia.org': 'Wikipedia',
5
+ 'britannica.com': 'Britannica',
6
+ 'unesco.org': 'UNESCO',
7
+ 'whc.unesco.org': 'UNESCO World Heritage',
8
+ 'tripadvisor.com': 'TripAdvisor',
9
+ 'booking.com': 'Booking.com',
10
+ 'getyourguide.com': 'GetYourGuide',
11
+ 'viator.com': 'Viator',
12
+ };
package/src/seo/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ImageMetadata } from 'astro';
2
+ import type { OutboundDomainStore } from '@sarimarcus/content-sites-core/url';
2
3
 
3
4
  export interface BreadcrumbItem {
4
5
  name: string;
@@ -16,5 +17,5 @@ export interface ArticleAuthor {
16
17
 
17
18
  export type SourceInput = string | { name?: string; url: string; description?: string; quote?: string };
18
19
 
19
- /** Host → outbound class (`competitor`, `affiliate`, …), or a function of the URL. */
20
- export type DomainClassifier = Record<string, string> | ((url: string) => string);
20
+ /** Host → outbound class (`competitor`, `affiliate`, …), the synced outbound-domains store, or a function of the URL. */
21
+ export type DomainClassifier = Record<string, string> | OutboundDomainStore | ((url: string) => string);