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

@jaydixit/astro-utils

v1.0.12

Published

Utility components and plugins for Astro projects

Readme

@jaydixit/astro-utils

A collection of utility components and plugins for Astro projects.

Installation

npm install @jaydixit/astro-utils

If you publish this package, make sure any shared package dependencies use published semver ranges rather than workspace:*. The 1.0.6 release specifically fixed that for @jaydixit/experimental-mode.

Publishing, as of the 1.0.7 release (2026-09-06):

  • Publish as the jaydixit npm account, which owns the @jaydixit scope. Check with npm whoami first; Jay's other npm account cannot publish here and gets a misleading 404 on the upload.
  • That account uses passkey 2FA, so npm publish needs a browser approval. From an agent shell, run it under a pseudo-terminal so npm offers the link instead of failing with EOTP and masking it: script -q /tmp/publish.log npm publish --access public, then read the https://www.npmjs.com/auth/cli/... URL out of the log and open it.
  • Before publishing: bump version, add a CHANGELOG entry, and run pnpm run build. files ships dist and src as they are on disk, so uncommitted files under src/ ship too.

Features

Components

  • TableOfContents - Shared TOC with classic/socratic variants (brace indicator, auto-scroll, overlap detection)
  • ImageGallery - PhotoSwipe-powered image gallery with lightbox
  • PhotoSwipeLightbox - Standalone PhotoSwipe binding with optional breakpoint-aware filtering
  • AcademicReference - Academic citation system with tooltips
  • AnchorHeading - Linkable headings with clipboard functionality
  • Backlinks - Content relationship system
  • LinkPeek - Link preview system
  • SocialShare - Social sharing with clipboard
  • StructuredData - SEO schema generation
  • Image - Shared image pipeline (Astro assets + Unpic)

Remark Plugins

  • Wiki-style links
  • Smart quotes
  • Auto image galleries
  • Reading time
  • PDF link formatting
  • Subtitle formatting with ~ prefix
  • And more...

Blog Filtering Utilities

  • Advanced visibility control system
  • Main blog vs specialized page filtering
  • Tag-based content organization
  • Draft post management

Experimental Mode Controller

  • Flag detection (?experimental=true) with optional custom param/value
  • Variant management with keyboard cycling (⌥X by default)
  • DOM dataset sync (data-experimental, data-experimental-variant) for CSS hooks
  • Simple subscription API for wiring UI treatments or telemetry

Usage

