perspectapi-ts-sdk 2.3.4 → 2.5.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/dist/index.d.mts CHANGED
@@ -276,6 +276,8 @@ interface Content {
276
276
  slug_prefix?: string;
277
277
  pageStatus: ContentStatus;
278
278
  pageType: ContentType;
279
+ media?: MediaItem[] | MediaItem[][];
280
+ image?: string;
279
281
  userId?: number;
280
282
  organizationId?: number;
281
283
  createdAt: string;
@@ -376,6 +378,7 @@ interface BlogPost {
376
378
  content?: string;
377
379
  excerpt?: string;
378
380
  image?: string;
381
+ media?: MediaItem[] | MediaItem[][];
379
382
  published?: boolean;
380
383
  created_at?: string;
381
384
  updated_at?: string;
@@ -1988,6 +1991,32 @@ declare class NewsletterClient extends BaseClient {
1988
1991
  message: string;
1989
1992
  sent: boolean;
1990
1993
  }>>;
1994
+ /**
1995
+ * Track email open event.
1996
+ * Called by client sites when their tracking pixel route is hit.
1997
+ * The client site serves the pixel and calls this to record the open.
1998
+ * @param siteName - The site name
1999
+ * @param token - The tracking token from the pixel URL
2000
+ */
2001
+ trackOpen(siteName: string, token: string): Promise<ApiResponse<{
2002
+ success: boolean;
2003
+ }>>;
2004
+ /**
2005
+ * Track link click event.
2006
+ * Called by client sites when their click tracking route is hit.
2007
+ * The client site redirects to the destination and calls this to record the click.
2008
+ * @param siteName - The site name
2009
+ * @param token - The tracking token from the click URL
2010
+ * @param url - The destination URL that was clicked
2011
+ */
2012
+ trackClick(siteName: string, token: string, url: string): Promise<ApiResponse<{
2013
+ success: boolean;
2014
+ }>>;
2015
+ /**
2016
+ * Get the 1x1 transparent GIF pixel as a Uint8Array.
2017
+ * Client sites can use this to serve the tracking pixel response.
2018
+ */
2019
+ static getTrackingPixel(): Uint8Array;
1991
2020
  }
1992
2021
 
1993
2022
  /**
@@ -2283,7 +2312,7 @@ declare const transformProduct: (perspectProduct: any, logger?: LoaderLogger) =>
2283
2312
  /**
2284
2313
  * Transform PerspectAPI content payload to a simplified BlogPost structure.
2285
2314
  */
2286
- declare const transformContent: (perspectContent: Content | (Content & Record<string, unknown>)) => BlogPost;
2315
+ declare const transformContent: (perspectContent: Content | (Content & Record<string, unknown>), logger?: LoaderLogger) => BlogPost;
2287
2316
  interface LoaderOptions {
2288
2317
  /**
2289
2318
  * Pre-configured PerspectAPI client. Pass null/undefined to force fallback behaviour.
package/dist/index.d.ts CHANGED
@@ -276,6 +276,8 @@ interface Content {
276
276
  slug_prefix?: string;
277
277
  pageStatus: ContentStatus;
278
278
  pageType: ContentType;
279
+ media?: MediaItem[] | MediaItem[][];
280
+ image?: string;
279
281
  userId?: number;
280
282
  organizationId?: number;
281
283
  createdAt: string;
@@ -376,6 +378,7 @@ interface BlogPost {
376
378
  content?: string;
377
379
  excerpt?: string;
378
380
  image?: string;
381
+ media?: MediaItem[] | MediaItem[][];
379
382
  published?: boolean;
380
383
  created_at?: string;
381
384
  updated_at?: string;
@@ -1988,6 +1991,32 @@ declare class NewsletterClient extends BaseClient {
1988
1991
  message: string;
1989
1992
  sent: boolean;
1990
1993
  }>>;
1994
+ /**
1995
+ * Track email open event.
1996
+ * Called by client sites when their tracking pixel route is hit.
1997
+ * The client site serves the pixel and calls this to record the open.
1998
+ * @param siteName - The site name
1999
+ * @param token - The tracking token from the pixel URL
2000
+ */
2001
+ trackOpen(siteName: string, token: string): Promise<ApiResponse<{
2002
+ success: boolean;
2003
+ }>>;
2004
+ /**
2005
+ * Track link click event.
2006
+ * Called by client sites when their click tracking route is hit.
2007
+ * The client site redirects to the destination and calls this to record the click.
2008
+ * @param siteName - The site name
2009
+ * @param token - The tracking token from the click URL
2010
+ * @param url - The destination URL that was clicked
2011
+ */
2012
+ trackClick(siteName: string, token: string, url: string): Promise<ApiResponse<{
2013
+ success: boolean;
2014
+ }>>;
2015
+ /**
2016
+ * Get the 1x1 transparent GIF pixel as a Uint8Array.
2017
+ * Client sites can use this to serve the tracking pixel response.
2018
+ */
2019
+ static getTrackingPixel(): Uint8Array;
1991
2020
  }
1992
2021
 
1993
2022
  /**
@@ -2283,7 +2312,7 @@ declare const transformProduct: (perspectProduct: any, logger?: LoaderLogger) =>
2283
2312
  /**
2284
2313
  * Transform PerspectAPI content payload to a simplified BlogPost structure.
2285
2314
  */
2286
- declare const transformContent: (perspectContent: Content | (Content & Record<string, unknown>)) => BlogPost;
2315
+ declare const transformContent: (perspectContent: Content | (Content & Record<string, unknown>), logger?: LoaderLogger) => BlogPost;
2287
2316
  interface LoaderOptions {
2288
2317
  /**
2289
2318
  * Pre-configured PerspectAPI client. Pass null/undefined to force fallback behaviour.
package/dist/index.js CHANGED
@@ -850,12 +850,12 @@ var ContentClient = class extends BaseClient {
850
850
  console.log("[PerspectAPI Content] Cache details:", {
851
851
  cacheEnabled: !!this.cache,
852
852
  cachePolicy,
853
- tags: this.buildContentTags(siteName)
853
+ tags: this.buildContentTags(siteName, void 0, void 0, normalizedParams?.slug_prefix)
854
854
  });
855
855
  const result = await this.fetchWithCache(
856
856
  endpoint,
857
857
  normalizedParams,
858
- this.buildContentTags(siteName),
858
+ this.buildContentTags(siteName, void 0, void 0, normalizedParams?.slug_prefix),
859
859
  cachePolicy,
860
860
  async () => {
861
861
  console.log("[PerspectAPI Content] Making HTTP request:", {
@@ -976,7 +976,7 @@ var ContentClient = class extends BaseClient {
976
976
  async duplicateContent(id) {
977
977
  return this.create(`/content/${id}/duplicate`, {});
978
978
  }
979
- buildContentTags(siteName, slug, id) {
979
+ buildContentTags(siteName, slug, id, slugPrefix) {
980
980
  const tags = /* @__PURE__ */ new Set(["content"]);
981
981
  if (siteName) {
982
982
  tags.add(`content:site:${siteName}`);
@@ -987,6 +987,9 @@ var ContentClient = class extends BaseClient {
987
987
  if (typeof id === "number") {
988
988
  tags.add(`content:id:${id}`);
989
989
  }
990
+ if (slugPrefix) {
991
+ tags.add(`content:prefix:${slugPrefix}`);
992
+ }
990
993
  return Array.from(tags.values());
991
994
  }
992
995
  };
@@ -2076,6 +2079,86 @@ var NewsletterClient = class extends BaseClient {
2076
2079
  data
2077
2080
  );
2078
2081
  }
2082
+ // ============================================
2083
+ // Tracking methods (for first-party tracking)
2084
+ // ============================================
2085
+ /**
2086
+ * Track email open event.
2087
+ * Called by client sites when their tracking pixel route is hit.
2088
+ * The client site serves the pixel and calls this to record the open.
2089
+ * @param siteName - The site name
2090
+ * @param token - The tracking token from the pixel URL
2091
+ */
2092
+ async trackOpen(siteName, token) {
2093
+ return this.http.post(
2094
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/open/${encodeURIComponent(token)}`))
2095
+ );
2096
+ }
2097
+ /**
2098
+ * Track link click event.
2099
+ * Called by client sites when their click tracking route is hit.
2100
+ * The client site redirects to the destination and calls this to record the click.
2101
+ * @param siteName - The site name
2102
+ * @param token - The tracking token from the click URL
2103
+ * @param url - The destination URL that was clicked
2104
+ */
2105
+ async trackClick(siteName, token, url) {
2106
+ return this.http.post(
2107
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/click/${encodeURIComponent(token)}`)),
2108
+ { url }
2109
+ );
2110
+ }
2111
+ /**
2112
+ * Get the 1x1 transparent GIF pixel as a Uint8Array.
2113
+ * Client sites can use this to serve the tracking pixel response.
2114
+ */
2115
+ static getTrackingPixel() {
2116
+ return new Uint8Array([
2117
+ 71,
2118
+ 73,
2119
+ 70,
2120
+ 56,
2121
+ 57,
2122
+ 97,
2123
+ 1,
2124
+ 0,
2125
+ 1,
2126
+ 0,
2127
+ 128,
2128
+ 0,
2129
+ 0,
2130
+ 255,
2131
+ 255,
2132
+ 255,
2133
+ 0,
2134
+ 0,
2135
+ 0,
2136
+ 33,
2137
+ 249,
2138
+ 4,
2139
+ 1,
2140
+ 0,
2141
+ 0,
2142
+ 0,
2143
+ 0,
2144
+ 44,
2145
+ 0,
2146
+ 0,
2147
+ 0,
2148
+ 0,
2149
+ 1,
2150
+ 0,
2151
+ 1,
2152
+ 0,
2153
+ 0,
2154
+ 2,
2155
+ 2,
2156
+ 68,
2157
+ 1,
2158
+ 0,
2159
+ 59
2160
+ ]);
2161
+ }
2079
2162
  };
