npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vdaluz/astro-affiliate

v1.1.2

Published

FTC-compliant affiliate-link catalog resolver and disclosure components - proven in production on vdaluz.com and imperfectsystems.com.

Readme

@vdaluz/astro-affiliate

npm version CI license

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 and imperfectsystems.com - see Consumers.

Install

npm install @vdaluz/astro-affiliate

Peer dependency: astro >= 6.

Define your config

Two top-level pieces: programs (disclosure text + how to resolve a program's links) and catalog (a single flat list of every item, each pointing at the program that resolves it).

// src/config/affiliate.ts
import { defineAffiliateConfig } from '@vdaluz/astro-affiliate';

export const affiliate = defineAffiliateConfig({
  programs: {
    amazon: {
      kind: 'amazon',
      tag: 'vdaluz-20',
      disclosure: 'As an Amazon Associate, I earn from qualifying purchases.',
    },
    proton: {
      kind: 'links',
      disclosure: 'As a Proton Partner, I earn from qualifying purchases.',
      links: { pass: 'https://go.getproton.me/SH2FI' },
    },
  },
  catalog: {
    atomicHabits: { program: 'amazon', asin: 'B07RFSSYBH' },
    protonPass: { program: 'proton', link: 'pass' },
  },
});

Two program kinds:

  • amazon - supply a site tag once; catalog entries reference it with just an ASIN. The URL is constructed as https://<domain>/dp/<ASIN>/ref=nosim?tag=<tag>, where domain defaults to www.amazon.com. For a locale-specific marketplace (e.g. Brazil), declare a second amazon-kind program with its own domain and tag, and point locale-specific catalog entries at it:

    programs: {
      amazon: { kind: 'amazon', tag: 'vdaluz-20', disclosure: '...' },
      amazonBr: { kind: 'amazon', domain: 'www.amazon.com.br', tag: 'vdaluz-br-20', disclosure: '...' },
    },
    catalog: {
      atomicHabits: { program: 'amazon', asin: 'B07RFSSYBH' },
      atomicHabitsBr: { program: 'amazonBr', asin: 'B07RFSSYBH' },
    },
  • links - a flat link-key-to-URL map on the program; catalog entries reference one of those keys.

Catalog keys are flat and unprefixed (atomicHabits, not amazon.atomicHabits) - that's what markdown links and <AffiliateLink> use directly. Each entry accepts an optional category string, ignored by resolution, for a consuming app's own filtering (e.g. a gear page listing only category: 'gear' entries).

Per-channel tags (reposts, syndication)

A program can declare a different tag/link for a named channel - e.g. a distinct Amazon tracking ID for content republished to Medium, so Associates reporting can tell channel traffic apart from the canonical site (Amazon's own tracking IDs exist for exactly this: up to 100 per account, independently reportable):

programs: {
  amazon: {
    kind: 'amazon',
    tag: 'vdaluz-20',
    channelTags: { medium: 'vdaluz-medium-20' },
    disclosure: '...',
  },
  proton: {
    kind: 'links',
    disclosure: '...',
    links: { pass: 'https://go.getproton.me/SH2FI' },
    channelLinks: { medium: { pass: 'https://go.getproton.me/MEDIUM' } },
  },
},

A channel not listed in channelTags/channelLinks falls back to the program's default - passing an unconfigured channel is a no-op, not an error.

Two ways to consume a channel, depending on where the affiliate link lives:

  • resolveAffiliate(config, key, channel) - pass the channel directly when resolving at request/render time (e.g. inside a non-prerendered .astro page or component).
  • rewriteAffiliateLinksForChannel(content, config, channel) - for content whose affiliate links were already resolved to the default channel at build time (markdown affiliate:key links compiled once via remarkAffiliate, baked into a prerendered page). Retargets the rendered output after the fact - via a middleware, an edge function, or whatever else drives the specific repost flow - by exact string substitution of each catalog entry's default URL, not a generic regex, so it can't accidentally touch unrelated content. buildChannelRewriteMap(config, channel) exposes the underlying default-URL -> channel-URL map directly, for callers that want to do their own substitution.

Markdown links (remarkAffiliate)

Wire the plugin into astro.config.mjs:

import { remarkAffiliate } from '@vdaluz/astro-affiliate/remark';
import { affiliate } from './src/config/affiliate';

export default defineConfig({
  markdown: {
    remarkPlugins: [[remarkAffiliate, affiliate]],
  },
});

Use the [plugin, options] tuple, not remarkAffiliate(affiliate) pre-invoked. Astro/unified calls the plugin function itself with the options; passing an already-invoked transformer means unified calls that with no arguments as if it were the attacher, which silently no-ops instead of rewriting anything - the build stays green with affiliate:key links left untouched in the output. Always verify by checking rendered HTML for the real resolved URL, not just a passing build.

Then in a post's markdown body:

---
title: My post
affiliates: [amazon]
---

I use [Atomic Habits](affiliate:atomicHabits) to stay on track.

affiliate:atomicHabits is rewritten to the real resolved URL at build time. An unknown key fails the build. Compliance by construction: every program actually used by affiliate: links in a post must be declared in that post's affiliates: frontmatter array, or the build fails with a clear error - there's no way to ship an affiliate link without its disclosure.

The plugin also writes the post's own catalog keys, in document order with duplicates removed, to affiliateKeys in the page's frontmatter - useful for a consumer that wants to know "which catalog items did this post actually link to" without re-parsing markdown (e.g. to seed a related-products widget from a post's own links before falling back to other sources - see Affiliate cards for a ready-made resolver that does exactly this). It's undefined, not an empty array, on a post with no affiliate links - read it as affiliateKeys ?? []. Access it via Astro's remarkPluginFrontmatter:

---
const { remarkPluginFrontmatter } = await render(entry);
const usedKeys = remarkPluginFrontmatter.affiliateKeys ?? [];
---

.astro pages (<AffiliateLink>)

For gear pages or other non-markdown content:

---
import AffiliateLink from '@vdaluz/astro-affiliate/AffiliateLink.astro';
import { affiliate } from '../config/affiliate';
---

<AffiliateLink config={affiliate} affiliateKey="atomicHabits">
  Atomic Habits
</AffiliateLink>

Renders target="_blank" rel="noopener noreferrer sponsored" by default. Pass class to style it, or channel to target a per-channel tag/link (see Per-channel tags) - falls back to the program's default when omitted or unconfigured for that channel.

Click tracking

<AffiliateLink> renders data-affiliate-key, data-affiliate-channel (omitted when no channel prop is passed), and data-affiliate-program on the anchor. This package emits no click events itself - it's a data-attribute contract a consumer's own analytics wiring can read. See @vdaluz/astro-opt-in-analytics's README for bindAffiliateClickTracking(), which reads these attributes and reports an affiliate-click event once analytics consent is granted.

Disclosure (<AffiliateDisclosure>)

Render at the top of the post body, above any affiliate links (FTC: disclosure before links, above the fold):

---
import AffiliateDisclosure from '@vdaluz/astro-affiliate/AffiliateDisclosure.astro';
import { affiliate } from '../config/affiliate';

const { affiliates = [] } = entry.data;
---

<AffiliateDisclosure config={affiliate} affiliates={affiliates} />

Renders one paragraph joining the disclosure text for every program in affiliates, or nothing if the array is empty. A program named more than once (for example, one entry per card in a card list) is disclosed once. Default styling is text-sm text-muted italic; pass class to override, see Per-app glue for the token variables this assumes.

Localized disclosure text

A program's disclosure accepts either a plain string or a Localized value (import it as a type: import type { Localized } from '@vdaluz/astro-affiliate') - { default: string, [locale]: string } - for sites publishing in more than one language:

programs: {
  amazon: {
    kind: 'amazon',
    tag: 'vdaluz-20',
    disclosure: {
      default: 'As an Amazon Associate, I earn from qualifying purchases.',
      es: 'Como Afiliado de Amazon, obtengo ingresos por las compras que califican.',
    },
  },
},

Pass locale to <AffiliateDisclosure> to select the matching entry - a region-qualified locale like pt-BR matches a pt entry via its primary subtag if there's no exact pt-BR entry. It falls back to default when neither the exact locale nor its subtag has an entry, or when disclosure is a plain string:

<AffiliateDisclosure config={affiliate} affiliates={affiliates} locale={locale} />

Disclosure text outside .astro (RSS, exports, plain text)

<AffiliateDisclosure> only works inside an .astro page. For anything that needs the same disclosure text as plain strings - an RSS item description, a channel-export pipeline, a plain-text newsletter - call resolveDisclosures directly:

import { resolveDisclosures } from '@vdaluz/astro-affiliate';
import { affiliate } from '../config/affiliate';

const disclosures = resolveDisclosures(affiliate, entry.data.affiliates ?? [], locale);

It returns the resolved text for each distinct program name, in first-seen order - a name passed more than once appears once (see Localized disclosure text for how locale selects between entries), and throws if any name isn't a program declared in config.programs - the same guarantee <AffiliateDisclosure> relies on internally, so a plain-text consumer can't silently drop a disclosure for a typo'd program name.

Affiliate cards (resolveAffiliateCards)

A related-affiliate-products widget for the end of a post. resolveAffiliateCards is a plain data function - the package ships no rendering component for it, so each consuming app owns its own card display component (see Per-app glue):

---
import { resolveAffiliateCards } from '@vdaluz/astro-affiliate';
import { getAffiliateCardDisplay, getGenericKeys } from '../config/affiliate-cards';
import AffiliateCards from '../components/AffiliateCards.astro';

const { remarkPluginFrontmatter } = await render(entry);
const affiliateCardEntries = resolveAffiliateCards({
  postKeys: remarkPluginFrontmatter.affiliateKeys ?? [],
  postCategory: entry.data.category,
  postSlug: stripLocalePrefix(entry.id),
  display: getAffiliateCardDisplay(locale),
  generic: getGenericKeys(entry.data.category),
});
---

<AffiliateCards entries={affiliateCardEntries} />

Resolves up to 3 entries per post, in priority order:

  1. The post's own affiliate: links (from affiliateKeys, see Markdown links), in document order - never randomized, since these are real editorial choices.
  2. Category-matched entries from display (matched on postCategories), shuffled with a PRNG seeded from postSlug, filling every remaining slot except the last one.
  3. The last remaining slot: leftover category matches from (2) plus the generic always-eligible defaults, pooled together and shuffled with the same seeded PRNG. This applies even when the category pool alone could have filled every remaining slot - a category with several matches can still yield a generic default in the mix, by design.

Deduplicates by key across all three tiers, so a post inlining a key that's also a category match or generic default only shows it once.

postSlug seeds the shuffle deterministically - the same post always resolves to the same card set across rebuilds, so it must be locale-independent (strip any locale prefix, e.g. stripLocalePrefix(entry.id)) or the same post's translations will land on different combinations instead of sharing one.

display (a Record<string, AffiliateCardDisplay> keyed by catalog key) and generic (an array of always-eligible catalog keys) are both required, not defaulted - the package stays data-free by design, since a shared package should never bake in a consumer's own catalog/display data.

Per-app glue

This is a component library, not a drop-in catalog. Each consuming app owns:

  • Its own affiliate config (programs, catalog, tags, disclosure text). Nothing is shared across sites.
  • The affiliates: field in its content collection schema (add affiliates: z.array(z.string()).optional()).
  • Token CSS variables referenced by the default disclosure styling: muted. See @vdaluz/astro-blog's tokens.example.css for the full token set these sites already share.
  • Its own card display map and rendering component for Affiliate cards - resolveAffiliateCards returns data only.

Contributing

Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.

Releasing

Maintainer-only. Releases are tag-triggered and published to npm via GitHub Actions (Trusted Publishing / OIDC, no token secret):

  1. Test before tagging: npm pack, install the tarball into a scratch Astro app (or a consumer locally), astro check && astro build.
  2. Bump version in package.json, commit.
  3. Tag vX.Y.Z and push the tag. Pushing the tag runs .github/workflows/publish.yml, which type-checks, tests, verifies the tag matches package.json's version, and only then runs npm publish.
  4. Confirm the version is live: npm view @vdaluz/astro-affiliate version. Consumers bump their own semver pin once it's confirmed live - see this package's CHANGELOG.md for what changed.

Consumers

License

MIT