Experimental Mode: Quick Start

  1. Install & build the shared package

    pnpm --filter @jaydixit/astro-utils build
  2. Register the controller in your global layout

    <script type="module" is:inline>
      import { createExperimentalModeController } from '@jaydixit/astro-utils/utils/experimentalMode';
    
      const controller = createExperimentalModeController({
        variants: [
          { key: 'current', label: 'Production' },
          { key: 'nav-hover', label: 'Navigation hover refresh' },
        ],
        defaultKey: 'current',
        experimentalDefaultKey: 'nav-hover',
      });
    
      controller.subscribe(({ enabled, activeKey }) => {
        window.dispatchEvent(new CustomEvent('experimental-mode-change', {
          detail: { enabled, activeKey },
        }));
      });
    </script>
  3. Style via data attributes The controller sets data-experimental and data-experimental-variant on <html>, so you can scope hover states or layouts with pure CSS:

    html[data-experimental='true'][data-experimental-variant='nav-hover'] .site-nav a:hover {
      background: var(--nav-hover-bg, rgba(56, 189, 248, 0.16));
      color: var(--nav-hover-color, #0284c7);
    }
  4. React to runtime changes (optional) Components can listen for the broadcast event or inspect window.__experimentalMode:

    window.addEventListener('experimental-mode-change', (event: CustomEvent) => {
      const { enabled, activeKey } = event.detail;
      if (enabled && activeKey === 'nav-hover') {
        document.documentElement.style.setProperty('--nav-hover-bg', 'rgba(59, 130, 246, 0.18)');
      } else {
        document.documentElement.style.removeProperty('--nav-hover-bg');
      }
    });

Hotkeys & customization

  • Flag defaults: ?experimental=true; override with paramName / paramValue.
  • Keyboard cycling: ⌥X (Option+X / Alt+X). Supply cycleHotkey to change or disable.
  • Programmatic control: window.__experimentalMode.setActiveKey('variant-key') and cycleVariant() help during QA sessions.

Shared Image Component & Resolver

  • The shared <Image /> resolves inputs flexibly:
    • ~/assets/images/... → resolved to ImageMetadata via Vite glob and optimized to /_astro/...
    • Root-relative /images/... and other strings → rendered as plain <img>
    • Remote URLs on allowed domains → optimized via Unpic
  • Resolver contract: returns ImageMetadata or the original string (never null) so callers don’t silently render nothing.

Dev cache note: After editing this package, rebuild and restart consuming app dev servers so Vite picks up the new dist:

pnpm -C packages/astro-utils build
pnpm -C apps/<app> dev -- --force

Image Smoke Test Route (apps)

Each app includes /image-smoke-test with:

  • Optimized image via ~/assets/images/... (should produce /_astro/... src)
  • Public image via /... (plain <img>, no optimization)
  • Remote image on an allowed domain (Unpic)

Use this page to quickly verify image resolution and detect regressions or stale dev caches.

Complete Astro Configuration

// astro.config.ts or astro.config.mjs
import {
  readingTimeRemarkPlugin,
  responsiveTablesRehypePlugin,
  lazyImagesRehypePlugin,
  remarkWikiLink,
  remarkEmDashCodeBlocks,
  remarkSmartQuotesHeadings,
  remarkRemoveDuplicateFirstHeading,
  remarkSmartQuotesUserBlocks,
  remarkSmartQuotesQuoteBlocks,
  remarkSubtitlePrefix,
  remarkParenthesizedLinks,
  remarkPdfLinks,
  remarkStudyLinks
} from '@jaydixit/astro-utils';
import remarkSmartypants from 'remark-smartypants';

export default defineConfig({
  integrations: [
    mdx({
      remarkPlugins: [
        // Reading time must come first
        readingTimeRemarkPlugin,
        
        // Smart quotes processing (order matters!)
        remarkSmartQuotesUserBlocks,
        remarkSmartQuotesQuoteBlocks,
        [remarkSmartypants, {
          dashes: 'oldschool',
          backticks: false,
          ellipses: false,
          quotes: true
        }],
        remarkEmDashCodeBlocks,
        remarkSmartQuotesHeadings,
        
        // Content transformations
        remarkSubtitlePrefix,
        remarkRemoveDuplicateFirstHeading,
        remarkParenthesizedLinks,
        remarkPdfLinks,
        remarkStudyLinks,
        
        // Wiki links must come last
        remarkWikiLink
      ],
      rehypePlugins: [
        responsiveTablesRehypePlugin,
        lazyImagesRehypePlugin
      ],
    })
  ]
});

TableOfContents Component

A shared table of contents component with two variants for different use cases.

---
import TableOfContents from '@jaydixit/astro-utils/components/TableOfContents.astro';

// Get headings from your content
const { headings } = await post.render();
---

<!-- Classic variant: sticky sidebar with border indicator -->
<TableOfContents
  headings={headings}
  maxDepth={2}
  variant="classic"
  class="lg:w-64"
/>

<!-- Socratic variant: fixed sidebar with brace indicator -->
<TableOfContents
  headings={headings}
  maxDepth={3}
  variant="socratic"
/>

Props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | headings | Heading[] | required | Array of { depth, slug, text } from Astro's render() | | maxDepth | number | 2 | Maximum heading depth to show (2 = H1+H2) | | variant | 'classic' \| 'socratic' | 'classic' | Visual style variant | | label | string | 'Table of Contents' | Accessible label | | id | string | 'toc-sidebar' | Element ID | | class | string | '' | Additional CSS classes |

Variant features:

  • classic: Sticky positioning, smooth scroll on click, left border active indicator
  • socratic: Fixed positioning, auto-scroll TOC to active item, curly brace indicator, hides when overlapping footer

Both variants include:

  • Active link highlighting based on scroll position
  • Dark mode support
  • Responsive hiding on smaller screens
  • Emoji stripping from heading text

ImageGallery Component

---
// In your .astro file
import ImageGallery from '@jaydixit/astro-utils/components/ImageGallery.astro';
---

<!-- Basic usage with external images -->
<ImageGallery
  images={[
    { src: "/image1.jpg", alt: "Image 1" },
    { src: "/image2.jpg", alt: "Image 2" }
  ]}
  galleryId="my-gallery"
  columns={3}
/>
---
// In your .mdx file
import ImageGallery from '@jaydixit/astro-utils/components/ImageGallery.astro';
import img1 from './assets/image1.jpg';
import img2 from './assets/image2.jpg';
---

<!-- Using imported images in MDX -->
<ImageGallery
  images={[
    { src: img1, alt: "Description 1" },
    { src: img2, alt: "Description 2" }
  ]}
  galleryId="gallery-2634"
  columns={2}
/>

PhotoSwipeLightbox Component

Binds a PhotoSwipe lightbox to an existing gallery. Pass the gallery selector and, optionally, a selector for the clickable children.

---
import PhotoSwipeLightbox from '@jaydixit/astro-utils/components/PhotoSwipeLightbox.astro';
---

<PhotoSwipeLightbox gallerySelector="#photo-grid" childrenSelector="a.photo" />

Breakpoint-aware filtering

responsiveExclude (one rule) and responsiveExcludes (several) drop gallery children from the lightbox while their media query matches, so the lightbox sequence matches what the visitor can actually see. Survivors are ordered by their computed CSS order, which keeps a flex or grid reordering in sync with the slide order. Without either prop the children selector is passed through unchanged and nothing is sorted.

The photos homepage uses both directions of one breakpoint:

<PhotoSwipeLightbox
  gallerySelector="#home-grid"
  responsiveExcludes={[
    { media: '(max-width: 820px)', selector: '[data-mobile-size="hidden"]' },
    { media: '(min-width: 821px)', selector: '[data-mobile-only="true"]' },
  ]}
/>

A single rule can be passed on its own:

<PhotoSwipeLightbox
  gallerySelector="#home-grid"
  responsiveExclude={{ media: '(max-width: 820px)', selector: '[data-mobile-size="hidden"]' }}
/>

Both props may be combined; responsiveExclude is merged into the front of the responsiveExcludes list. Each rule's media query is re-evaluated on change, so resizing the window resyncs the slide list without a reload.

Typography Utilities

---
// Smart quotes for dynamic content
import { smartQuotes } from '@jaydixit/astro-utils';

const title = '"Building better websites"';
const processedTitle = smartQuotes(title); // "Building better websites"
---

<h1>{processedTitle}</h1>
<p>{smartQuotes("It's a beautiful day — let's code!")}</p>

StructuredData Component

---
// In your Layout.astro
import StructuredData from '@jaydixit/astro-utils/components/StructuredData.astro';

const structuredData = {
  "@type": "WebSite",
  name: "My Website",
  url: "https://example.com"
};
---

<head>
  <StructuredData data={structuredData} />
</head>

LinkPeek Component

---
// Enable link previews in your content
import LinkPeek from '@jaydixit/astro-utils/components/LinkPeek.astro';
---

<!-- In blog post layout -->
<Content components={{ LinkPeek, ImageGallery }} />

CSS Styles

/* In your global CSS file */
@import '@jaydixit/astro-utils/styles/blocks.css';

Working with Content Collections

---
// Proper typing for content collections
import type { CollectionEntry } from 'astro:content';
type BlogPost = CollectionEntry<'blog'>;

const { post }: { post: BlogPost } = Astro.props;
---

<article>
  <h1>{smartQuotes(post.data.title)}</h1>
  <time datetime={post.data.publishDate.toISOString()}>
    {getFormattedDate(post.data.publishDate)}
  </time>
</article>

Blog Filtering and Visibility Control

Control where your blog posts appear with advanced filtering utilities:

// Import filtering utilities
import { 
  filterMainBlogPosts,
  filterSpecializedBlogPosts,
  isPostHidden,
  isPostSpecialized,
  normalizeTags
} from '@jaydixit/astro-utils';

// Get posts from content collection
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog');

// Filter for main blog index (excludes draft and visibility-restricted posts)
const mainBlogPosts = filterMainBlogPosts(allPosts);

// Filter for specialized pages (e.g., /examples/)
const examplePosts = filterSpecializedBlogPosts(allPosts, 'example');

// Check individual post visibility
const isHidden = isPostHidden(post); // true if draft or visibility="hidden"
const isSpecialized = isPostSpecialized(post); // true if has visibility value

Content Schema Setup

Configure your content collection schema to support visibility control:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    excerpt: z.string(),
    publishDate: z.date(),
    // Support both single string and array formats for tags
    tags: z.union([z.string(), z.array(z.string())]).optional(),
    // Visibility control
    visibility: z.string().optional(),
    draft: z.boolean().optional(),
    // ... other fields
  }),
});

