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

@anhur/assets

v0.1.0

Published

Image and file fields for @anhur/core: content-hashed copies, sizes, blur placeholders, optional CDN upload

Readme

@anhur/assets

🖼️ Images and files for Anhur content.

Point a schema field at a local image or file next to your Markdown/MDX/YAML/JSON. Anhur copies it into a content-hashed assets folder and gives you a public URL (plus size, type and a blur placeholder for images) in the generated data.

📦 Install

bun add @anhur/assets

You also need @anhur/core and zod. Image metadata uses sharp (optional peer — install it when you use a.image()).

🚀 Setup

Register the plugin and use a.image() / a.file() in your schema:

import { defineCollection, defineConfig, schema as s } from "@anhur/core";
import { assets, schema as a } from "@anhur/assets";

const posts = defineCollection({
  name: "posts",
  directory: "content/posts",
  include: "**/*.md",
  schema: s.object({
    title: s.string(),
    cover: a.image(),
    hero: a.image({ allowRemote: true }).optional(),
    attachment: a.file().optional(),
  }),
});

export default defineConfig({
  content: [posts],
  plugins: [assets()],
});
---
title: Hello
cover: ./cover.png
attachment: ./spec.pdf
---

| Helper | Result | | -------------------------------- | ---------------------------------------------------------------------------- | | a.image() | { src, width, height, contentType, blurDataURL?, blurWidth?, blurHeight? } | | a.image({ allowRemote: true }) | the above, or { src, remote: true } for https://…, //host/… or /… | | a.image({ blur: false }) | no blur placeholder | | a.file() | { src, size, contentType } |

Width and height follow EXIF orientation. SVGs report their size from width / height / viewBox (whole pixels, at least 1). a.image() reads AVIF, GIF, JPEG, PNG, SVG, TIFF and WebP; use a.file() for other files such as .ico.

allowRemote keeps only http(s)://…, protocol-relative //host/… and root /… URLs. Other schemes (javascript:, data:, …) fail the build.

⚙️ Options

| Option | Default | What it does | | ------------ | -------------------------- | -------------------------------------------------------------------- | | dir | .anhur/assets | Folder for copies (relative to the config; must not overlap content) | | base | /anhur-assets/ | Public URL prefix; a full URL (CDN) is used as is | | devBase | see storage | URL prefix used in dev mode | | roots | the project folder | Extra folders assets may be read from | | extensions | DEFAULT_ASSET_EXTENSIONS | Allowed extensions (images, audio, video, captions, fonts, PDF) | | svg | "sanitize" | Rebuild SVGs from an allowlist ("keep" to copy them as is) | | storage | — | Upload to object storage (below) |

