@vdaluz/astro-affiliate 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Da Luz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # @vdaluz/astro-affiliate
2
+
3
+ [![CI](https://github.com/vdaluz/astro-affiliate/actions/workflows/ci.yml/badge.svg)](https://github.com/vdaluz/astro-affiliate/actions/workflows/ci.yml)
4
+
5
+ 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
+
7
+ ## Install
8
+
9
+ Pinned https tarball from a tag (no registry needed):
10
+
11
+ ```jsonc
12
+ // package.json
13
+ "dependencies": {
14
+ "@vdaluz/astro-affiliate": "https://github.com/vdaluz/astro-affiliate/archive/refs/tags/v0.4.0.tar.gz"
15
+ }
16
+ ```
17
+
18
+ > **Why a tarball, not `github:vdaluz/astro-affiliate#v0.2.0`?** npm canonicalizes GitHub
19
+ > shorthand (and even an explicit `git+https://` URL) to `git+ssh://` in the lockfile.
20
+ > CI runners (e.g. Cloudflare Pages/Workers) have no SSH key, so `npm ci` would fail to
21
+ > clone it. The `/archive/refs/tags/<tag>.tar.gz` URL is anonymous https with an integrity
22
+ > hash in the lockfile, it just works in CI. Bump the tag in the URL to upgrade. This is the
23
+ > only supported install path; there's no npm registry package (tag-tarball works for anyone,
24
+ > no registry auth needed).
25
+
26
+ Peer dependency: `astro` >= 6.
27
+
28
+ ## Define your config
29
+
30
+ Two top-level pieces: `programs` (disclosure text + how to resolve a program's links) and
31
+ `catalog` (a single flat list of every item, each pointing at the program that resolves it).
32
+
33
+ ```ts
34
+ // src/config/affiliate.ts
35
+ import { defineAffiliateConfig } from '@vdaluz/astro-affiliate';
36
+
37
+ export const affiliate = defineAffiliateConfig({
38
+ programs: {
39
+ amazon: {
40
+ kind: 'amazon',
41
+ tag: 'vdaluz-20',
42
+ disclosure: 'As an Amazon Associate, I earn from qualifying purchases.',
43
+ },
44
+ proton: {
45
+ kind: 'links',
46
+ disclosure: 'As a Proton Partner, I earn from qualifying purchases.',
47
+ links: { pass: 'https://go.getproton.me/SH2FI' },
48
+ },
49
+ },
50
+ catalog: {
51
+ atomicHabits: { program: 'amazon', asin: 'B07RFSSYBH' },
52
+ protonPass: { program: 'proton', link: 'pass' },
53
+ },
54
+ });
55
+ ```
56
+
57
+ Two program kinds:
58
+
59
+ - **`amazon`** - supply a site tag once; catalog entries reference it with just an ASIN. The URL
60
+ is constructed as `https://<domain>/dp/<ASIN>/ref=nosim?tag=<tag>`, where `domain` defaults to
61
+ `www.amazon.com`. For a locale-specific marketplace (e.g. Brazil), declare a second `amazon`-kind
62
+ program with its own `domain` and `tag`, and point locale-specific catalog entries at it:
63
+
64
+ ```ts
65
+ programs: {
66
+ amazon: { kind: 'amazon', tag: 'vdaluz-20', disclosure: '...' },
67
+ amazonBr: { kind: 'amazon', domain: 'www.amazon.com.br', tag: 'vdaluz-br-20', disclosure: '...' },
68
+ },
69
+ catalog: {
70
+ atomicHabits: { program: 'amazon', asin: 'B07RFSSYBH' },
71
+ atomicHabitsBr: { program: 'amazonBr', asin: 'B07RFSSYBH' },
72
+ },
73
+ ```
74
+ - **`links`** - a flat link-key-to-URL map on the program; catalog entries reference one of those
75
+ keys.
76
+
77
+ Catalog keys are flat and unprefixed (`atomicHabits`, not `amazon.atomicHabits`) - that's what
78
+ markdown links and `<AffiliateLink>` use directly. Each entry accepts an optional `category`
79
+ string, ignored by resolution, for a consuming app's own filtering (e.g. a gear page listing only
80
+ `category: 'gear'` entries).
81
+
82
+ ## Per-channel tags (reposts, syndication)
83
+
84
+ A program can declare a different tag/link for a named channel - e.g. a distinct Amazon tracking
85
+ ID for content republished to Medium, so Associates reporting can tell channel traffic apart from
86
+ the canonical site (Amazon's own tracking IDs exist for exactly this: up to 100 per account,
87
+ independently reportable):
88
+
89
+ ```ts
90
+ programs: {
91
+ amazon: {
92
+ kind: 'amazon',
93
+ tag: 'vdaluz-20',
94
+ channelTags: { medium: 'vdaluz-medium-20' },
95
+ disclosure: '...',
96
+ },
97
+ proton: {
98
+ kind: 'links',
99
+ disclosure: '...',
100
+ links: { pass: 'https://go.getproton.me/SH2FI' },
101
+ channelLinks: { medium: { pass: 'https://go.getproton.me/MEDIUM' } },
102
+ },
103
+ },
104
+ ```
105
+
106
+ A channel not listed in `channelTags`/`channelLinks` falls back to the program's default - passing
107
+ an unconfigured channel is a no-op, not an error.
108
+
109
+ Two ways to consume a channel, depending on where the affiliate link lives:
110
+
111
+ - **`resolveAffiliate(config, key, channel)`** - pass the channel directly when resolving at
112
+ request/render time (e.g. inside a non-prerendered `.astro` page or component).
113
+ - **`rewriteAffiliateLinksForChannel(content, config, channel)`** - for content whose affiliate
114
+ links were already resolved to the default channel at build time (markdown `affiliate:key` links
115
+ compiled once via `remarkAffiliate`, baked into a prerendered page). Retargets the rendered
116
+ output after the fact - via a middleware, an edge function, or whatever else drives the specific
117
+ repost flow - by exact string substitution of each catalog entry's default URL, not a generic
118
+ regex, so it can't accidentally touch unrelated content. `buildChannelRewriteMap(config, channel)`
119
+ exposes the underlying default-URL -> channel-URL map directly, for callers that want to do their
120
+ own substitution.
121
+
122
+ ## Markdown links (`remarkAffiliate`)
123
+
124
+ Wire the plugin into `astro.config.mjs`:
125
+
126
+ ```js
127
+ import { remarkAffiliate } from '@vdaluz/astro-affiliate/remark';
128
+ import { affiliate } from './src/config/affiliate';
129
+
130
+ export default defineConfig({
131
+ markdown: {
132
+ remarkPlugins: [[remarkAffiliate, affiliate]],
133
+ },
134
+ });
135
+ ```
136
+
137
+ > **Use the `[plugin, options]` tuple, not `remarkAffiliate(affiliate)` pre-invoked.** Astro/unified
138
+ > calls the plugin function itself with the options; passing an already-invoked transformer means
139
+ > unified calls *that* with no arguments as if it were the attacher, which silently no-ops instead
140
+ > of rewriting anything - the build stays green with `affiliate:key` links left untouched in the
141
+ > output. Always verify by checking rendered HTML for the real resolved URL, not just a passing
142
+ > build.
143
+
144
+ Then in a post's markdown body:
145
+
146
+ ```md
147
+ ---
148
+ title: My post
149
+ affiliates: [amazon]
150
+ ---
151
+
152
+ I use [Atomic Habits](affiliate:atomicHabits) to stay on track.
153
+ ```
154
+
155
+ `affiliate:atomicHabits` is rewritten to the real resolved URL at build time. An unknown key
156
+ fails the build. **Compliance by construction:** every program actually used by `affiliate:`
157
+ links in a post must be declared in that post's `affiliates:` frontmatter array, or the build
158
+ fails with a clear error - there's no way to ship an affiliate link without its disclosure.
159
+
160
+ ## `.astro` pages (`<AffiliateLink>`)
161
+
162
+ For gear pages or other non-markdown content:
163
+
164
+ ```astro
165
+ ---
166
+ import AffiliateLink from '@vdaluz/astro-affiliate/AffiliateLink.astro';
167
+ import { affiliate } from '../config/affiliate';
168
+ ---
169
+
170
+ <AffiliateLink config={affiliate} affiliateKey="atomicHabits">
171
+ Atomic Habits
172
+ </AffiliateLink>
173
+ ```
174
+
175
+ Renders `target="_blank" rel="noopener noreferrer sponsored"` by default. Pass `class` to style it.
176
+
177
+ ## Disclosure (`<AffiliateDisclosure>`)
178
+
179
+ Render at the **top** of the post body, above any affiliate links (FTC: disclosure before links,
180
+ above the fold):
181
+
182
+ ```astro
183
+ ---
184
+ import AffiliateDisclosure from '@vdaluz/astro-affiliate/AffiliateDisclosure.astro';
185
+ import { affiliate } from '../config/affiliate';
186
+
187
+ const { affiliates = [] } = entry.data;
188
+ ---
189
+
190
+ <AffiliateDisclosure config={affiliate} affiliates={affiliates} />
191
+ ```
192
+
193
+ Renders one paragraph joining the disclosure text for every program in `affiliates`, or nothing
194
+ if the array is empty. Default styling is `text-sm text-muted italic`; pass `class` to override,
195
+ see [Per-app glue](#per-app-glue) for the token variables this assumes.
196
+
197
+ ### Localized disclosure text
198
+
199
+ A program's `disclosure` accepts either a plain string or a `Localized` value - `{ default: string, [locale]: string }` - for sites publishing in more than one language:
200
+
201
+ ```ts
202
+ programs: {
203
+ amazon: {
204
+ kind: 'amazon',
205
+ tag: 'vdaluz-20',
206
+ disclosure: {
207
+ default: 'As an Amazon Associate, I earn from qualifying purchases.',
208
+ es: 'Como Afiliado de Amazon, obtengo ingresos por las compras que califican.',
209
+ },
210
+ },
211
+ },
212
+ ```
213
+
214
+ Pass `locale` to `<AffiliateDisclosure>` to select the matching entry; it falls back to `default` when the given locale has no entry, or when `disclosure` is a plain string:
215
+
216
+ ```astro
217
+ <AffiliateDisclosure config={affiliate} affiliates={affiliates} locale={locale} />
218
+ ```
219
+
220
+ ## Per-app glue
221
+
222
+ This is a component library, not a drop-in catalog. Each consuming app owns:
223
+
224
+ - Its own `affiliate` config (programs, catalog, tags, disclosure text). Nothing is shared across
225
+ sites.
226
+ - The `affiliates:` field in its content collection schema (add `affiliates: z.array(z.string()).optional()`).
227
+ - Token CSS variables referenced by the default disclosure styling: `muted`. See
228
+ [`@vdaluz/astro-blog`'s `tokens.example.css`](https://github.com/vdaluz/astro-blog) for the
229
+ full token set these sites already share.
230
+
231
+ ## Release process
232
+
233
+ Tag-pinned tarballs, no registry:
234
+
235
+ 1. Test before tagging: `npm pack`, install the tarball into a scratch Astro app (or one of the
236
+ consumers locally), `astro check && astro build`.
237
+ 2. Bump `version` in `package.json`, commit.
238
+ 3. Tag `vX.Y.Z` and push the tag. **The tag must be public before any consumer CI references
239
+ it**, the tarball URL 404s otherwise.
240
+ 4. Bump the tag in each consumer's `package.json` dependency URL.
241
+
242
+ ## Contributing
243
+
244
+ Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.
245
+
246
+ ## Consumers
247
+
248
+ - [vdaluz.com](https://vdaluz.com)
249
+ - [imperfectsystems.com](https://imperfectsystems.com)
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@vdaluz/astro-affiliate",
3
+ "version": "0.5.0",
4
+ "description": "FTC-compliant affiliate-link catalog resolver and disclosure components - proven in production on vdaluz.com and imperfectsystems.com.",
5
+ "keywords": [
6
+ "astro",
7
+ "astro-component",
8
+ "affiliate-marketing",
9
+ "ftc-disclosure",
10
+ "amazon-associates"
11
+ ],
12
+ "homepage": "https://github.com/vdaluz/astro-affiliate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/vdaluz/astro-affiliate/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/vdaluz/astro-affiliate.git"
19
+ },
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "check": "astro check",
28
+ "test": "node --test"
29
+ },
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "exports": {
34
+ ".": "./src/index.ts",
35
+ "./remark": "./src/lib/remark.ts",
36
+ "./AffiliateLink.astro": "./src/components/AffiliateLink.astro",
37
+ "./AffiliateDisclosure.astro": "./src/components/AffiliateDisclosure.astro"
38
+ },
39
+ "peerDependencies": {
40
+ "astro": ">=6.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@astrojs/check": "^0.9.9",
44
+ "@types/node": "^26.1.1",
45
+ "astro": "^7.1.3",
46
+ "typescript": "^6.0.3"
47
+ }
48
+ }
@@ -0,0 +1,22 @@
1
+ ---
2
+ import type { AffiliateConfig } from '../lib/types';
3
+ import { resolveDisclosures } from '../lib/disclosures';
4
+
5
+ interface Props {
6
+ config: AffiliateConfig;
7
+ /** Post's `affiliates:` frontmatter array. */
8
+ affiliates: string[];
9
+ class?: string;
10
+ locale?: string;
11
+ }
12
+
13
+ const { config, affiliates, class: className, locale } = Astro.props;
14
+
15
+ const disclosures = resolveDisclosures(config, affiliates, locale);
16
+ ---
17
+
18
+ {
19
+ disclosures.length > 0 && (
20
+ <p class={className ?? 'text-sm text-muted italic'}>{disclosures.join(' ')}</p>
21
+ )
22
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { AffiliateConfig } from '../lib/types';
3
+ import { resolveAffiliate } from '../lib/resolve';
4
+
5
+ interface Props {
6
+ config: AffiliateConfig;
7
+ /** Flat catalog key, e.g. 'atomicHabits'. */
8
+ affiliateKey: string;
9
+ class?: string;
10
+ }
11
+
12
+ const { config, affiliateKey, class: className } = Astro.props;
13
+ const { url } = resolveAffiliate(config, affiliateKey);
14
+ ---
15
+
16
+ <a href={url} target="_blank" rel="noopener noreferrer sponsored" class={className}>
17
+ <slot />
18
+ </a>
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { defineAffiliateConfig } from './lib/config.ts';
2
+ export { resolveAffiliate } from './lib/resolve.ts';
3
+ export { buildChannelRewriteMap, rewriteAffiliateLinksForChannel } from './lib/channel-rewrite.ts';
4
+ export type {
5
+ AffiliateConfig,
6
+ AffiliateProgram,
7
+ AffiliateProgramAmazon,
8
+ AffiliateProgramLinks,
9
+ CatalogEntry,
10
+ CatalogEntryAmazon,
11
+ CatalogEntryLink,
12
+ ResolvedAffiliate,
13
+ } from './lib/types.ts';
@@ -0,0 +1,46 @@
1
+ import type { AffiliateConfig } from './types.ts';
2
+ import { resolveAffiliate } from './resolve.ts';
3
+
4
+ /**
5
+ * Maps each catalog entry's default-resolved URL to its channel-specific URL,
6
+ * for every entry where the channel actually differs from default. Markdown
7
+ * `affiliate:key` links are resolved once at Astro build time via
8
+ * `remarkAffiliate`, so a repost target (Medium, or any future channel) can't
9
+ * get its own tag through a second compile - this map lets already-rendered
10
+ * content be retargeted after the fact instead.
11
+ *
12
+ * Rendered HTML escapes `&` as `&amp;` in href attributes, so a default URL
13
+ * with an ampersand (any URL with more than one query param) never matches
14
+ * the raw map key in prerendered output. Add the HTML-escaped variant of
15
+ * each such entry too, mapped to the escaped channel URL, so substitution
16
+ * against real rendered HTML works regardless of query param count.
17
+ */
18
+ export function buildChannelRewriteMap(config: AffiliateConfig, channel: string): Record<string, string> {
19
+ const map: Record<string, string> = {};
20
+ for (const key of Object.keys(config.catalog)) {
21
+ const defaultResolved = resolveAffiliate(config, key);
22
+ const channelResolved = resolveAffiliate(config, key, channel);
23
+ if (defaultResolved.url !== channelResolved.url) {
24
+ map[defaultResolved.url] = channelResolved.url;
25
+ const escapedDefault = defaultResolved.url.replace(/&/g, '&amp;');
26
+ if (escapedDefault !== defaultResolved.url) {
27
+ map[escapedDefault] = channelResolved.url.replace(/&/g, '&amp;');
28
+ }
29
+ }
30
+ }
31
+ return map;
32
+ }
33
+
34
+ /**
35
+ * Rewrites already-rendered content (e.g. a prerendered post's HTML) to swap
36
+ * default-channel affiliate URLs for a specific channel's URLs. Exact
37
+ * string replacement per catalog entry, not a generic regex over "tag=" or
38
+ * similar - safe against matching unrelated content.
39
+ */
40
+ export function rewriteAffiliateLinksForChannel(content: string, config: AffiliateConfig, channel: string): string {
41
+ const map = buildChannelRewriteMap(config, channel);
42
+ return Object.entries(map).reduce(
43
+ (result, [defaultUrl, channelUrl]) => result.split(defaultUrl).join(channelUrl),
44
+ content
45
+ );
46
+ }
@@ -0,0 +1,30 @@
1
+ import type { AffiliateConfig } from './types.ts';
2
+
3
+ /**
4
+ * Identity function for authoring a site's affiliate config with type checking
5
+ * and editor autocomplete. Use in the consuming app's affiliate config:
6
+ *
7
+ * import { defineAffiliateConfig } from '@vdaluz/astro-affiliate';
8
+ *
9
+ * export const affiliate = defineAffiliateConfig({
10
+ * programs: {
11
+ * amazon: {
12
+ * kind: 'amazon',
13
+ * tag: 'vdaluz-20',
14
+ * disclosure: 'As an Amazon Associate, I earn from qualifying purchases.',
15
+ * },
16
+ * proton: {
17
+ * kind: 'links',
18
+ * disclosure: 'As a Proton Partner, I earn from qualifying purchases.',
19
+ * links: { pass: 'https://go.getproton.me/SH2FI' },
20
+ * },
21
+ * },
22
+ * catalog: {
23
+ * atomicHabits: { program: 'amazon', asin: 'B07RFSSYBH' },
24
+ * protonPass: { program: 'proton', link: 'pass' },
25
+ * },
26
+ * });
27
+ */
28
+ export function defineAffiliateConfig(config: AffiliateConfig): AffiliateConfig {
29
+ return config;
30
+ }
@@ -0,0 +1,20 @@
1
+ import type { AffiliateConfig } from './types.ts';
2
+ import { resolveLocalized } from './i18n.ts';
3
+
4
+ /**
5
+ * Resolves a post's `affiliates:` frontmatter names to their disclosure text.
6
+ * Throws on an unknown program name instead of silently omitting its
7
+ * disclosure - a typo'd/renamed program must fail the build, not ship a
8
+ * page that uses the program without disclosing it.
9
+ */
10
+ export function resolveDisclosures(config: AffiliateConfig, affiliates: string[], locale?: string): string[] {
11
+ return affiliates.map((name) => {
12
+ const program = config.programs[name];
13
+ if (!program) {
14
+ throw new Error(
15
+ `Unknown affiliate program "${name}" in "affiliates:" frontmatter. Known programs: ${Object.keys(config.programs).join(', ') || '(none configured)'}.`
16
+ );
17
+ }
18
+ return resolveLocalized(program.disclosure, locale);
19
+ });
20
+ }
@@ -0,0 +1,6 @@
1
+ export type Localized = string | ({ default: string } & Record<string, string>);
2
+
3
+ export function resolveLocalized(text: Localized, locale?: string): string {
4
+ if (typeof text === 'string') return text;
5
+ return (locale && text[locale]) || text.default;
6
+ }
@@ -0,0 +1,69 @@
1
+ import type { AffiliateConfig } from './types.ts';
2
+ import { resolveAffiliate } from './resolve.ts';
3
+
4
+ const AFFILIATE_PREFIX = 'affiliate:';
5
+
6
+ /** Minimal shape this plugin cares about - avoids a mdast-util-* type dependency. */
7
+ interface MdastNode {
8
+ type: string;
9
+ url?: string;
10
+ children?: MdastNode[];
11
+ }
12
+
13
+ interface VFileWithAstroFrontmatter {
14
+ data?: {
15
+ astro?: {
16
+ frontmatter?: {
17
+ affiliates?: string[];
18
+ };
19
+ };
20
+ };
21
+ }
22
+
23
+ function collectAffiliateLinkNodes(node: MdastNode, out: MdastNode[]) {
24
+ if (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith(AFFILIATE_PREFIX)) {
25
+ out.push(node);
26
+ }
27
+ if (node.children) {
28
+ for (const child of node.children) collectAffiliateLinkNodes(child, out);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Remark plugin: rewrites `[text](affiliate:catalogKey)` links to their real
34
+ * resolved URL at build time, and enforces FTC compliance by construction -
35
+ * every program actually used by a post must be declared in that post's
36
+ * `affiliates:` frontmatter (so `<AffiliateDisclosure>` knows to render it).
37
+ * An unknown key, or a used program missing from frontmatter, fails the build.
38
+ *
39
+ * import { remarkAffiliate } from '@vdaluz/astro-affiliate/remark';
40
+ *
41
+ * export default defineConfig({
42
+ * markdown: { remarkPlugins: [[remarkAffiliate, affiliate]] },
43
+ * });
44
+ */
45
+ export function remarkAffiliate(config: AffiliateConfig) {
46
+ return (tree: MdastNode, file: VFileWithAstroFrontmatter) => {
47
+ const linkNodes: MdastNode[] = [];
48
+ collectAffiliateLinkNodes(tree, linkNodes);
49
+ if (linkNodes.length === 0) return;
50
+
51
+ const declaredAffiliates = file.data?.astro?.frontmatter?.affiliates ?? [];
52
+ const usedPrograms = new Set<string>();
53
+
54
+ for (const node of linkNodes) {
55
+ const key = node.url!.slice(AFFILIATE_PREFIX.length);
56
+ const { url, program } = resolveAffiliate(config, key);
57
+ node.url = url;
58
+ usedPrograms.add(program);
59
+ }
60
+
61
+ for (const program of usedPrograms) {
62
+ if (!declaredAffiliates.includes(program)) {
63
+ throw new Error(
64
+ `Post uses an "${program}" affiliate link but doesn't declare "affiliates: [${program}, ...]" in its frontmatter. Add it so the disclosure renders.`
65
+ );
66
+ }
67
+ }
68
+ };
69
+ }
@@ -0,0 +1,58 @@
1
+ import type { AffiliateConfig, ResolvedAffiliate } from './types.ts';
2
+
3
+ /**
4
+ * Resolves a flat catalog key (e.g. 'atomicHabits', 'protonPass') against a
5
+ * site's affiliate config into a real URL. Throws on an unknown key, an
6
+ * unknown program, or a catalog entry/program kind mismatch - an unresolvable
7
+ * affiliate link is a build failure, not a silently broken link.
8
+ *
9
+ * `channel` selects a per-channel tag/link override (e.g. a distinct Amazon
10
+ * tracking ID for Medium reposts) if the program declares one via
11
+ * `channelTags`/`channelLinks` - falls back to the default `tag`/`links` entry
12
+ * for any channel not listed, so passing an unconfigured channel is a no-op,
13
+ * not an error.
14
+ */
15
+ export function resolveAffiliate(config: AffiliateConfig, key: string, channel?: string): ResolvedAffiliate {
16
+ const entry = config.catalog[key];
17
+ if (!entry) {
18
+ throw new Error(
19
+ `Unknown affiliate catalog key "${key}". Known keys: ${Object.keys(config.catalog).join(', ') || '(none configured)'}.`
20
+ );
21
+ }
22
+
23
+ const program = config.programs[entry.program];
24
+ if (!program) {
25
+ throw new Error(`Catalog key "${key}" references unknown program "${entry.program}".`);
26
+ }
27
+
28
+ if ('asin' in entry) {
29
+ if (program.kind !== 'amazon') {
30
+ throw new Error(
31
+ `Catalog key "${key}" has an "asin" field, but program "${entry.program}" is kind "${program.kind}", not "amazon".`
32
+ );
33
+ }
34
+ const tag = (channel && program.channelTags?.[channel]) || program.tag;
35
+ const domain = program.domain ?? 'www.amazon.com';
36
+ return {
37
+ url: `https://${domain}/dp/${entry.asin}/ref=nosim?tag=${tag}`,
38
+ program: entry.program,
39
+ };
40
+ }
41
+
42
+ if ('link' in entry) {
43
+ if (program.kind !== 'links') {
44
+ throw new Error(
45
+ `Catalog key "${key}" has a "link" field, but program "${entry.program}" is kind "${program.kind}", not "links".`
46
+ );
47
+ }
48
+ const url = (channel && program.channelLinks?.[channel]?.[entry.link]) || program.links[entry.link];
49
+ if (!url) {
50
+ throw new Error(
51
+ `Catalog key "${key}" references unknown link "${entry.link}" in program "${entry.program}".`
52
+ );
53
+ }
54
+ return { url, program: entry.program };
55
+ }
56
+
57
+ throw new Error(`Catalog key "${key}" has neither "asin" nor "link".`);
58
+ }
@@ -0,0 +1,76 @@
1
+ import type { Localized } from './i18n.ts';
2
+
3
+ /**
4
+ * Amazon Associates program. `resolveAffiliate` constructs the manual-link URL
5
+ * from `domain`, `tag`, and a catalog entry's ASIN: `https://<domain>/dp/<ASIN>/ref=nosim?tag=<tag>`.
6
+ */
7
+ export interface AffiliateProgramAmazon {
8
+ kind: 'amazon';
9
+ /** Associates tracking ID for this site, e.g. 'vdaluz-20'. */
10
+ tag: string;
11
+ /**
12
+ * Amazon marketplace domain. Defaults to 'www.amazon.com'. Set for a
13
+ * locale-specific marketplace (e.g. 'www.amazon.com.br') that needs its own
14
+ * Associates tag - declare a second 'amazon'-kind program with that domain
15
+ * and tag, and catalog entries referencing it.
16
+ */
17
+ domain?: string;
18
+ /**
19
+ * Optional per-channel tracking ID overrides (e.g. `{ medium: 'vdaluz-medium-20' }`
20
+ * for a distinct tag on Medium reposts). A channel not listed here falls back to `tag`.
21
+ */
22
+ channelTags?: Record<string, string>;
23
+ /** FTC disclosure text rendered by <AffiliateDisclosure> for this program. */
24
+ disclosure: Localized;
25
+ }
26
+
27
+ /** A flat link-key -> URL program (Proton, AdGuard, future one-off referral links). */
28
+ export interface AffiliateProgramLinks {
29
+ kind: 'links';
30
+ disclosure: Localized;
31
+ links: Record<string, string>;
32
+ /**
33
+ * Optional per-channel URL overrides, keyed by channel then link key. A
34
+ * channel/link-key combination not listed here falls back to `links`.
35
+ */
36
+ channelLinks?: Record<string, Record<string, string>>;
37
+ }
38
+
39
+ export type AffiliateProgram = AffiliateProgramAmazon | AffiliateProgramLinks;
40
+
41
+ export interface CatalogEntryAmazon {
42
+ /** Name of a 'amazon'-kind program in `programs`. */
43
+ program: string;
44
+ asin: string;
45
+ /** Free-form grouping for site-side filtering (e.g. a /gear page), not used by resolution. */
46
+ category?: string;
47
+ }
48
+
49
+ export interface CatalogEntryLink {
50
+ /** Name of a 'links'-kind program in `programs`. */
51
+ program: string;
52
+ /** Key into that program's `links` map. */
53
+ link: string;
54
+ /** Free-form grouping for site-side filtering (e.g. a /gear page), not used by resolution. */
55
+ category?: string;
56
+ }
57
+
58
+ export type CatalogEntry = CatalogEntryAmazon | CatalogEntryLink;
59
+
60
+ /**
61
+ * Per-site affiliate config. `programs` holds disclosure text and per-program
62
+ * resolution settings (site tag, or a link map); `catalog` is a single flat,
63
+ * unprefixed-key list of every item, each pointing at the program that resolves
64
+ * it. Catalog keys are what posts use in `affiliate:key` markdown links and
65
+ * what `resolveAffiliate`/`<AffiliateLink>` expect.
66
+ */
67
+ export interface AffiliateConfig {
68
+ programs: Record<string, AffiliateProgram>;
69
+ catalog: Record<string, CatalogEntry>;
70
+ }
71
+
72
+ export interface ResolvedAffiliate {
73
+ url: string;
74
+ /** Program name the key resolved under, e.g. 'amazon'. */
75
+ program: string;
76
+ }