Visibility Options

  • No visibility field - Post appears everywhere (default)
  • visibility: "hidden" - Hidden from all listings (still accessible via direct URL)
  • visibility: "example" - Hidden from main blog, shown on /examples/ page
  • draft: true - Excluded from all production listings

Example: Creating Specialized Pages

---
// pages/examples/index.astro
import { getCollection } from 'astro:content';
import { filterSpecializedBlogPosts } from '@jaydixit/astro-utils';

// Get all posts filtered for "example" visibility or tag
const allPosts = await getCollection('blog');
const examplePosts = filterSpecializedBlogPosts(allPosts, 'example');
---

<h1>Examples</h1>
{examplePosts.map(post => (
  <article>
    <h2>{post.data.title}</h2>
    <p>{post.data.excerpt}</p>
  </article>
))}

Tag Normalization

The utility handles both single string and array tag formats:

import { normalizeTags } from '@jaydixit/astro-utils';

// Works with both formats
normalizeTags("tutorial");              // Returns: ["tutorial"]
normalizeTags(["tutorial", "advanced"]); // Returns: ["tutorial", "advanced"]
normalizeTags(undefined);                // Returns: []

Workspace Configuration

// In your app's package.json
{
  "dependencies": {
    "@jaydixit/astro-utils": "workspace:*"
  }
}

