@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 +21 -0
- package/README.md +249 -0
- package/package.json +48 -0
- package/src/components/AffiliateDisclosure.astro +22 -0
- package/src/components/AffiliateLink.astro +18 -0
- package/src/index.ts +13 -0
- package/src/lib/channel-rewrite.ts +46 -0
- package/src/lib/config.ts +30 -0
- package/src/lib/disclosures.ts +20 -0
- package/src/lib/i18n.ts +6 -0
- package/src/lib/remark.ts +69 -0
- package/src/lib/resolve.ts +58 -0
- package/src/lib/types.ts +76 -0
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
|
+
[](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 `&` 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, '&');
|
|
26
|
+
if (escapedDefault !== defaultResolved.url) {
|
|
27
|
+
map[escapedDefault] = channelResolved.url.replace(/&/g, '&');
|
|
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
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
}
|