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

@jdevalk/astro-seo-graph

v0.5.2

Published

Astro integration for @jdevalk/seo-graph-core. Seo component, route factories, content-collection aggregator, Zod content helpers.

Readme

@jdevalk/astro-seo-graph

npm version license

Astro integration for @jdevalk/seo-graph-core. Ships a <Seo> component, route factories for agent-ready schema endpoints, a content-collection aggregator, breadcrumb helpers, a fuzzy 404 redirect component, and Zod helpers for content schemas.

For detailed usage — including all builder signatures, site-type recipes, and schema.org best practices — see AGENTS.md.

What you get

| API | Purpose | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | <Seo> (./Seo.astro) | Single head component covering <title>, meta description, canonical, Open Graph, Twitter card, hreflang alternates, and optional JSON-LD @graph. Wraps astro-seo for the meta tags. | | createSchemaEndpoint | Factory returning an Astro APIRoute handler that serves a corpus-wide JSON-LD @graph for a content collection. | | createSchemaMap | Factory returning an APIRoute handler that emits a sitemap-style XML listing of your site's schema endpoints — the discovery point for agent crawlers. | | aggregate | Shared engine behind the endpoint factories. Walks a list of entries, runs a caller-supplied mapper, deduplicates by @id. | | seoSchema, imageSchema | Zod schemas for the seo and image fields on content collections. Import them into src/content.config.ts. | | buildAstroSeoProps | Pure-TS logic that powers <Seo> — exported for users who want to feed a different head component. | | buildAlternateLinks | Pure helper that turns a { hreflang, href } entry list into normalized <link rel="alternate"> tags plus an x-default. Used internally by <Seo>'s alternates prop, and exported for non-Astro callers (e.g. CMS plugins feeding their own metadata pipelines). | | breadcrumbsFromUrl | Derives a breadcrumb trail from an Astro URL. Splits path segments, supports custom display names and segment skipping. Returns BreadcrumbItem[] ready to pass to buildBreadcrumbList. | | <FuzzyRedirect> | Drop-in 404 component. Fetches your sitemap, fuzzy-matches the current URL against known paths, and suggests or auto-redirects to the closest match. |

Installation

pnpm add @jdevalk/astro-seo-graph @jdevalk/seo-graph-core

@jdevalk/seo-graph-core is a direct dep of this package so it's installed transitively, but depending on it explicitly lets you pin the version and import piece builders directly.

<Seo> component

---
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
import { buildSchemaGraph } from '../utils/schema';

const graph = buildSchemaGraph({
    pageType: 'blogPost',
    canonicalUrl: Astro.url.href,
    title: 'My Post',
    description: '…',
    publishDate: new Date('2026-04-07'),
});
---