Common Patterns

Blog Post with All Features

---
// pages/blog/[slug].astro
import { smartQuotes } from '@jaydixit/astro-utils';
import ImageGallery from '@jaydixit/astro-utils/components/ImageGallery.astro';
import LinkPeek from '@jaydixit/astro-utils/components/LinkPeek.astro';
import type { CollectionEntry } from 'astro:content';

type BlogPost = CollectionEntry<'blog'>;
const { post }: { post: BlogPost } = Astro.props;
const { Content } = await post.render();
---

<article>
  <header>
    <h1>{smartQuotes(post.data.title)}</h1>
    {post.data.subtitle && (
      <p class="subtitle">{smartQuotes(post.data.subtitle)}</p>
    )}
  </header>
  
  <Content components={{ LinkPeek, ImageGallery }} />
</article>

Hero Component with Smart Quotes

---
import { smartQuotes } from '@jaydixit/astro-utils';

interface Props {
  title?: string;
  subtitle?: string;
  tagline?: string;
}

const { title, subtitle, tagline } = Astro.props;
---

<section class="hero">
  {title && <h1>{smartQuotes(title)}</h1>}
  {subtitle && <h2>{smartQuotes(subtitle)}</h2>}
  {tagline && <p>{smartQuotes(tagline)}</p>}
</section>

Plugin Order Matters!