2080
2163
 
2081
2164
  // src/perspect-api-client.ts
@@ -2323,6 +2406,29 @@ var normalizeMediaList = (rawMedia) => {
2323
2406
  }
2324
2407
  return rawMedia.filter(isMediaItem);
2325
2408
  };
2409
+ var describeMedia = (rawMedia) => {
2410
+ const isArray = Array.isArray(rawMedia);
2411
+ const nestedArray = isArray && rawMedia.length > 0 && Array.isArray(rawMedia[0]);
2412
+ const rawCount = isArray ? rawMedia.length : 0;
2413
+ const flattenedCount = nestedArray ? rawMedia.flat().length : rawCount;
2414
+ const firstSample = (() => {
2415
+ if (!isArray) {
2416
+ return rawMedia;
2417
+ }
2418
+ if (nestedArray) {
2419
+ const firstGroup = rawMedia.find(Array.isArray);
2420
+ return firstGroup ? firstGroup[0] : void 0;
2421
+ }
2422
+ return rawMedia[0];
2423
+ })();
2424
+ return {
2425
+ isArray,
2426
+ nestedArray,
2427
+ rawCount,
2428
+ flattenedCount,
2429
+ sampleType: firstSample ? typeof firstSample : "undefined"
2430
+ };
2431
+ };
2326
2432
  var normalizeQueryParamList = (value) => {
2327
2433
  if (value === void 0 || value === null) {
2328
2434
  return void 0;
@@ -2365,9 +2471,28 @@ var transformProduct = (perspectProduct, logger) => {
2365
2471
  ...rawProduct
2366
2472
  };
2367
2473
  };
2368
- var transformContent = (perspectContent) => {
2474
+ var transformContent = (perspectContent, logger) => {
2369
2475
  const raw = perspectContent ?? {};
2476
+ const mediaItems = normalizeMediaList(raw.media);
2477
+ const image = raw.image;
2478
+ if (raw.media !== void 0) {
2479
+ const mediaDetails = describeMedia(raw.media);
2480
+ const slugOrId = raw.slug ?? raw.id;
2481
+ if (mediaItems.length === 0 && (mediaDetails.rawCount > 0 || mediaDetails.flattenedCount > 0)) {
2482
+ log(logger, "debug", "[PerspectAPI] Content media present but failed normalization", {
2483
+ slug: slugOrId,
2484
+ ...mediaDetails
2485
+ });
2486
+ } else {
2487
+ log(logger, "debug", "[PerspectAPI] Content media normalization", {
2488
+ slug: slugOrId,
2489
+ ...mediaDetails,
2490
+ normalizedCount: mediaItems.length
2491
+ });
2492
+ }
2493
+ }
2370
2494
  return {
2495
+ ...raw,
2371
2496
  id: typeof raw.id === "string" ? raw.id : String(raw.id ?? ""),
2372
2497
  slug: raw.slug ?? `post-${raw.id ?? "unknown"}`,
2373
2498
  slug_prefix: raw.slug_prefix ?? (raw.pageType === "post" ? "blog" : "page"),
@@ -2382,7 +2507,8 @@ var transformContent = (perspectContent) => {
2382
2507
  published_date: raw.published_date,
2383
2508
  author: raw.author,
2384
2509
  tags: raw.tags,
2385
- image: raw.image
2510
+ image,
2511
+ media: mediaItems
2386
2512
  };
2387
2513
  };
2388
2514
  var getDefaultFallbackProducts = (siteName) => {
@@ -2568,7 +2694,7 @@ async function loadPages(options) {
2568
2694
  if (!response.data) {
2569
2695
  return [];
2570
2696
  }
2571
- return response.data.map((content) => transformContent(content));
2697
+ return response.data.map((content) => transformContent(content, logger));
2572
2698
  } catch (error) {
2573
2699
  log(logger, "error", "[PerspectAPI] Error loading pages", error);
2574
2700
  return [];
@@ -2602,7 +2728,7 @@ async function loadPosts(options) {
2602
2728
  log(logger, "warn", "[PerspectAPI] Posts response missing data");
2603
2729
  return [];
2604
2730
  }
2605
- return response.data.map((content) => transformContent(content));
2731
+ return response.data.map((content) => transformContent(content, logger));
2606
2732
  } catch (error) {
2607
2733
  log(logger, "error", "[PerspectAPI] Error loading posts", error);
2608
2734
  return [];
@@ -2628,7 +2754,7 @@ async function loadContentBySlug(options) {
2628
2754
  log(logger, "warn", `[PerspectAPI] Content not found for slug "${slug}"`);
2629
2755
  return null;
2630
2756
  }
2631
- return transformContent(response.data);
2757
+ return transformContent(response.data, logger);
2632
2758
  } catch (error) {
2633
2759
  log(logger, "error", `[PerspectAPI] Error loading content slug "${slug}"`, error);
2634
2760
  return null;
package/dist/index.mjs CHANGED
@@ -789,12 +789,12 @@ var ContentClient = class extends BaseClient {
789
789
  console.log("[PerspectAPI Content] Cache details:", {
790
790
  cacheEnabled: !!this.cache,
791
791
  cachePolicy,
792
- tags: this.buildContentTags(siteName)
792
+ tags: this.buildContentTags(siteName, void 0, void 0, normalizedParams?.slug_prefix)
793
793
  });
794
794
  const result = await this.fetchWithCache(
795
795
  endpoint,
796
796
  normalizedParams,
797
- this.buildContentTags(siteName),
797
+ this.buildContentTags(siteName, void 0, void 0, normalizedParams?.slug_prefix),
798
798
  cachePolicy,
799
799
  async () => {
800
800
  console.log("[PerspectAPI Content] Making HTTP request:", {
@@ -915,7 +915,7 @@ var ContentClient = class extends BaseClient {
915
915
  async duplicateContent(id) {
916
916
  return this.create(`/content/${id}/duplicate`, {});
917
917
  }
918
- buildContentTags(siteName, slug, id) {
918
+ buildContentTags(siteName, slug, id, slugPrefix) {
919
919
  const tags = /* @__PURE__ */ new Set(["content"]);
920
920
  if (siteName) {
921
921
  tags.add(`content:site:${siteName}`);
@@ -926,6 +926,9 @@ var ContentClient = class extends BaseClient {
926
926
  if (typeof id === "number") {
927
927
  tags.add(`content:id:${id}`);
928
928
  }
929
+ if (slugPrefix) {
930
+ tags.add(`content:prefix:${slugPrefix}`);
931
+ }
929
932
  return Array.from(tags.values());
930
933
  }
931
934
  };
@@ -2015,6 +2018,86 @@ var NewsletterClient = class extends BaseClient {
2015
2018
  data
2016
2019
  );
2017
2020
  }
2021
+ // ============================================
2022
+ // Tracking methods (for first-party tracking)
2023
+ // ============================================
2024
+ /**
2025
+ * Track email open event.
2026
+ * Called by client sites when their tracking pixel route is hit.
2027
+ * The client site serves the pixel and calls this to record the open.
2028
+ * @param siteName - The site name
2029
+ * @param token - The tracking token from the pixel URL
2030
+ */
2031
+ async trackOpen(siteName, token) {
2032
+ return this.http.post(
2033
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/open/${encodeURIComponent(token)}`))
2034
+ );
2035
+ }
2036
+ /**
2037
+ * Track link click event.
2038
+ * Called by client sites when their click tracking route is hit.
2039
+ * The client site redirects to the destination and calls this to record the click.
2040
+ * @param siteName - The site name
2041
+ * @param token - The tracking token from the click URL
2042
+ * @param url - The destination URL that was clicked
2043
+ */
2044
+ async trackClick(siteName, token, url) {
2045
+ return this.http.post(
2046
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/click/${encodeURIComponent(token)}`)),
2047
+ { url }
2048
+ );
2049
+ }
2050
+ /**
2051
+ * Get the 1x1 transparent GIF pixel as a Uint8Array.
2052
+ * Client sites can use this to serve the tracking pixel response.
2053
+ */
2054
+ static getTrackingPixel() {
2055
+ return new Uint8Array([
2056
+ 71,
2057
+ 73,
2058
+ 70,
2059
+ 56,
2060
+ 57,
2061
+ 97,
2062
+ 1,
2063
+ 0,
2064
+ 1,
2065
+ 0,
2066
+ 128,
2067
+ 0,
2068
+ 0,
2069
+ 255,
2070
+ 255,
2071
+ 255,
2072
+ 0,
2073
+ 0,
2074
+ 0,
2075
+ 33,
2076
+ 249,
2077
+ 4,
2078
+ 1,
2079
+ 0,
2080
+ 0,
2081
+ 0,
2082
+ 0,
2083
+ 44,
2084
+ 0,
2085
+ 0,
2086
+ 0,
2087
+ 0,
2088
+ 1,
2089
+ 0,
2090
+ 1,
2091
+ 0,
2092
+ 0,
2093
+ 2,
2094
+ 2,
2095
+ 68,
2096
+ 1,
2097
+ 0,
2098
+ 59
2099
+ ]);
2100
+ }
2018
2101
  };
2019
2102
 
2020
2103
  // src/perspect-api-client.ts
@@ -2262,6 +2345,29 @@ var normalizeMediaList = (rawMedia) => {
2262
2345
  }
2263
2346
  return rawMedia.filter(isMediaItem);
2264
2347
  };
2348
+ var describeMedia = (rawMedia) => {
2349
+ const isArray = Array.isArray(rawMedia);
2350
+ const nestedArray = isArray && rawMedia.length > 0 && Array.isArray(rawMedia[0]);
2351
+ const rawCount = isArray ? rawMedia.length : 0;
2352
+ const flattenedCount = nestedArray ? rawMedia.flat().length : rawCount;
2353
+ const firstSample = (() => {
2354
+ if (!isArray) {
2355
+ return rawMedia;
2356
+ }
2357
+ if (nestedArray) {
2358
+ const firstGroup = rawMedia.find(Array.isArray);
2359
+ return firstGroup ? firstGroup[0] : void 0;
2360
+ }
2361
+ return rawMedia[0];
2362
+ })();
2363
+ return {
2364
+ isArray,
2365
+ nestedArray,
2366
+ rawCount,
2367
+ flattenedCount,
2368
+ sampleType: firstSample ? typeof firstSample : "undefined"
2369
+ };
2370
+ };
2265
2371
  var normalizeQueryParamList = (value) => {
2266
2372
  if (value === void 0 || value === null) {
2267
2373
  return void 0;
@@ -2304,9 +2410,28 @@ var transformProduct = (perspectProduct, logger) => {
2304
2410
  ...rawProduct
2305
2411
  };
2306
2412
  };
2307
- var transformContent = (perspectContent) => {
2413
+ var transformContent = (perspectContent, logger) => {
2308
2414
  const raw = perspectContent ?? {};
2415
+ const mediaItems = normalizeMediaList(raw.media);
2416
+ const image = raw.image;
2417
+ if (raw.media !== void 0) {
2418
+ const mediaDetails = describeMedia(raw.media);
2419
+ const slugOrId = raw.slug ?? raw.id;
2420
+ if (mediaItems.length === 0 && (mediaDetails.rawCount > 0 || mediaDetails.flattenedCount > 0)) {
2421
+ log(logger, "debug", "[PerspectAPI] Content media present but failed normalization", {
2422
+ slug: slugOrId,
2423
+ ...mediaDetails
2424
+ });
2425
+ } else {
2426
+ log(logger, "debug", "[PerspectAPI] Content media normalization", {
2427
+ slug: slugOrId,
2428
+ ...mediaDetails,
2429
+ normalizedCount: mediaItems.length
2430
+ });
2431
+ }
2432
+ }
2309
2433
  return {
2434
+ ...raw,
2310
2435
  id: typeof raw.id === "string" ? raw.id : String(raw.id ?? ""),
2311
2436
  slug: raw.slug ?? `post-${raw.id ?? "unknown"}`,
2312
2437
  slug_prefix: raw.slug_prefix ?? (raw.pageType === "post" ? "blog" : "page"),
@@ -2321,7 +2446,8 @@ var transformContent = (perspectContent) => {
2321
2446
  published_date: raw.published_date,
2322
2447
  author: raw.author,
2323
2448
  tags: raw.tags,
2324
- image: raw.image
2449
+ image,
2450
+ media: mediaItems
2325
2451
  };
2326
2452
  };
2327
2453
  var getDefaultFallbackProducts = (siteName) => {
@@ -2507,7 +2633,7 @@ async function loadPages(options) {
2507
2633
  if (!response.data) {
2508
2634
  return [];
2509
2635
  }
2510
- return response.data.map((content) => transformContent(content));
2636
+ return response.data.map((content) => transformContent(content, logger));
2511
2637
  } catch (error) {
2512
2638
  log(logger, "error", "[PerspectAPI] Error loading pages", error);
2513
2639
  return [];
@@ -2541,7 +2667,7 @@ async function loadPosts(options) {
2541
2667
  log(logger, "warn", "[PerspectAPI] Posts response missing data");
2542
2668
  return [];
2543
2669
  }
2544
- return response.data.map((content) => transformContent(content));
2670
+ return response.data.map((content) => transformContent(content, logger));
2545
2671
  } catch (error) {
2546
2672
  log(logger, "error", "[PerspectAPI] Error loading posts", error);
2547
2673
  return [];
@@ -2567,7 +2693,7 @@ async function loadContentBySlug(options) {
2567
2693
  log(logger, "warn", `[PerspectAPI] Content not found for slug "${slug}"`);
2568
2694
  return null;
2569
2695
  }
2570
- return transformContent(response.data);
2696
+ return transformContent(response.data, logger);
2571
2697
  } catch (error) {
2572
2698
  log(logger, "error", `[PerspectAPI] Error loading content slug "${slug}"`, error);
2573
2699
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perspectapi-ts-sdk",
3
- "version": "2.3.4",
3
+ "version": "2.5.0",
4
4
  "description": "TypeScript SDK for PerspectAPI - Cloudflare Workers compatible",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -76,13 +76,13 @@ export class ContentClient extends BaseClient {
76
76
  console.log('[PerspectAPI Content] Cache details:', {
77
77
  cacheEnabled: !!this.cache,
78
78
  cachePolicy,
79
- tags: this.buildContentTags(siteName)
79
+ tags: this.buildContentTags(siteName, undefined, undefined, normalizedParams?.slug_prefix)
80
80
  });
81
81
 
82
82
  const result = await this.fetchWithCache<PaginatedResponse<Content>>(
83
83
  endpoint,
84
84
  normalizedParams,
85
- this.buildContentTags(siteName),
85
+ this.buildContentTags(siteName, undefined, undefined, normalizedParams?.slug_prefix),
86
86
  cachePolicy,
87
87
  async () => {
88
88
  console.log('[PerspectAPI Content] Making HTTP request:', {
@@ -229,7 +229,7 @@ export class ContentClient extends BaseClient {
229
229
  return this.create<Record<string, never>, Content>(`/content/${id}/duplicate`, {});
230
230
  }
231
231
 
232
- private buildContentTags(siteName?: string, slug?: string, id?: number): string[] {
232
+ private buildContentTags(siteName?: string, slug?: string, id?: number, slugPrefix?: string): string[] {
233
233
  const tags = new Set<string>(['content']);
234
234
  if (siteName) {
235
235
  tags.add(`content:site:${siteName}`);
@@ -240,6 +240,9 @@ export class ContentClient extends BaseClient {
240
240
  if (typeof id === 'number') {
241
241
  tags.add(`content:id:${id}`);
242
242
  }
243
+ if (slugPrefix) {
244
+ tags.add(`content:prefix:${slugPrefix}`);
245
+ }
243
246
  return Array.from(tags.values());
244
247
  }
245
248
  }
@@ -380,4 +380,56 @@ export class NewsletterClient extends BaseClient {
380
380
  data
381
381
  );
382
382
  }
383
+
384
+ // ============================================
385
+ // Tracking methods (for first-party tracking)
386
+ // ============================================
387
+
388
+ /**
389
+ * Track email open event.
390
+ * Called by client sites when their tracking pixel route is hit.
391
+ * The client site serves the pixel and calls this to record the open.
392
+ * @param siteName - The site name
393
+ * @param token - The tracking token from the pixel URL
394
+ */
395
+ async trackOpen(
396
+ siteName: string,
397
+ token: string
398
+ ): Promise<ApiResponse<{ success: boolean }>> {
399
+ return this.http.post(
400
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/open/${encodeURIComponent(token)}`))
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Track link click event.
406
+ * Called by client sites when their click tracking route is hit.
407
+ * The client site redirects to the destination and calls this to record the click.
408
+ * @param siteName - The site name
409
+ * @param token - The tracking token from the click URL
410
+ * @param url - The destination URL that was clicked
411
+ */
412
+ async trackClick(
413
+ siteName: string,
414
+ token: string,
415
+ url: string
416
+ ): Promise<ApiResponse<{ success: boolean }>> {
417
+ return this.http.post(
418
+ this.buildPath(this.newsletterEndpoint(siteName, `/newsletter/track/click/${encodeURIComponent(token)}`)),
419
+ { url }
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Get the 1x1 transparent GIF pixel as a Uint8Array.
425
+ * Client sites can use this to serve the tracking pixel response.
426
+ */
427
+ static getTrackingPixel(): Uint8Array {
428
+ return new Uint8Array([
429
+ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
430
+ 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00,
431
+ 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02,
432
+ 0x44, 0x01, 0x00, 0x3b,
433
+ ]);
434
+ }
383
435
  }
package/src/loaders.ts CHANGED
@@ -72,6 +72,40 @@ const normalizeMediaList = (rawMedia: unknown): MediaItem[] => {
72
72
  return (rawMedia as unknown[]).filter(isMediaItem);
73
73
  };
74
74
 
75
+ const describeMedia = (rawMedia: unknown) => {
76
+ const isArray = Array.isArray(rawMedia);
77
+ const nestedArray =
78
+ isArray &&
79
+ (rawMedia as unknown[]).length > 0 &&
80
+ Array.isArray((rawMedia as unknown[])[0]);
81
+
82
+ const rawCount = isArray ? (rawMedia as unknown[]).length : 0;
83
+ const flattenedCount = nestedArray
84
+ ? (rawMedia as unknown[]).flat().length
85
+ : rawCount;
86
+
87
+ const firstSample = (() => {
88
+ if (!isArray) {
89
+ return rawMedia;
90
+ }
91
+ if (nestedArray) {
92
+ const firstGroup = (rawMedia as unknown[]).find(Array.isArray) as
93
+ | unknown[]
94
+ | undefined;
95
+ return firstGroup ? firstGroup[0] : undefined;
96
+ }
97
+ return (rawMedia as unknown[])[0];
98
+ })();
99
+
100
+ return {
101
+ isArray,
102
+ nestedArray,
103
+ rawCount,
104
+ flattenedCount,
105
+ sampleType: firstSample ? typeof firstSample : 'undefined'
106
+ };
107
+ };
108
+
75
109
  const normalizeQueryParamList = (
76
110
  value: string | number | Array<string | number> | undefined
77
111
  ): string | undefined => {
@@ -148,11 +182,33 @@ export const transformProduct = (
148
182
  * Transform PerspectAPI content payload to a simplified BlogPost structure.
149
183
  */
150
184
  export const transformContent = (
151
- perspectContent: Content | (Content & Record<string, unknown>)
185
+ perspectContent: Content | (Content & Record<string, unknown>),
186
+ logger?: LoaderLogger
152
187
  ): BlogPost => {
153
188
  const raw = perspectContent ?? ({} as Content);
189
+ const mediaItems = normalizeMediaList((raw as any).media);
190
+ const image = (raw as any).image;
191
+
192
+ if ((raw as any).media !== undefined) {
193
+ const mediaDetails = describeMedia((raw as any).media);
194
+ const slugOrId = raw.slug ?? raw.id;
195
+
196
+ if (mediaItems.length === 0 && (mediaDetails.rawCount > 0 || mediaDetails.flattenedCount > 0)) {
197
+ log(logger, 'debug', '[PerspectAPI] Content media present but failed normalization', {
198
+ slug: slugOrId,
199
+ ...mediaDetails
200
+ });
201
+ } else {
202
+ log(logger, 'debug', '[PerspectAPI] Content media normalization', {
203
+ slug: slugOrId,
204
+ ...mediaDetails,
205
+ normalizedCount: mediaItems.length
206
+ });
207
+ }
208
+ }
154
209
 
155
210
  return {
211
+ ...raw,
156
212
  id: typeof raw.id === 'string' ? raw.id : String(raw.id ?? ''),
157
213
  slug: raw.slug ?? `post-${raw.id ?? 'unknown'}`,
158
214
  slug_prefix:
@@ -172,7 +228,8 @@ export const transformContent = (
172
228
  published_date: (raw as any).published_date,
173
229
  author: (raw as any).author,
174
230
  tags: (raw as any).tags,
175
- image: (raw as any).image
231
+ image,
232
+ media: mediaItems
176
233
  };
177
234
  };
178
235
 
@@ -463,7 +520,7 @@ export async function loadPages(
463
520
  return [];
464
521
  }
465
522
 
466
- return response.data.map(content => transformContent(content));
523
+ return response.data.map(content => transformContent(content, logger));
467
524
  } catch (error) {
468
525
  log(logger, 'error', '[PerspectAPI] Error loading pages', error);
469
526
  return [];
@@ -510,7 +567,7 @@ export async function loadPosts(
510
567
  return [];
511
568
  }
512
569
 
513
- return response.data.map(content => transformContent(content));
570
+ return response.data.map(content => transformContent(content, logger));
514
571
  } catch (error) {
515
572
  log(logger, 'error', '[PerspectAPI] Error loading posts', error);
516
573
  return [];
@@ -551,7 +608,7 @@ export async function loadContentBySlug(
551
608
  return null;
552
609
  }
553
610
 
554
- return transformContent(response.data);
611
+ return transformContent(response.data, logger);
555
612
  } catch (error) {
556
613
  log(logger, 'error', `[PerspectAPI] Error loading content slug "${slug}"`, error);
557
614
  return null;
@@ -226,6 +226,8 @@ export interface Content {
226
226
  slug_prefix?: string;
227
227
  pageStatus: ContentStatus;
228
228
  pageType: ContentType;
229
+ media?: MediaItem[] | MediaItem[][];
230
+ image?: string;
229
231
  userId?: number;
230
232
  organizationId?: number;
231
233
  createdAt: string;
@@ -338,6 +340,7 @@ export interface BlogPost {
338
340
  content?: string;
339
341
  excerpt?: string;
340
342
  image?: string;
343
+ media?: MediaItem[] | MediaItem[][];
341
344
  published?: boolean;
342
345
  created_at?: string;
343
346
  updated_at?: string;