@vdaluz/astro-affiliate 0.8.1 → 1.1.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 +90 -4
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/lib/affiliate-cards.ts +2 -3
- package/src/lib/channel-rewrite.ts +21 -7
- package/src/lib/remark.ts +34 -2
package/README.md
CHANGED
|
@@ -149,9 +149,10 @@ fails with a clear error - there's no way to ship an affiliate link without its
|
|
|
149
149
|
The plugin also writes the post's own catalog keys, in document order with duplicates removed,
|
|
150
150
|
to `affiliateKeys` in the page's frontmatter - useful for a consumer that wants to know "which
|
|
151
151
|
catalog items did this post actually link to" without re-parsing markdown (e.g. to seed a
|
|
152
|
-
related-products widget from a post's own links before falling back to other sources
|
|
153
|
-
|
|
154
|
-
`
|
|
152
|
+
related-products widget from a post's own links before falling back to other sources - see
|
|
153
|
+
[Affiliate cards](#affiliate-cards-resolveaffiliatecards) for a ready-made resolver that does
|
|
154
|
+
exactly this). It's `undefined`, not an empty array, on a post with no affiliate links - read it
|
|
155
|
+
as `affiliateKeys ?? []`. Access it via Astro's `remarkPluginFrontmatter`:
|
|
155
156
|
|
|
156
157
|
```astro
|
|
157
158
|
---
|
|
@@ -208,7 +209,8 @@ see [Per-app glue](#per-app-glue) for the token variables this assumes.
|
|
|
208
209
|
|
|
209
210
|
### Localized disclosure text
|
|
210
211
|
|
|
211
|
-
A program's `disclosure` accepts either a plain string or a `Localized` value
|
|
212
|
+
A program's `disclosure` accepts either a plain string or a `Localized` value (import it as a
|
|
213
|
+
type: `import type { Localized } from '@vdaluz/astro-affiliate'`) - `{ default: string, [locale]: string }` - for sites publishing in more than one language:
|
|
212
214
|
|
|
213
215
|
```ts
|
|
214
216
|
programs: {
|
|
@@ -229,6 +231,74 @@ Pass `locale` to `<AffiliateDisclosure>` to select the matching entry; it falls
|
|
|
229
231
|
<AffiliateDisclosure config={affiliate} affiliates={affiliates} locale={locale} />
|
|
230
232
|
```
|
|
231
233
|
|
|
234
|
+
### Disclosure text outside `.astro` (RSS, exports, plain text)
|
|
235
|
+
|
|
236
|
+
`<AffiliateDisclosure>` only works inside an `.astro` page. For anything that needs the same
|
|
237
|
+
disclosure text as plain strings - an RSS item description, a channel-export pipeline, a
|
|
238
|
+
plain-text newsletter - call `resolveDisclosures` directly:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { resolveDisclosures } from '@vdaluz/astro-affiliate';
|
|
242
|
+
import { affiliate } from '../config/affiliate';
|
|
243
|
+
|
|
244
|
+
const disclosures = resolveDisclosures(affiliate, entry.data.affiliates ?? [], locale);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
It returns the resolved text for each program name in order (see [Localized disclosure
|
|
248
|
+
text](#localized-disclosure-text) for how `locale` selects between entries), and throws if any
|
|
249
|
+
name isn't a program declared in `config.programs` - the same guarantee `<AffiliateDisclosure>`
|
|
250
|
+
relies on internally, so a plain-text consumer can't silently drop a disclosure for a typo'd
|
|
251
|
+
program name.
|
|
252
|
+
|
|
253
|
+
## Affiliate cards (`resolveAffiliateCards`)
|
|
254
|
+
|
|
255
|
+
A related-affiliate-products widget for the end of a post. `resolveAffiliateCards` is a plain
|
|
256
|
+
data function - the package ships no rendering component for it, so each consuming app owns its
|
|
257
|
+
own card display component (see [Per-app glue](#per-app-glue)):
|
|
258
|
+
|
|
259
|
+
```astro
|
|
260
|
+
---
|
|
261
|
+
import { resolveAffiliateCards } from '@vdaluz/astro-affiliate';
|
|
262
|
+
import { getAffiliateCardDisplay, getGenericKeys } from '../config/affiliate-cards';
|
|
263
|
+
import AffiliateCards from '../components/AffiliateCards.astro';
|
|
264
|
+
|
|
265
|
+
const { remarkPluginFrontmatter } = await render(entry);
|
|
266
|
+
const affiliateCardEntries = resolveAffiliateCards({
|
|
267
|
+
postKeys: remarkPluginFrontmatter.affiliateKeys ?? [],
|
|
268
|
+
postCategory: entry.data.category,
|
|
269
|
+
postSlug: stripLocalePrefix(entry.id),
|
|
270
|
+
display: getAffiliateCardDisplay(locale),
|
|
271
|
+
generic: getGenericKeys(entry.data.category),
|
|
272
|
+
});
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
<AffiliateCards entries={affiliateCardEntries} />
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Resolves up to 3 entries per post, in priority order:
|
|
279
|
+
|
|
280
|
+
1. The post's own `affiliate:` links (from `affiliateKeys`, see [Markdown
|
|
281
|
+
links](#markdown-links-remarkaffiliate)), in document order - never randomized, since these
|
|
282
|
+
are real editorial choices.
|
|
283
|
+
2. Category-matched entries from `display` (matched on `postCategories`), shuffled with a PRNG
|
|
284
|
+
seeded from `postSlug`, filling every remaining slot except the last one.
|
|
285
|
+
3. The last remaining slot: leftover category matches from (2) plus the `generic` always-eligible
|
|
286
|
+
defaults, pooled together and shuffled with the same seeded PRNG. This applies even when the
|
|
287
|
+
category pool alone could have filled every remaining slot - a category with several matches
|
|
288
|
+
can still yield a generic default in the mix, by design.
|
|
289
|
+
|
|
290
|
+
Deduplicates by key across all three tiers, so a post inlining a key that's also a category match
|
|
291
|
+
or generic default only shows it once.
|
|
292
|
+
|
|
293
|
+
`postSlug` seeds the shuffle deterministically - the same post always resolves to the same card
|
|
294
|
+
set across rebuilds, so it must be **locale-independent** (strip any locale prefix, e.g.
|
|
295
|
+
`stripLocalePrefix(entry.id)`) or the same post's translations will land on different combinations
|
|
296
|
+
instead of sharing one.
|
|
297
|
+
|
|
298
|
+
`display` (a `Record<string, AffiliateCardDisplay>` keyed by catalog key) and `generic` (an array
|
|
299
|
+
of always-eligible catalog keys) are both required, not defaulted - the package stays data-free by
|
|
300
|
+
design, since a shared package should never bake in a consumer's own catalog/display data.
|
|
301
|
+
|
|
232
302
|
## Per-app glue
|
|
233
303
|
|
|
234
304
|
This is a component library, not a drop-in catalog. Each consuming app owns:
|
|
@@ -239,11 +309,27 @@ This is a component library, not a drop-in catalog. Each consuming app owns:
|
|
|
239
309
|
- Token CSS variables referenced by the default disclosure styling: `muted`. See
|
|
240
310
|
[`@vdaluz/astro-blog`'s `tokens.example.css`](https://github.com/vdaluz/astro-blog) for the
|
|
241
311
|
full token set these sites already share.
|
|
312
|
+
- Its own card display map and rendering component for [Affiliate
|
|
313
|
+
cards](#affiliate-cards-resolveaffiliatecards) - `resolveAffiliateCards` returns data only.
|
|
242
314
|
|
|
243
315
|
## Contributing
|
|
244
316
|
|
|
245
317
|
Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.
|
|
246
318
|
|
|
319
|
+
### Releasing
|
|
320
|
+
|
|
321
|
+
Maintainer-only. Releases are tag-triggered and published to npm via GitHub Actions (Trusted
|
|
322
|
+
Publishing / OIDC, no token secret):
|
|
323
|
+
|
|
324
|
+
1. Test before tagging: `npm pack`, install the tarball into a scratch Astro app (or a consumer
|
|
325
|
+
locally), `astro check && astro build`.
|
|
326
|
+
2. Bump `version` in `package.json`, commit.
|
|
327
|
+
3. Tag `vX.Y.Z` and push the tag. Pushing the tag runs `.github/workflows/publish.yml`, which
|
|
328
|
+
type-checks, tests, verifies the tag matches `package.json`'s version, and only then runs
|
|
329
|
+
`npm publish`.
|
|
330
|
+
4. Confirm the version is live: `npm view @vdaluz/astro-affiliate version`. Consumers bump their
|
|
331
|
+
own semver pin once it's confirmed live - see this package's CHANGELOG.md for what changed.
|
|
332
|
+
|
|
247
333
|
## Consumers
|
|
248
334
|
|
|
249
335
|
- [vdaluz.com](https://vdaluz.com)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vdaluz/astro-affiliate",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"astro": ">=6.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@astrojs/check": "^0.9.
|
|
43
|
+
"@astrojs/check": "^0.9.10",
|
|
44
44
|
"@types/node": "^26.1.1",
|
|
45
|
-
"astro": "^7.
|
|
45
|
+
"astro": "^7.2.4",
|
|
46
46
|
"typescript": "^6.0.3"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
4
|
export { resolveAffiliateCards } from './lib/affiliate-cards.ts';
|
|
5
|
+
export { resolveDisclosures } from './lib/disclosures.ts';
|
|
5
6
|
export type {
|
|
6
7
|
AffiliateConfig,
|
|
7
8
|
AffiliateProgram,
|
|
@@ -12,6 +13,7 @@ export type {
|
|
|
12
13
|
CatalogEntryLink,
|
|
13
14
|
ResolvedAffiliate,
|
|
14
15
|
} from './lib/types.ts';
|
|
16
|
+
export type { Localized } from './lib/i18n.ts';
|
|
15
17
|
export type {
|
|
16
18
|
AffiliateCardDisplay,
|
|
17
19
|
AffiliateCardEntry,
|
|
@@ -21,9 +21,8 @@ export interface ResolveAffiliateCardsInput {
|
|
|
21
21
|
postSlug: string;
|
|
22
22
|
/** Display data for every catalog key eligible to appear as a card, keyed
|
|
23
23
|
* by the same catalog key used in the consumer's affiliate.ts. Required,
|
|
24
|
-
* not defaulted - this package stays data-free by design
|
|
25
|
-
*
|
|
26
|
-
* own catalog/display data). */
|
|
24
|
+
* not defaulted - this package stays data-free by design, since a shared
|
|
25
|
+
* package should never bake in a consumer's own catalog/display data. */
|
|
27
26
|
display: Record<string, AffiliateCardDisplay>;
|
|
28
27
|
/** Always-eligible last-resort fill tier, as catalog keys into `display`. */
|
|
29
28
|
generic: string[];
|
|
@@ -31,16 +31,30 @@ export function buildChannelRewriteMap(config: AffiliateConfig, channel: string)
|
|
|
31
31
|
return map;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function escapeRegExp(value: string): string {
|
|
35
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
/**
|
|
35
39
|
* 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
|
-
*
|
|
38
|
-
* similar - safe against
|
|
40
|
+
* default-channel affiliate URLs for a specific channel's URLs. Exact string
|
|
41
|
+
* matching per catalog entry (each default URL escaped into a regex
|
|
42
|
+
* alternative), not a generic regex over "tag=" or similar - safe against
|
|
43
|
+
* matching unrelated content.
|
|
44
|
+
*
|
|
45
|
+
* A single regex pass, not one split/join per entry: sequential passes
|
|
46
|
+
* re-scan each other's output, so a shorter default URL that happens to be a
|
|
47
|
+
* prefix of another entry's default OR channel URL would corrupt an already-
|
|
48
|
+
* rewritten result on a later pass. One pass never re-scans a replacement.
|
|
49
|
+
* Alternatives are ordered longest-first because regex alternation is
|
|
50
|
+
* first-match-wins at a given position, not longest-match-wins - without the
|
|
51
|
+
* ordering a shorter prefix could still win the match before the longer
|
|
52
|
+
* alternative gets a chance.
|
|
39
53
|
*/
|
|
40
54
|
export function rewriteAffiliateLinksForChannel(content: string, config: AffiliateConfig, channel: string): string {
|
|
41
55
|
const map = buildChannelRewriteMap(config, channel);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
56
|
+
const defaultUrls = Object.keys(map).sort((a, b) => b.length - a.length);
|
|
57
|
+
if (defaultUrls.length === 0) return content;
|
|
58
|
+
const pattern = new RegExp(defaultUrls.map(escapeRegExp).join('|'), 'g');
|
|
59
|
+
return content.replace(pattern, (matched) => map[matched]);
|
|
46
60
|
}
|
package/src/lib/remark.ts
CHANGED
|
@@ -22,7 +22,11 @@ interface VFileWithAstroFrontmatter {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function collectAffiliateLinkNodes(node: MdastNode, out: MdastNode[]) {
|
|
25
|
-
if (
|
|
25
|
+
if (
|
|
26
|
+
(node.type === 'link' || node.type === 'definition') &&
|
|
27
|
+
typeof node.url === 'string' &&
|
|
28
|
+
node.url.startsWith(AFFILIATE_PREFIX)
|
|
29
|
+
) {
|
|
26
30
|
out.push(node);
|
|
27
31
|
}
|
|
28
32
|
if (node.children) {
|
|
@@ -30,6 +34,26 @@ function collectAffiliateLinkNodes(node: MdastNode, out: MdastNode[]) {
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Last-resort guard: after rewriting every node `collectAffiliateLinkNodes`
|
|
39
|
+
* found, walk the whole tree once more for any node that still carries an
|
|
40
|
+
* `affiliate:`-prefixed `url` - a node type the collector doesn't know about
|
|
41
|
+
* (e.g. an image ``). Unresolvable is a build failure
|
|
42
|
+
* here just like an unknown catalog key, so this must run even when the
|
|
43
|
+
* collector found nothing - that zero-nodes-collected case is exactly the
|
|
44
|
+
* shape of the reference-link bug this plugin exists to prevent.
|
|
45
|
+
*/
|
|
46
|
+
function assertNoRemainingAffiliateUrls(node: MdastNode) {
|
|
47
|
+
if (typeof node.url === 'string' && node.url.startsWith(AFFILIATE_PREFIX)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Unrewritten affiliate link "${node.url}" remains in the tree on a "${node.type}" node - collectAffiliateLinkNodes doesn't handle this node type yet.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (node.children) {
|
|
53
|
+
for (const child of node.children) assertNoRemainingAffiliateUrls(child);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
/**
|
|
34
58
|
* Remark plugin: rewrites `[text](affiliate:catalogKey)` links to their real
|
|
35
59
|
* resolved URL at build time, and enforces FTC compliance by construction -
|
|
@@ -41,6 +65,8 @@ function collectAffiliateLinkNodes(node: MdastNode, out: MdastNode[]) {
|
|
|
41
65
|
* duplicates removed, to `affiliateKeys` in the page's frontmatter (via
|
|
42
66
|
* `remarkPluginFrontmatter`) - undefined, not an empty array, on a post with
|
|
43
67
|
* no affiliate links, so a consumer should read it as `affiliateKeys ?? []`.
|
|
68
|
+
* For a reference-style link (`[text][ref]`), "document order" follows the
|
|
69
|
+
* position of the `[ref]: affiliate:key` definition, not the in-prose usage.
|
|
44
70
|
*
|
|
45
71
|
* import { remarkAffiliate } from '@vdaluz/astro-affiliate/remark';
|
|
46
72
|
*
|
|
@@ -52,7 +78,11 @@ export function remarkAffiliate(config: AffiliateConfig) {
|
|
|
52
78
|
return (tree: MdastNode, file: VFileWithAstroFrontmatter) => {
|
|
53
79
|
const linkNodes: MdastNode[] = [];
|
|
54
80
|
collectAffiliateLinkNodes(tree, linkNodes);
|
|
55
|
-
|
|
81
|
+
|
|
82
|
+
if (linkNodes.length === 0) {
|
|
83
|
+
assertNoRemainingAffiliateUrls(tree);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
56
86
|
|
|
57
87
|
const declaredAffiliates = file.data?.astro?.frontmatter?.affiliates ?? [];
|
|
58
88
|
const usedPrograms = new Set<string>();
|
|
@@ -78,5 +108,7 @@ export function remarkAffiliate(config: AffiliateConfig) {
|
|
|
78
108
|
file.data.astro ??= {};
|
|
79
109
|
file.data.astro.frontmatter ??= {};
|
|
80
110
|
file.data.astro.frontmatter.affiliateKeys = usedKeys;
|
|
111
|
+
|
|
112
|
+
assertNoRemainingAffiliateUrls(tree);
|
|
81
113
|
};
|
|
82
114
|
}
|