base and devBase must be a path prefix (not the site root /, no . / .. segments, no ? / #) or an http(s) URL. Characters that are not valid in a URL path are percent-encoded (/my assets/ → /my%20assets/).

Extensions

DEFAULT_ASSET_EXTENSIONS covers images, audio, video, .vtt captions, fonts and .pdf. Data, archive and office formats (.json, .txt, .csv, .zip, .gz, .epub, Word / Excel / PowerPoint) are opt-in, because a stray link such as ../service-account.json would publish a private file:

import {
  assets,
  DEFAULT_ASSET_EXTENSIONS,
  DOCUMENT_ASSET_EXTENSIONS,
} from "@anhur/assets";

assets({ extensions: [...DEFAULT_ASSET_EXTENSIONS, ".zip"] });
// or every document format:
assets({
  extensions: [...DEFAULT_ASSET_EXTENSIONS, ...DOCUMENT_ASSET_EXTENSIONS],
});

Roots

Files are read from the project folder (the folder of the config file). Add roots for files outside it, or for a package in node_modules:

assets({ roots: ["../shared/images", "node_modules/@fontsource/inter/files"] });

Below the matching root, dotfiles, dot-folders and node_modules are always refused. A root may itself be a dot-folder (roots: [".shared"]).

🛡️ Safety

  • Files are read only from the project folder (and roots), after resolving symlinks. ../../secret, dotfiles (.env) and node_modules are refused.
  • Only listed extensions are copied; the default list has no data or office formats.
  • Copies are named name-<hash>.ext, so URLs are cache-safe. Each copy is checked against its hash; a file that changes during the build fails it ("run the build again").
  • The plugin only deletes files it created (tracked in .anhur-assets.json) and refuses to write into a folder with files it did not create. A folder without that manifest is only adopted when every file has a hashed asset name with an allowed extension.
  • Files of earlier builds are deleted only after the new generated modules are written. When a copy fails, the files already copied are still recorded, so the next build removes them.
  • Files of any size are hashed, copied and uploaded as streams.
  • Do not run vite build (or anhur build) while a dev server uses the same dir: each removes the files the other one does not use. Give one of them its own dir if you need both.

SVG

With svg: "sanitize" (default), each SVG is parsed as XML and rebuilt from an allowlist of SVG elements and presentation attributes:

  • no <script>, <foreignObject>, XHTML or other foreign elements, event handlers, comments, processing instructions or xml:base;
  • href / xlink:href keep only #fragment, relative and http(s): URLs (entities and control characters are decoded first), plus raster data:image/… URLs on <image> / <feImage>;
  • <animate> / <set> / <animateMotion> / <animateTransform> that change links or event handlers are removed;
  • SVGs that are not well-formed XML, use custom entities, or are larger than 16 MiB fail the build. Fix the file, or use svg: "keep" for it.

The SVG mode is part of the output name, so switching between "keep" and "sanitize" (or a sanitizer update) copies and uploads the SVGs again under new names.

Sanitizing is defense in depth. Serve assets with these headers in production too (the Vite dev server sends them):

X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src data: 'self'; sandbox   (for .svg)

Better still, serve user-supplied files from a separate origin (a CDN domain).

🔗 Body images and links

With @anhur/mdx or @anhur/markdown, relative URLs in the body — ![Alt](./photo.png), [PDF](./spec.pdf), <video src="./clip.mp4">, srcset — are copied and rewritten when assets() is registered. Links to other content files are left alone. Without assets(), relative asset URLs fail the build so broken paths do not ship quietly.

⚡ With Vite

@anhur/vite serves the assets folder in dev (with ranges for video, correct content types and the headers above) and copies the files the build uses into the client build output.

☁️ Remote storage

Optional upload to any files-sdk backend (S3, R2, MinIO, …). base must be the public URL of the prefix:

import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";

assets({
  base: "https://cdn.example.com/site/",
  storage: {
    prefix: "site",
    files: () => new Files({ adapter: s3({ bucket: "assets" }) }),
  },
});

| Storage option | Default | What it does | | -------------- | ------------------------------------- | ------------------------------------------------------ | | prefix | (required) | Key prefix; must match the end of base | | files | (required) | A files-sdk client, or a factory (only called on sync) | | prune | false | After a build, delete unused hashed keys (see below) | | pruneEmpty | false | Allow pruning when the build has no assets | | syncInDev | false | Also upload in dev / watch mode (never prunes) | | dryRun | false | Report changes without uploading or deleting | | concurrency | 8 | Parallel uploads (a whole number of at least 1) | | cacheControl | public, max-age=31536000, immutable | Sent with uploads, along with the content type |

Uploads run in build mode only by default. In dev, URLs point at the local copies (devBase defaults to /anhur-assets/), so editing content never touches the bucket. A process remembers the keys it uploaded or found, so rebuilds do not ask the bucket again.

Pruning is off by default because previews and rollbacks may still use older files; turn it on only when one deployment owns the prefix. It runs only in build mode (never in dev, even with syncInDev), after the generated modules are written, and deletes only keys directly under the prefix whose names look like Anhur output (site/<name>-<16 hex>.<ext>). Keys in sub-folders (site/uploads/…) and other names are never touched.

files-sdk is an optional peer. Do not set a prefix on the Files client itself — Anhur applies storage.prefix.

Local MinIO for development: docker compose -f docker-compose.minio.yml up at the Anhur monorepo root (throwaway credentials only).

License

MIT