@vdaluz/astro-affiliate 1.0.0 → 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 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). It's
153
- `undefined`, not an empty array, on a post with no affiliate links - read it as
154
- `affiliateKeys ?? []`. Access it via Astro's `remarkPluginFrontmatter`:
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 - `{ default: string, [locale]: string }` - for sites publishing in more than one language:
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": "1.0.0",
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",
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 (see AST-36's
25
- * cancellation for why a shared package should never bake in a consumer's
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
- * string replacement per catalog entry, not a generic regex over "tag=" or
38
- * similar - safe against matching unrelated content.
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
- return Object.entries(map).reduce(
43
- (result, [defaultUrl, channelUrl]) => result.split(defaultUrl).join(channelUrl),
44
- content
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 (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith(AFFILIATE_PREFIX)) {
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 `![alt](affiliate:key)`). 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
- if (linkNodes.length === 0) return;
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
  }