The order of remark plugins is critical for proper processing:

  1. readingTimeRemarkPlugin - Must come first
  2. Smart quotes plugins - Process different quote contexts
  3. remarkSmartypants - General smart typography
  4. Content transformations - Headings, subtitles, etc.
  5. remarkStudyLinks - Convert [[study:...]] to AcademicReference
  6. remarkWikiLink - Must come last to avoid conflicts

Working with Images

Setting Up the ~ Alias

The ~ alias provides a clean way to import from your src directory. Configure it in your tsconfig.json:

// tsconfig.json in each app
{
  "extends": "astro/tsconfigs/base",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~/*": ["src/*"],
      "astro-utils/*": ["../../packages/astro-utils/src/*"]
    }
  }
}

Image Organization

Organize your images in a consistent structure:

apps/[app-name]/src/assets/
├── images/
│   ├── posts/           # Blog post images
│   │   ├── [post-slug]/ # Individual post folders
│   │   └── shared/      # Shared across posts
│   ├── authors/         # Author headshots
│   └── backgrounds/     # Background images
└── icons/               # SVG icons

Import Patterns in .astro Files

---
// Basic image imports in .astro files
import { Image } from 'astro:assets';
import heroImage from '~/assets/images/posts/my-post/hero.jpg';
import authorImage from '~/assets/images/authors/jay-dixit-512.png';

// Multiple related images
import visionIcon from '~/assets/images/plant-grow.png';
import humanApproachIcon from '~/assets/images/new-creativity-brain.png';
import neuralNetBg from '~/assets/images/backgrounds/neural-net.png';
---

<!-- Using Astro's Image component -->
<Image src={heroImage} alt="Hero image description" />

<!-- With additional attributes -->
<Image 
  src={authorImage} 
  alt="Jay Dixit"
  width={512}
  height={512}
  class="rounded-full"
/>

Import Patterns in .mdx Files

---
title: My Blog Post
author: Jay Dixit
# Reference images in frontmatter using ~ alias
authorImage: ~/assets/images/authors/jay-dixit-512.png
image: ~/assets/images/posts/my-post/hero.jpg
imageAlt: Descriptive alt text for hero image
---

{/* Import images after frontmatter */}
import { Image } from 'astro:assets';
import hero from '~/assets/images/posts/my-post/hero.jpg';

{/* For ImageGallery component */}
import ImageGallery from '@jaydixit/astro-utils/components/ImageGallery.astro';
import img1 from '~/assets/images/posts/my-post/image-1.jpg';
import img2 from '~/assets/images/posts/my-post/image-2.png';
import img3 from '~/assets/images/posts/my-post/image-3.webp';

# My Blog Post

<Image src={hero} alt="Hero image" />

Regular content here...

<ImageGallery
  images={[
    { src: img1, alt: "First image description" },
    { src: img2, alt: "Second image description" },
    { src: img3, alt: "Third image description" }
  ]}
  galleryId="unique-gallery-id"
/>

Complex Gallery Example (from gallery-test-rigorous.mdx)

---
title: Gallery Test
# Image in frontmatter
image: ~/assets/images/posts/gallery-test/hero.png
imageAlt: Hero image description
---

import { Image } from 'astro:assets';
import ImageGallery from '@jaydixit/astro-utils/components/ImageGallery.astro';

{/* Import multiple images for galleries */}
import massimoImgA from '~/assets/images/posts/massimo-dutti/outfit-1.jpg';
import massimoImgB from '~/assets/images/posts/massimo-dutti/outfit-2.jpg';
import boggiImage from '~/assets/images/posts/boggi-storefront.webp';
import massimoNews01 from '~/assets/images/posts/massimo-dutti/editorial.jpg';
import qrCode from '~/assets/images/posts/gallery-test/QR1-0943.jpg';

{/* Mixed format imports (JPG, PNG, WebP) */}
import portrait from '~/assets/images/posts/gallery-test/portrait.jpg';
import screenshot from '~/assets/images/posts/gallery-test/screenshot.png';
import optimized from '~/assets/images/posts/gallery-test/optimized.webp';

