@jaydixit/astro-utils
v1.0.6
Published
Utility components and plugins for Astro projects
Maintainers
Readme
@jaydixit/astro-utils
A collection of utility components and plugins for Astro projects.
Installation
npm install @jaydixit/astro-utilsFeatures
Components
- TableOfContents - Shared TOC with classic/socratic variants (brace indicator, auto-scroll, overlap detection)
- ImageGallery - PhotoSwipe-powered image gallery with lightbox
- 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
Install & build the shared package
pnpm --filter @jaydixit/astro-utils buildRegister 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>Style via data attributes The controller sets
data-experimentalanddata-experimental-varianton<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); }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 withparamName/paramValue. - Keyboard cycling: ⌥X (Option+X / Alt+X). Supply
cycleHotkeyto change or disable. - Programmatic control:
window.__experimentalMode.setActiveKey('variant-key')andcycleVariant()help during QA sessions.
Shared Image Component & Resolver
- The shared
<Image />resolves inputs flexibly:~/assets/images/...→ resolved toImageMetadatavia 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
ImageMetadataor the original string (nevernull) 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 -- --forceImage 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}
/>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 valueContent 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/pagedraft: 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:
- readingTimeRemarkPlugin - Must come first
- Smart quotes plugins - Process different quote contexts
- remarkSmartypants - General smart typography
- Content transformations - Headings, subtitles, etc.
- remarkStudyLinks - Convert [[study:...]] to AcademicReference
- 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 iconsImport 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
Consistent Naming: Use descriptive, kebab-case filenames
✅ jay-dixit-headshot.jpg ✅ socratic-ai-logo.png ❌ IMG_1234.jpg ❌ untitled.pngFolder Organization: Group related images
posts/worlds-best-cities/cascais-portugal.jpg posts/worlds-best-cities/tokyo-skyline.jpg posts/shared/default-hero.pngAlt Text: Always provide meaningful alt text
<Image src={heroImage} alt="Sunset over Cascais beach in Portugal" />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} />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' });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
limititems, with fallback fill if fewer meetminScore.
