@sarimarcus/content-sites-core 0.21.0 → 0.22.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,10 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.22.0 (2026-10-04)
6
+
7
+ - New `./affiliate`: `createGygCmp(siteKey)` (the GYG `cmp=` tagger and `tagGygLinksInHtml`), `gygProductId` (one `-t<digits>` pattern for every site), `cmpSegment`, `amazonProductUrl`, `bookingSearchUrl` and `travelpayoutsWidgetSrc`. Sites pass their partner ids and destinations in; output is byte-identical to the per-site builders it replaces. `./url` now exports `pageTypeOf` (SLA-2919).
8
+
5
9
  ## 0.21.0 (2026-10-02)
6
10
 
7
11
  - New `./images`: one width ladder (`IMAGE_LADDER`, `ladderWidths`, `snapWidth`, `variantRungs`) and `./images/service`, an Astro image service that never encodes. It serves committed WebP variants from the site's `image-variants/` and the source bytes at the native width, so production srcsets point at real files of the width they claim; previously every width resolved to one file under the noop service. `./images/variants` and the `content-sites-image-variants` bin generate, prune and check the variants (`sharp` is an optional peer). `defineSiteConfig` installs the service on every build in place of noop/sharp (SLA-2912).
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`, `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` and `images` (`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,28 @@
1
+ /** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
2
+ export declare function gygProductId(url: string): string;
3
+ /**
4
+ * cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
5
+ *
6
+ * Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
7
+ */
8
+ export declare function cmpSegment(raw: string): string;
9
+ export interface GygCmp {
10
+ /** Append `cmp=<siteKey>__<pageType>__<placement>` to a GetYourGuide affiliate URL. */
11
+ withGygCmp(href: string, pathname: string, placement: string): string;
12
+ /** Tag every GetYourGuide partner link inside already-rendered HTML. */
13
+ tagGygLinksInHtml(html: string, pathname: string, placement?: string): string;
14
+ }
15
+ /**
16
+ * The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
17
+ * the `<siteKey>__<pageType>__<placement>` shape against the built dist.
18
+ *
19
+ * Three deliberate no-ops, each a wrong-attribution bug if dropped:
20
+ * - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
21
+ * - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
22
+ * - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
23
+ * campaign value: the gate refuses any other shape.
24
+ *
25
+ * The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
26
+ * re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
27
+ */
28
+ export declare function createGygCmp(siteKey: string): GygCmp;
@@ -0,0 +1,60 @@
1
+ import { pageTypeOf } from "../url/pageType.js";
2
+ const GYG_HOST = /(^|\.)getyourguide\.com$/;
3
+ /** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
4
+ export function gygProductId(url) {
5
+ return url.match(/-t(\d+)/)?.[1] ?? '';
6
+ }
7
+ /**
8
+ * cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
9
+ *
10
+ * Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
11
+ */
12
+ export function cmpSegment(raw) {
13
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
14
+ }
15
+ /**
16
+ * The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
17
+ * the `<siteKey>__<pageType>__<placement>` shape against the built dist.
18
+ *
19
+ * Three deliberate no-ops, each a wrong-attribution bug if dropped:
20
+ * - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
21
+ * - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
22
+ * - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
23
+ * campaign value: the gate refuses any other shape.
24
+ *
25
+ * The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
26
+ * re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
27
+ */
28
+ export function createGygCmp(siteKey) {
29
+ function withGygCmp(href, pathname, placement) {
30
+ let u;
31
+ try {
32
+ u = new URL(href);
33
+ }
34
+ catch {
35
+ return href;
36
+ }
37
+ if (!GYG_HOST.test(u.hostname.replace(/^www\./, '')))
38
+ return href;
39
+ if (!u.searchParams.has('partner_id'))
40
+ return href;
41
+ if (u.searchParams.has('cmp'))
42
+ return href;
43
+ const type = cmpSegment(pageTypeOf(pathname));
44
+ const place = cmpSegment(placement);
45
+ if (!type || !place)
46
+ return href;
47
+ // cmp is [a-z0-9-] segments joined by `__`, so it needs no percent-encoding.
48
+ const hashAt = href.indexOf('#');
49
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
50
+ const frag = hashAt === -1 ? '' : href.slice(hashAt);
51
+ return `${base}${base.includes('?') ? '&' : '?'}cmp=${siteKey}__${type}__${place}${frag}`;
52
+ }
53
+ function tagGygLinksInHtml(html, pathname, placement = 'prose') {
54
+ return html.replace(/href="(https?:\/\/[^"]*getyourguide\.com[^"]*)"/g,
55
+ // `&amp;` is the standard encoding inside an attribute; without decoding it first, new URL() reads the key
56
+ // as `amp;partner_id` and the link ships untagged.
57
+ (_m, href) => `href="${withGygCmp(href.replace(/&amp;/g, '&'), pathname, placement).replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d)/g, '&amp;')}"`);
58
+ }
59
+ return { withGygCmp, tagGygLinksInHtml };
60
+ }
@@ -0,0 +1,6 @@
1
+ export { gygProductId, cmpSegment, createGygCmp } from './gyg.ts';
2
+ export type { GygCmp } from './gyg.ts';
3
+ export { amazonProductUrl, bookingSearchUrl } from './links.ts';
4
+ export type { BookingSearch } from './links.ts';
5
+ export { travelpayoutsWidgetSrc } from './travelpayouts.ts';
6
+ export type { WidgetLoader, WidgetAccount, WidgetOptions } from './travelpayouts.ts';
@@ -0,0 +1,4 @@
1
+ // Public surface of @sarimarcus/content-sites-core/affiliate.
2
+ export { gygProductId, cmpSegment, createGygCmp } from "./gyg.js";
3
+ export { amazonProductUrl, bookingSearchUrl } from "./links.js";
4
+ export { travelpayoutsWidgetSrc } from "./travelpayouts.js";
@@ -0,0 +1,17 @@
1
+ /** Amazon product page. With a tag (even an empty one) the path gains a trailing slash before the query; without one it has none. */
2
+ export declare function amazonProductUrl(asin: string, tag?: string): string;
3
+ export interface BookingSearch {
4
+ /** Free text. Alone it resolves to a property; with `destId` the destination wins. */
5
+ ss: string;
6
+ destId?: string;
7
+ destType?: string;
8
+ checkin?: string;
9
+ checkout?: string;
10
+ }
11
+ /**
12
+ * A Booking.com search for two adults in one room.
13
+ *
14
+ * Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
15
+ * redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
16
+ */
17
+ export declare function bookingSearchUrl({ ss, destId, destType, checkin, checkout }: BookingSearch): string;
@@ -0,0 +1,27 @@
1
+ /** Amazon product page. With a tag (even an empty one) the path gains a trailing slash before the query; without one it has none. */
2
+ export function amazonProductUrl(asin, tag) {
3
+ const base = `https://www.amazon.com/dp/${asin}`;
4
+ return tag === undefined ? base : `${base}/?${new URLSearchParams({ tag })}`;
5
+ }
6
+ /**
7
+ * A Booking.com search for two adults in one room.
8
+ *
9
+ * Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
10
+ * redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
11
+ */
12
+ export function bookingSearchUrl({ ss, destId, destType = 'city', checkin, checkout }) {
13
+ const params = new URLSearchParams({ ss });
14
+ if (destId) {
15
+ params.set('dest_id', destId);
16
+ params.set('dest_type', destType);
17
+ }
18
+ if (checkin)
19
+ params.set('checkin', checkin);
20
+ if (checkout)
21
+ params.set('checkout', checkout);
22
+ params.set('group_adults', '2');
23
+ params.set('group_children', '0');
24
+ params.set('no_rooms', '1');
25
+ params.set('lang', 'en-gb');
26
+ return `https://www.booking.com/searchresults.html?${params.toString()}`;
27
+ }
@@ -0,0 +1,38 @@
1
+ /** Where a Travelpayouts widget kind loads from and the ids it reports under. Declared in ui affiliate/resources.ts. */
2
+ export interface WidgetLoader {
3
+ /** The loader script URL without its query, e.g. `https://<host>/content`. */
4
+ url: string;
5
+ promoId: string;
6
+ campaignId?: string;
7
+ }
8
+ export interface WidgetAccount {
9
+ /** The site's traffic source, which splits revenue per site. */
10
+ trs: string;
11
+ /** The Travelpayouts marker. */
12
+ marker: string;
13
+ }
14
+ export type WidgetOptions = {
15
+ kind: 'hotel';
16
+ direction: string;
17
+ } | {
18
+ kind: 'activity';
19
+ place: string;
20
+ } | {
21
+ kind: 'train';
22
+ } | {
23
+ kind: 'car';
24
+ country: string;
25
+ city: string;
26
+ colors: {
27
+ background: string;
28
+ font: string;
29
+ button: string;
30
+ buttonFont: string;
31
+ };
32
+ buttonText?: string;
33
+ };
34
+ /**
35
+ * A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
36
+ * (hotel puts promo_id before campaign_id, activity and car after).
37
+ */
38
+ export declare function travelpayoutsWidgetSrc(loader: WidgetLoader, account: WidgetAccount, options: WidgetOptions): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
3
+ * (hotel puts promo_id before campaign_id, activity and car after).
4
+ */
5
+ export function travelpayoutsWidgetSrc(loader, account, options) {
6
+ const ids = (order) => {
7
+ const promo = ['promo_id', loader.promoId];
8
+ const campaign = loader.campaignId ? [['campaign_id', loader.campaignId]] : [];
9
+ return order === 'promo-first' ? [promo, ...campaign] : [...campaign, promo];
10
+ };
11
+ const who = [['trs', account.trs], ['shmarker', account.marker]];
12
+ let pairs;
13
+ switch (options.kind) {
14
+ case 'hotel':
15
+ pairs = [...who, ['locale', 'en'], ['default_direction', options.direction], ['sustainable', 'false'], ['deals', 'false'],
16
+ ['border_radius', '5'], ['plain', 'true'], ['powered_by', 'true'], ...ids('promo-first')];
17
+ break;
18
+ case 'activity':
19
+ pairs = [...who, ['place', options.place], ['items', '3'], ['locale', 'en-US'], ['powered_by', 'true'], ...ids('campaign-first')];
20
+ break;
21
+ case 'train':
22
+ pairs = [['currency', 'USD'], ...who, ['powered_by', 'true'], ['locale', 'en'], ['mode', 'default'], ['theme', 'white'],
23
+ ['layout', 'fluid'], ...ids('promo-first')];
24
+ break;
25
+ case 'car':
26
+ pairs = [['currency', 'usd'], ...who, ['country', options.country], ['city', options.city], ['locale', 'en'], ['powered_by', 'true'],
27
+ ['bg_color', options.colors.background], ['font_color', options.colors.font], ['button_color', options.colors.button],
28
+ ['button_font_color', options.colors.buttonFont], ['button_text', options.buttonText ?? 'Search'], ['rounded_corners', 'true'],
29
+ ['benefits', 'true'], ['dc_powered_by', 'false'], ['supplier_logos', 'false'], ...ids('campaign-first')];
30
+ break;
31
+ }
32
+ return `${loader.url}?${pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`;
33
+ }
@@ -4,3 +4,4 @@ export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.t
4
4
  export { buildImageMap, keyBySlug } from './imageMap.ts';