# Multi-Column Gallery (5 images)

<ImageGallery
  images={[
    { src: massimoImgA, alt: "Massimo Dutti outfit 1" },
    { src: massimoImgB, alt: "Massimo Dutti outfit 2" },
    { src: boggiImage, alt: "Boggi Milano storefront" },
    { src: massimoNews01, alt: "Massimo Dutti editorial" },
    { src: qrCode, alt: "QR code test" }
  ]}
  galleryId="gallery-389"
  columns={3}
/>

# Mixed Content: Gallery + Individual Images

{/* 2-image gallery */}
<ImageGallery
  images={[
    { src: portrait, alt: "Portrait photo" },
    { src: screenshot, alt: "Screenshot" }
  ]}
  galleryId="gallery-2634"
  columns={2}
/>

{/* Individual image (not in gallery) */}
<Image src={optimized} alt="Standalone optimized image" />

Importing from Shared Folders

{/* Import from shared folder for reusable images */}
import hero from '~/assets/images/posts/shared/cherry-blossom.png';
import defaultAuthor from '~/assets/images/authors/default-avatar.png';

{/* Cross-post image sharing */}
import sharedDiagram from '~/assets/images/posts/shared/venn-diagram.png';

Image Format Support

Astro supports these image formats:

  • Static formats: .jpg, .jpeg, .png, .webp, .gif, .svg, .avif
  • Animated: .gif (preserved), .webp (animated)
  • Vector: .svg (passed through unchanged)
---
// Different format examples
import jpgImage from '~/assets/images/photo.jpg';
import pngImage from '~/assets/images/diagram.png';
import webpImage from '~/assets/images/optimized.webp';
import svgIcon from '~/assets/images/icon.svg';
---

<!-- Each format handled appropriately -->
<Image src={jpgImage} alt="JPEG photo" />
<Image src={pngImage} alt="PNG with transparency" />
<Image src={webpImage} alt="WebP optimized" />
<img src={svgIcon.src} alt="SVG icon" /> <!-- SVG as regular img -->

Logo Component Example

---
// components/Logo.astro
import { Image } from 'astro:assets';
import logoImage from '~/assets/images/logo.png';
---

<div class="logo">
  <Image 
    src={logoImage} 
    alt="Company Logo"
    width={200}
    height={60}
    loading="eager"
  />
</div>

Common Patterns and Best Practices

  1. Consistent Naming: Use descriptive, kebab-case filenames

    ✅ jay-dixit-headshot.jpg
    ✅ socratic-ai-logo.png
    ❌ IMG_1234.jpg
    ❌ untitled.png
  2. Folder Organization: Group related images

    posts/worlds-best-cities/cascais-portugal.jpg
    posts/worlds-best-cities/tokyo-skyline.jpg
    posts/shared/default-hero.png
  3. Alt Text: Always provide meaningful alt text

    <Image src={heroImage} alt="Sunset over Cascais beach in Portugal" />
  4. Image Optimization: Let Astro handle it

    <!-- Astro automatically optimizes this -->
    <Image src={largePhoto} alt="Description" />
       
    <!-- Specify dimensions for better performance -->
    <Image src={largePhoto} alt="Description" width={800} height={600} />
  5. Frontmatter vs Imports:

    • Use frontmatter for metadata (hero images, og:image)
    • Use imports for images rendered in content

Tips and Best Practices

  • Always import from 'astro-utils' not relative paths
  • Use workspace:* for monorepo dependencies
  • Apply smart quotes to all user-facing text
  • Test plugin combinations when adding new ones
  • Use proper TypeScript types for content collections
  • Organize images in consistent folder structures
  • Always use the ~ alias for src-relative imports
  • Provide descriptive alt text for all images
  • Use the smoke test route to validate image behavior after changes

License

MIT

SEO Helpers

Build consistent meta, OpenGraph, and Twitter card data across apps.

