@vdaluz/astro-affiliate 0.7.0 → 0.8.1
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 +8 -15
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/lib/affiliate-cards.ts +136 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @vdaluz/astro-affiliate
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@vdaluz/astro-affiliate)
|
|
3
4
|
[](https://github.com/vdaluz/astro-affiliate/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
7
|
Affiliate links need FTC-compliant disclosure, per-channel tracking tags for reposts and syndication, and a way to keep the two in sync so a disclosure can't silently drift from the links it's supposed to cover. `@vdaluz/astro-affiliate` is a catalog resolver and disclosure component pair that enforces that link: the remark plugin fails the build if a post uses an affiliate link without declaring its program in frontmatter. Ships raw `.astro` and `.ts` - the consuming app's Astro/Vite compiles them (no prebuild step). Machinery only: the package carries no affiliate data itself, each site supplies its own catalog, tracking tags, and disclosure text via config. Proven in production on [vdaluz.com](https://vdaluz.com) and [imperfectsystems.com](https://imperfectsystems.com) - see [Consumers](#consumers).
|
|
6
8
|
|
|
@@ -10,21 +12,6 @@ Affiliate links need FTC-compliant disclosure, per-channel tracking tags for rep
|
|
|
10
12
|
npm install @vdaluz/astro-affiliate
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
Alternatively, a pinned https tarball from a tag works too, with no registry involved:
|
|
14
|
-
|
|
15
|
-
```jsonc
|
|
16
|
-
// package.json
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@vdaluz/astro-affiliate": "https://github.com/vdaluz/astro-affiliate/archive/refs/tags/v0.7.0.tar.gz"
|
|
19
|
-
}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
> **Why a tarball, not `github:vdaluz/astro-affiliate#v0.2.0`?** npm canonicalizes GitHub
|
|
23
|
-
> shorthand (and even an explicit `git+https://` URL) to `git+ssh://` in the lockfile.
|
|
24
|
-
> CI runners (e.g. Cloudflare Pages/Workers) have no SSH key, so `npm ci` would fail to
|
|
25
|
-
> clone it. The `/archive/refs/tags/<tag>.tar.gz` URL is anonymous https with an integrity
|
|
26
|
-
> hash in the lockfile, it just works in CI. Bump the tag in the URL to upgrade.
|
|
27
|
-
|
|
28
15
|
Peer dependency: `astro` >= 6.
|
|
29
16
|
|
|
30
17
|
## Define your config
|
|
@@ -261,3 +248,9 @@ Issues welcome. PRs by discussion - open an issue first for anything beyond a ty
|
|
|
261
248
|
|
|
262
249
|
- [vdaluz.com](https://vdaluz.com)
|
|
263
250
|
- [imperfectsystems.com](https://imperfectsystems.com)
|
|
251
|
+
- [freetoolbox.net](https://freetoolbox.net)
|
|
252
|
+
- [vicstradamus.com](https://vicstradamus.com)
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT
|
package/package.json
CHANGED
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
|
+
}
|