@vdaluz/astro-affiliate 0.6.0 → 0.8.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/README.md CHANGED
@@ -15,7 +15,7 @@ Alternatively, a pinned https tarball from a tag works too, with no registry inv
15
15
  ```jsonc
16
16
  // package.json
17
17
  "dependencies": {
18
- "@vdaluz/astro-affiliate": "https://github.com/vdaluz/astro-affiliate/archive/refs/tags/v0.6.0.tar.gz"
18
+ "@vdaluz/astro-affiliate": "https://github.com/vdaluz/astro-affiliate/archive/refs/tags/v0.7.0.tar.gz"
19
19
  }
20
20
  ```
21
21
 
@@ -159,6 +159,20 @@ fails the build. **Compliance by construction:** every program actually used by
159
159
  links in a post must be declared in that post's `affiliates:` frontmatter array, or the build
160
160
  fails with a clear error - there's no way to ship an affiliate link without its disclosure.
161
161
 
162
+ The plugin also writes the post's own catalog keys, in document order with duplicates removed,
163
+ to `affiliateKeys` in the page's frontmatter - useful for a consumer that wants to know "which
164
+ catalog items did this post actually link to" without re-parsing markdown (e.g. to seed a
165
+ related-products widget from a post's own links before falling back to other sources). It's
166
+ `undefined`, not an empty array, on a post with no affiliate links - read it as
167
+ `affiliateKeys ?? []`. Access it via Astro's `remarkPluginFrontmatter`:
168
+
169
+ ```astro
170
+ ---
171
+ const { remarkPluginFrontmatter } = await render(entry);
172
+ const usedKeys = remarkPluginFrontmatter.affiliateKeys ?? [];
173
+ ---
174
+ ```
175
+
162
176
  ## `.astro` pages (`<AffiliateLink>`)
163
177
 
164
178
  For gear pages or other non-markdown content:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vdaluz/astro-affiliate",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "FTC-compliant affiliate-link catalog resolver and disclosure components - proven in production on vdaluz.com and imperfectsystems.com.",