import { createSEOHelpers, adaptOpenGraphImages } from '@jaydixit/astro-utils';

const helpers = createSEOHelpers({
  siteName: 'My Site',
  baseUrl: 'https://example.com',
  locale: 'en_US',
  twitterHandle: '@example',
  titleTemplate: '%s | My Site',
  defaultImage: '/img/default.png',
  defaultImageAlt: 'Default image'
});

const meta = helpers.buildMeta({
  title: 'Page Title',
  description: 'Desc',
  canonicalPath: '/page',
});

let og = helpers.buildOpenGraph({
  title: meta.title,
  description: meta.description,
  image: '/img/og.png',
  canonicalUrl: meta.canonical,
  type: 'article',
});
og = await adaptOpenGraphImages(og, new URL('https://example.com'));

const twitter = helpers.buildTwitter({ title: meta.title, description: meta.description, image: '/img/og.png' });

buildOpenGraph also accepts an ordered images array with url, alt, width, height, type, and secureUrl. Root-relative URLs become absolute; ~/assets/images/ aliases remain available to the image adapter. A custom image does not inherit another image's default alt text.

Render the result with @jaydixit/astro-utils/components/SeoTags.astro. It owns Open Graph and Twitter tags, including media properties and the article:, book:, profile:, and video: type namespaces. Twitter fields default to the processed Open Graph data; explicit named additionalMetaTags override generated values without duplicating them. Keep each site's titles, image choices, canonical policy, and indexing decisions in its adapter.

For public files, emitted build assets, or selected remote images, import resolveSocialImage from @jaydixit/astro-utils/utils/social-image.ts in Astro frontmatter. This server-only helper reads actual dimensions and MIME type, caches measurements, and leaves the image unmodified. It uses the consuming app's publicDir and outDir; images awaiting Astro optimization need dimensions and MIME supplied by getImage or adaptOpenGraphImages. Do not import this helper into browser scripts or a plain Node process because it uses Astro's server configuration module.

Use the exported serializeJsonLd(data) when inserting JSON-LD with set:html; it escapes HTML delimiters without altering the parsed data. The shared StructuredData component uses it automatically.

After building the apps, run pnpm check:social-metadata at the monorepo root for representative built-page checks. An external Photos checkout is opt-in: pnpm check:social-metadata -- --site photos --photos-dir /absolute/path/to/photos-jaydixit/dist. This checks tags and local image bytes; it does not certify every page's editorial image choice or a social platform's cached preview.

Related Posts Scoring

Centralize relevance scoring based on tag/category overlap.

import { scoreRelated } from '@jaydixit/astro-utils';

const related = scoreRelated(allPosts, currentPost, {
  tagWeight: 2,
  categoryWeight: 1,
  minScore: 2,
  limit: 4,
});

Notes:

  • Posts should have { slug, category?: { slug }, tags?: Array<{ slug }> }.
  • Returns up to limit items, with fallback fill if fewer meet minScore.

Formspree contact forms

Import enhanceFormspreeForm from @jaydixit/astro-utils/utils/formspree to enhance a native form with retained-input error handling, duplicate-submit guards, and an optional postSuccessRedirect. Pass a focusable status element and keep each site's labels and redirect policy local.

Each request has a 20-second timeout. A timeout aborts the browser request, preserves the form values, restores the original button label/markup, and permits a manual retry. The notice explicitly says delivery could not be confirmed and a retry may duplicate the message: aborting the browser request does not establish whether the server accepted it. Override that notice with data-timeout-message if needed. There is no automatic retry, form reset, redirect, or formspree:success event on timeout; responses received after the deadline cannot become successes. Settled requests clear their timers.

The monorepo's scripts/check-socratic-contact.mjs exercises validation, HTTP/network failures, timeout recovery, late-response suppression, retained values/button markup, manual retry, and accepted-contact analytics on the three apps' real local forms. All provider and analytics requests are intercepted; it sends no real inquiry. Photos receives changes to this helper through a published package update.