5
5
  export { articleFeedItem } from './feed.ts';
6
6
  export type { FeedArticle, FeedItem } from './feed.ts';
7
+ export { pageTypeOf } from './pageType.ts';
package/dist/url/index.js CHANGED
@@ -4,3 +4,4 @@ export { getOgImagePath } from "./ogImage.js";
4
4
  export { normalizeHref, extractProseHrefs, dedupRelated } from "./relatedDedup.js";
5
5
  export { buildImageMap, keyBySlug } from "./imageMap.js";
6
6
  export { articleFeedItem } from "./feed.js";
7
+ export { pageTypeOf } from "./pageType.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Page type: the first path segment, 'home' at the root.
3
+ *
4
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
5
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
6
+ * normalisation is what makes the two agree.
7
+ */
8
+ export declare function pageTypeOf(pathname: string): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Page type: the first path segment, 'home' at the root.
3
+ *
4
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
5
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
6
+ * normalisation is what makes the two agree.
7
+ */
8
+ export function pageTypeOf(pathname) {
9
+ const first = pathname.split('/').filter(Boolean)[0];
10
+ if (!first)
11
+ return 'home';
12
+ const bare = first.replace(/\.html$/, '');
13
+ return bare === 'index' || bare === '' ? 'home' : bare;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-core",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/url/index.d.ts",
35
35
  "default": "./dist/url/index.js"
36
36
  },