5
5
  "keywords": [
6
6
  "astro",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { defineAffiliateConfig } from './lib/config.ts';
2
2
  export { resolveAffiliate } from './lib/resolve.ts';
3
3
  export { buildChannelRewriteMap, rewriteAffiliateLinksForChannel } from './lib/channel-rewrite.ts';
4
+ export { resolveAffiliateCards } from './lib/affiliate-cards.ts';
4
5
  export type {
5
6
  AffiliateConfig,
6
7
  AffiliateProgram,
@@ -11,3 +12,8 @@ export type {
11
12
  CatalogEntryLink,
12
13
  ResolvedAffiliate,
13
14
  } from './lib/types.ts';
15
+ export type {
16
+ AffiliateCardDisplay,
17
+ AffiliateCardEntry,
18
+ ResolveAffiliateCardsInput,
19
+ } from './lib/affiliate-cards.ts';
@@ -0,0 +1,136 @@
1
+ export interface AffiliateCardDisplay {
2
+ name: string;
3
+ blurb: string;
4
+ /** Root-relative image path. Optional - a card with no image just renders without one. */
5
+ image?: string;
6
+ postCategories?: string[];
7
+ }
8
+
9
+ export interface AffiliateCardEntry {
10
+ key: string;
11
+ display: AffiliateCardDisplay;
12
+ }
13
+
14
+ export interface ResolveAffiliateCardsInput {
15
+ /** The post's own affiliate:key links, in document order (see remarkAffiliate's affiliateKeys). */
16
+ postKeys: string[];
17
+ postCategory: string;
18
+ /** Locale-independent post identifier - seeds the deterministic shuffle so a
19
+ * post shows the same cards on every rebuild and in every locale, while
20
+ * different posts land on different combinations. */
21
+ postSlug: string;
22
+ /** Display data for every catalog key eligible to appear as a card, keyed
23
+ * by the same catalog key used in the consumer's affiliate.ts. Required,
24
+ * not defaulted - this package stays data-free by design (see AST-36's
25
+ * cancellation for why a shared package should never bake in a consumer's
26
+ * own catalog/display data). */
27
+ display: Record<string, AffiliateCardDisplay>;
28
+ /** Always-eligible last-resort fill tier, as catalog keys into `display`. */
29
+ generic: string[];
30
+ }
31
+
32
+ const MAX_CARDS = 3;
33
+
34
+ function hashString(value: string): number {
35
+ let hash = 0x811c9dc5;
36
+ for (let i = 0; i < value.length; i++) {
37
+ hash ^= value.charCodeAt(i);
38
+ hash = Math.imul(hash, 0x01000193);
39
+ }
40
+ return hash >>> 0;
41
+ }
42
+
43
+ /** mulberry32 - small deterministic PRNG seeded from a 32-bit int. */
44
+ function mulberry32(seed: number): () => number {
45
+ let state = seed;
46
+ return () => {
47
+ state |= 0;
48
+ state = (state + 0x6d2b79f5) | 0;
49
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
50
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
51
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
52
+ };
53
+ }
54
+
55
+ function shuffle<T>(items: T[], rng: () => number): T[] {
56
+ const result = [...items];
57
+ for (let i = result.length - 1; i > 0; i--) {
58
+ const j = Math.floor(rng() * (i + 1));
59
+ [result[i], result[j]] = [result[j], result[i]];
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Resolves exactly up to MAX_CARDS entries for a post, in priority order:
66
+ *
67
+ * 1. The post's own inline links, in document order (first MAX_CARDS if it has
68
+ * more than that) - never randomized, these are real editorial choices.
69
+ * 2. Category-matched catalog items, shuffled with a PRNG seeded from postSlug,
70
+ * filling every remaining slot except the last one.
71
+ * 3. The last remaining slot: any category-matched items left over from (2) plus
72
+ * the generic always-eligible defaults, pooled together and shuffled with the
73
+ * same seeded PRNG. This applies even when the category pool alone could have
74
+ * filled every remaining slot - a category with several matches still yields
75
+ * a generic default in the mix sometimes, by design.
76
+ *
77
+ * Deduplicates by catalog key across all tiers, so a post inlining a key that's
78
+ * also a category match or generic default only shows it once.
79
+ *
80
+ * The shuffle is deterministic per postSlug: the same post always resolves to
81
+ * the same card set across rebuilds and locales, but different posts in the
82
+ * same category land on different combinations.
83
+ *
84
+ * Side-effect-free and consumer-config-free by design - the piece meant to be
85
+ * shared across every site that wants this behavior, with display/generic
86
+ * data supplied by each consumer's own config.
87
+ */
88
+ export function resolveAffiliateCards({
89
+ postKeys,
90
+ postCategory,
91
+ postSlug,
92
+ display,
93
+ generic,
94
+ }: ResolveAffiliateCardsInput): AffiliateCardEntry[] {
95
+ const seen = new Set<string>();
96
+ const result: AffiliateCardEntry[] = [];
97
+ const rng = mulberry32(hashString(postSlug));
98
+
99
+ function tryAdd(key: string) {
100
+ if (result.length >= MAX_CARDS || seen.has(key)) return;
101
+ const entry = display[key];
102
+ if (!entry) return;
103
+ seen.add(key);
104
+ result.push({ key, display: entry });
105
+ }
106
+
107
+ for (const key of postKeys) {
108
+ if (result.length >= MAX_CARDS) break;
109
+ tryAdd(key);
110
+ }
111
+
112
+ if (result.length < MAX_CARDS) {
113
+ const categoryCandidates = shuffle(
114
+ Object.entries(display)
115
+ .filter(([key, entry]) => !seen.has(key) && entry.postCategories?.includes(postCategory))
116
+ .map(([key]) => key),
117
+ rng
118
+ );
119
+
120
+ const slotsRemaining = MAX_CARDS - result.length;
121
+ const categoryOnlySlots = Math.max(0, slotsRemaining - 1);
122
+ for (const key of categoryCandidates.slice(0, categoryOnlySlots)) tryAdd(key);
123
+
124
+ if (result.length < MAX_CARDS) {
125
+ const leftoverCategoryCandidates = categoryCandidates.slice(categoryOnlySlots);
126
+ const genericCandidates = generic.filter((key) => !seen.has(key));
127
+ const finalPool = shuffle([...leftoverCategoryCandidates, ...genericCandidates], rng);
128
+ for (const key of finalPool) {
129
+ if (result.length >= MAX_CARDS) break;
130
+ tryAdd(key);
131
+ }
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
package/src/lib/remark.ts CHANGED
@@ -15,6 +15,7 @@ interface VFileWithAstroFrontmatter {
15
15
  astro?: {
16
16
  frontmatter?: {
17
17
  affiliates?: string[];
18
+ affiliateKeys?: string[];
18
19
  };
19
20
  };
20
21
  };
@@ -36,6 +37,11 @@ function collectAffiliateLinkNodes(node: MdastNode, out: MdastNode[]) {
36
37
  * `affiliates:` frontmatter (so `<AffiliateDisclosure>` knows to render it).
37
38
  * An unknown key, or a used program missing from frontmatter, fails the build.
38
39
  *
40
+ * Also writes the post's own affiliate catalog keys, in document order with
41
+ * duplicates removed, to `affiliateKeys` in the page's frontmatter (via
42
+ * `remarkPluginFrontmatter`) - undefined, not an empty array, on a post with
43
+ * no affiliate links, so a consumer should read it as `affiliateKeys ?? []`.
44
+ *
39
45
  * import { remarkAffiliate } from '@vdaluz/astro-affiliate/remark';
40
46
  *
41
47
  * export default defineConfig({
@@ -50,12 +56,14 @@ export function remarkAffiliate(config: AffiliateConfig) {
50
56
 
51
57
  const declaredAffiliates = file.data?.astro?.frontmatter?.affiliates ?? [];
52
58
  const usedPrograms = new Set<string>();
59
+ const usedKeys: string[] = [];
53
60
 
54
61
  for (const node of linkNodes) {
55
62
  const key = node.url!.slice(AFFILIATE_PREFIX.length);
56
63
  const { url, program } = resolveAffiliate(config, key);
57
64
  node.url = url;
58
65
  usedPrograms.add(program);
66
+ if (!usedKeys.includes(key)) usedKeys.push(key);
59
67
  }
60
68
 
61
69
  for (const program of usedPrograms) {
@@ -65,5 +73,10 @@ export function remarkAffiliate(config: AffiliateConfig) {
65
73
  );
66
74
  }
67
75
  }
76
+
77
+ file.data ??= {};
78
+ file.data.astro ??= {};
79
+ file.data.astro.frontmatter ??= {};
80
+ file.data.astro.frontmatter.affiliateKeys = usedKeys;
68
81
  };
69
82
  }