<html>
    <head>
        <Seo
            title="My Post"
            titleTemplate="%s | Example"
            description="…"
            ogType="article"
            ogImage="https://example.com/og/my-post.jpg"
            ogImageAlt="My Post"
            ogImageWidth={1200}
            ogImageHeight={675}
            siteName="Example"
            twitter={{ site: '@example', creator: '@author' }}
            article={{ publishedTime: new Date('2026-04-07'), tags: ['tech'] }}
            graph={graph}
            extraLinks={[
                { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
                { rel: 'sitemap', href: '/sitemap-index.xml' },
            ]}
        />
    </head>
    <body>...</body>
</html>

Breadcrumbs from URL

Derive a breadcrumb trail from an Astro page URL instead of computing it manually:

import { breadcrumbsFromUrl } from '@jdevalk/astro-seo-graph';
import { buildBreadcrumbList, makeIds } from '@jdevalk/seo-graph-core';

const ids = makeIds({ siteUrl: 'https://example.com' });
const url = 'https://example.com/blog/open-source/my-post/';

const items = breadcrumbsFromUrl({
    url: Astro.url, // or any URL / string
    siteUrl: 'https://example.com',
    pageName: 'My Post', // display name for the current page
    // homeName: 'Home',                     // optional, defaults to 'Home'
    // names: { blog: 'Articles' },          // optional, custom segment names
    // skip: ['category'],                   // optional, segments to omit
});

const breadcrumb = buildBreadcrumbList({ url, items }, ids);
// items === [
//   { name: 'Home', url: 'https://example.com/' },
//   { name: 'Blog', url: 'https://example.com/blog/' },
//   { name: 'Open Source', url: 'https://example.com/blog/open-source/' },
//   { name: 'My Post', url: 'https://example.com/blog/open-source/my-post/' },
// ]

Segments without a names entry are title-cased from their slug (open-sourceOpen Source). Sites with a base path (e.g. https://example.com/docs) are supported — pass the base path as part of siteUrl.

Fuzzy 404 redirect

When a visitor hits a 404, <FuzzyRedirect> fetches your sitemap, compares the mistyped URL against all known paths, and either suggests the closest match or auto-redirects. Drop it into your 404.astro page:

---
// src/pages/404.astro
import FuzzyRedirect from '@jdevalk/astro-seo-graph/FuzzyRedirect.astro';
---

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>Page not found</title>
    </head>
    <body>
        <h1>Page not found</h1>
        <p>Sorry, the page you're looking for doesn't exist.</p>
        <p style="font-size: 1.25em; font-weight: bold;">
            <FuzzyRedirect />
        </p>
        <p><a href="/">Go to the homepage</a></p>
    </body>
</html>

When a close match is found, the component renders a message like Did you mean /seo-graph/? inside the element where you place it. Style the surrounding element to make it prominent.

How it works

  1. Fetches /sitemap-index.xml (follows sitemap index → child sitemaps)
  2. Extracts all paths and computes Levenshtein similarity against the current URL
  3. 0.6–0.85 similarity: shows "Did you mean /correct-path/?"
  4. Above 0.85: auto-redirects with window.location.replace
  5. Below 0.6 or exact match: does nothing

Props

| Prop | Default | Description | | ----------------------- | ---------------------- | -------------------------------------------------- | | threshold | 0.6 | Minimum similarity for a suggestion to appear | | autoRedirectThreshold | 0.85 | Similarity above which the user is auto-redirected | | sitemapUrl | '/sitemap-index.xml' | URL of the sitemap index or sitemap file | | suggestionText | 'Did you mean' | Text shown before the suggested link |

hreflang alternates

For multilingual sites, pass an alternates prop with one entry per locale. <Seo> emits a <link rel="alternate"> for every entry plus an x-default, normalizes BCP 47 tags on the way out, and drops entries with relative or non-http(s) URLs.

---
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
---

<Seo
    title="Hello"
    alternates={{
        defaultLocale: 'en',
        entries: [
            { hreflang: 'en',    href: 'https://example.com/hello/' },
            { hreflang: 'fr-CA', href: 'https://example.com/fr-ca/bonjour/' },
            { hreflang: 'nl',    href: 'https://example.com/nl/hallo/' },
        ],
    }}
/>

Renders roughly:

<link rel="alternate" hreflang="en" href="https://example.com/hello/" />
<link rel="alternate" hreflang="fr-CA" href="https://example.com/fr-ca/bonjour/" />
<link rel="alternate" hreflang="nl" href="https://example.com/nl/hallo/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/hello/" />

Rules

  • Absolute URLs only. Relative (/hello/), protocol-relative (//…), and non-http schemes (mailto:) are dropped silently.
  • Include the current page. Google treats self-referential hreflang as required, not optional.
  • BCP 47 normalization. fr-ca becomes fr-CA, zh-hant-hk becomes zh-Hant-HK. Language subtag lowercase, script subtag title-case, region subtag uppercase.
  • First entry wins. Duplicate normalized tags are collapsed to the first one.
  • Automatic x-default. Points at defaultLocale if it matches an entry; otherwise falls back to the first entry.
  • < 2 entries → nothing emitted. A single-locale page has no meaningful alternates.
  • "x-default" is reserved. Passing it as an input hreflang gets dropped; it's only ever added automatically.

Feeding buildAlternateLinks from other renderers

If you're not using <Seo> directly (e.g. you're writing a CMS plugin that contributes to its own metadata pipeline), import buildAlternateLinks from the main package entry:

import { buildAlternateLinks } from '@jdevalk/astro-seo-graph';

const links = buildAlternateLinks({
    defaultLocale: 'en',
    entries: [
        { hreflang: 'en', href: siteEn },
        { hreflang: 'fr', href: siteFr },
    ],
});
// → [{ rel: 'alternate', hreflang: 'en', href: ... }, ..., { hreflang: 'x-default', ... }]

The main package entry is pure TypeScript — importing buildAlternateLinks does not pull in any Astro runtime, so it's safe to use from non-Astro contexts.

Schema endpoints

// src/pages/schema/post.json.ts
import { getCollection } from 'astro:content';
import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
import { buildArticle, buildWebPage, makeIds } from '@jdevalk/seo-graph-core';

const ids = makeIds({ siteUrl: 'https://example.com' });

export const GET = createSchemaEndpoint({
    entries: () => getCollection('blog'),
    mapper: (post) => {
        const url = `https://example.com/${post.id}/`;
        return [
            buildWebPage(
                {
                    url,
                    name: post.data.title,
                    isPartOf: { '@id': ids.website },
                    breadcrumb: { '@id': ids.breadcrumb(url) },
                    datePublished: post.data.publishDate,
                },
                ids,
            ),
            buildArticle(
                {
                    url,
                    isPartOf: { '@id': ids.webPage(url) },
                    author: { '@id': ids.person },
                    publisher: { '@id': ids.person },
                    headline: post.data.title,
                    description: post.data.excerpt ?? '',
                    datePublished: post.data.publishDate,
                },
                ids,
                'BlogPosting',
            ),
        ];
    },
});

Schema map discovery

// src/pages/schemamap.xml.ts
import { createSchemaMap } from '@jdevalk/astro-seo-graph';

export const GET = createSchemaMap({
    siteUrl: 'https://example.com',
    entries: [
        { path: '/schema/post.json', lastModified: new Date('2026-04-07') },
        { path: '/schema/video.json', lastModified: new Date('2026-03-13') },
    ],
});

Zod content helpers

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { seoSchema } from '@jdevalk/astro-seo-graph';

const blog = defineCollection({
    schema: ({ image }) =>
        z.object({
            title: z.string(),
            publishDate: z.coerce.date(),
            seo: seoSchema(image).optional(),
        }),
});

License

MIT © Joost de Valk