37
+ "./affiliate": {
38
+ "types": "./dist/affiliate/index.d.ts",
39
+ "default": "./dist/affiliate/index.js"
40
+ },
37
41
  "./dates": {
38
42
  "types": "./dist/dates/index.d.ts",
39
43
  "default": "./dist/dates/index.js"
@@ -0,0 +1,66 @@
1
+ import { pageTypeOf } from '../url/pageType.ts';
2
+
3
+ const GYG_HOST = /(^|\.)getyourguide\.com$/;
4
+
5
+ /** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
6
+ export function gygProductId(url: string): string {
7
+ return url.match(/-t(\d+)/)?.[1] ?? '';
8
+ }
9
+
10
+ /**
11
+ * cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
12
+ *
13
+ * Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
14
+ */
15
+ export function cmpSegment(raw: string): string {
16
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
17
+ }
18
+
19
+ export interface GygCmp {
20
+ /** Append `cmp=<siteKey>__<pageType>__<placement>` to a GetYourGuide affiliate URL. */
21
+ withGygCmp(href: string, pathname: string, placement: string): string;
22
+ /** Tag every GetYourGuide partner link inside already-rendered HTML. */
23
+ tagGygLinksInHtml(html: string, pathname: string, placement?: string): string;
24
+ }
25
+
26
+ /**
27
+ * The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
28
+ * the `<siteKey>__<pageType>__<placement>` shape against the built dist.
29
+ *
30
+ * Three deliberate no-ops, each a wrong-attribution bug if dropped:
31
+ * - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
32
+ * - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
33
+ * - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
34
+ * campaign value: the gate refuses any other shape.
35
+ *
36
+ * The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
37
+ * re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
38
+ */
39
+ export function createGygCmp(siteKey: string): GygCmp {
40
+ function withGygCmp(href: string, pathname: string, placement: string): string {
41
+ let u: URL;
42
+ try { u = new URL(href); } catch { return href; }
43
+ if (!GYG_HOST.test(u.hostname.replace(/^www\./, ''))) return href;
44
+ if (!u.searchParams.has('partner_id')) return href;
45
+ if (u.searchParams.has('cmp')) return href;
46
+ const type = cmpSegment(pageTypeOf(pathname));
47
+ const place = cmpSegment(placement);
48
+ if (!type || !place) return href;
49
+ // cmp is [a-z0-9-] segments joined by `__`, so it needs no percent-encoding.
50
+ const hashAt = href.indexOf('#');
51
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
52
+ const frag = hashAt === -1 ? '' : href.slice(hashAt);
53
+ return `${base}${base.includes('?') ? '&' : '?'}cmp=${siteKey}__${type}__${place}${frag}`;
54
+ }
55
+
56
+ function tagGygLinksInHtml(html: string, pathname: string, placement = 'prose'): string {
57
+ return html.replace(
58
+ /href="(https?:\/\/[^"]*getyourguide\.com[^"]*)"/g,
59
+ // `&amp;` is the standard encoding inside an attribute; without decoding it first, new URL() reads the key
60
+ // as `amp;partner_id` and the link ships untagged.
61
+ (_m, href: string) => `href="${withGygCmp(href.replace(/&amp;/g, '&'), pathname, placement).replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d)/g, '&amp;')}"`,
62
+ );
63
+ }
64
+
65
+ return { withGygCmp, tagGygLinksInHtml };
66
+ }
@@ -0,0 +1,7 @@
1
+ // Public surface of @sarimarcus/content-sites-core/affiliate.
2
+ export { gygProductId, cmpSegment, createGygCmp } from './gyg.ts';
3
+ export type { GygCmp } from './gyg.ts';
4
+ export { amazonProductUrl, bookingSearchUrl } from './links.ts';
5
+ export type { BookingSearch } from './links.ts';
6
+ export { travelpayoutsWidgetSrc } from './travelpayouts.ts';
7
+ export type { WidgetLoader, WidgetAccount, WidgetOptions } from './travelpayouts.ts';
@@ -0,0 +1,35 @@
1
+ /** Amazon product page. With a tag (even an empty one) the path gains a trailing slash before the query; without one it has none. */
2
+ export function amazonProductUrl(asin: string, tag?: string): string {
3
+ const base = `https://www.amazon.com/dp/${asin}`;
4
+ return tag === undefined ? base : `${base}/?${new URLSearchParams({ tag })}`;
5
+ }
6
+
7
+ export interface BookingSearch {
8
+ /** Free text. Alone it resolves to a property; with `destId` the destination wins. */
9
+ ss: string;
10
+ destId?: string;
11
+ destType?: string;
12
+ checkin?: string;
13
+ checkout?: string;
14
+ }
15
+
16
+ /**
17
+ * A Booking.com search for two adults in one room.
18
+ *
19
+ * Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
20
+ * redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
21
+ */
22
+ export function bookingSearchUrl({ ss, destId, destType = 'city', checkin, checkout }: BookingSearch): string {
23
+ const params = new URLSearchParams({ ss });
24
+ if (destId) {
25
+ params.set('dest_id', destId);
26
+ params.set('dest_type', destType);
27
+ }
28
+ if (checkin) params.set('checkin', checkin);
29
+ if (checkout) params.set('checkout', checkout);
30
+ params.set('group_adults', '2');
31
+ params.set('group_children', '0');
32
+ params.set('no_rooms', '1');
33
+ params.set('lang', 'en-gb');
34
+ return `https://www.booking.com/searchresults.html?${params.toString()}`;
35
+ }
@@ -0,0 +1,62 @@
1
+ /** Where a Travelpayouts widget kind loads from and the ids it reports under. Declared in ui affiliate/resources.ts. */
2
+ export interface WidgetLoader {
3
+ /** The loader script URL without its query, e.g. `https://<host>/content`. */
4
+ url: string;
5
+ promoId: string;
6
+ campaignId?: string;
7
+ }
8
+
9
+ export interface WidgetAccount {
10
+ /** The site's traffic source, which splits revenue per site. */
11
+ trs: string;
12
+ /** The Travelpayouts marker. */
13
+ marker: string;
14
+ }
15
+
16
+ export type WidgetOptions =
17
+ | { kind: 'hotel'; direction: string }
18
+ | { kind: 'activity'; place: string }
19
+ | { kind: 'train' }
20
+ | {
21
+ kind: 'car';
22
+ country: string;
23
+ city: string;
24
+ colors: { background: string; font: string; button: string; buttonFont: string };
25
+ buttonText?: string;
26
+ };
27
+
28
+ type Pair = [string, string];
29
+
30
+ /**
31
+ * A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
32
+ * (hotel puts promo_id before campaign_id, activity and car after).
33
+ */
34
+ export function travelpayoutsWidgetSrc(loader: WidgetLoader, account: WidgetAccount, options: WidgetOptions): string {
35
+ const ids = (order: 'promo-first' | 'campaign-first'): Pair[] => {
36
+ const promo: Pair = ['promo_id', loader.promoId];
37
+ const campaign: Pair[] = loader.campaignId ? [['campaign_id', loader.campaignId]] : [];
38
+ return order === 'promo-first' ? [promo, ...campaign] : [...campaign, promo];
39
+ };
40
+ const who: Pair[] = [['trs', account.trs], ['shmarker', account.marker]];
41
+ let pairs: Pair[];
42
+ switch (options.kind) {
43
+ case 'hotel':
44
+ pairs = [...who, ['locale', 'en'], ['default_direction', options.direction], ['sustainable', 'false'], ['deals', 'false'],
45
+ ['border_radius', '5'], ['plain', 'true'], ['powered_by', 'true'], ...ids('promo-first')];
46
+ break;
47
+ case 'activity':
48
+ pairs = [...who, ['place', options.place], ['items', '3'], ['locale', 'en-US'], ['powered_by', 'true'], ...ids('campaign-first')];
49
+ break;
50
+ case 'train':
51
+ pairs = [['currency', 'USD'], ...who, ['powered_by', 'true'], ['locale', 'en'], ['mode', 'default'], ['theme', 'white'],
52
+ ['layout', 'fluid'], ...ids('promo-first')];
53
+ break;
54
+ case 'car':
55
+ pairs = [['currency', 'usd'], ...who, ['country', options.country], ['city', options.city], ['locale', 'en'], ['powered_by', 'true'],
56
+ ['bg_color', options.colors.background], ['font_color', options.colors.font], ['button_color', options.colors.button],
57
+ ['button_font_color', options.colors.buttonFont], ['button_text', options.buttonText ?? 'Search'], ['rounded_corners', 'true'],
58
+ ['benefits', 'true'], ['dc_powered_by', 'false'], ['supplier_logos', 'false'], ...ids('campaign-first')];
59
+ break;
60
+ }
61
+ return `${loader.url}?${pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`;
62
+ }
package/src/url/index.ts CHANGED
@@ -5,3 +5,4 @@ export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.t
5
5
  export { buildImageMap, keyBySlug } from './imageMap.ts';
6
6
  export { articleFeedItem } from './feed.ts';
7
7
  export type { FeedArticle, FeedItem } from './feed.ts';
8
+ export { pageTypeOf } from './pageType.ts';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Page type: the first path segment, 'home' at the root.
3
+ *
4
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
5
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
6
+ * normalisation is what makes the two agree.
7
+ */
8
+ export function pageTypeOf(pathname: string): string {
9
+ const first = pathname.split('/').filter(Boolean)[0];
10
+ if (!first) return 'home';
11
+ const bare = first.replace(/\.html$/, '');
12
+ return bare === 'index' || bare === '' ? 'home' : bare;
13
+ }