npm package: @jaydixit/astro-utils
Description: Utility components and plugins for Astro projects
Author: Jay Dixit
Latest version: 1.0.6

# @jaydixit/astro-utils

A collection of utility components and plugins for Astro projects.

## Installation

```bash
npm install @jaydixit/astro-utils
```

## Features

### 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

1. **Install & build the shared package**
   ```bash
   pnpm --filter @jaydixit/astro-utils build
   ```

2. **Register the controller in your global layout**
   ```astro
   <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:
   ```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`:
   ```ts
   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`:

```bash
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

```js
// 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.

```astro
---
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

```astro
---
// 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}
/>
```

```mdx
---
// 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

```astro
---
// 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

```astro
---
// 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

```astro
---
// 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

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

### Working with Content Collections

```astro
---
// 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:

```js
// 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:

```js
// 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

```astro
---
// 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:

```js
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

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

## Common Patterns

### Blog Post with All Features

```astro
---
// 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

```astro
---
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`:

```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

```astro
---
// 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

```mdx
---
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)

```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

```mdx
{/* 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)

```astro
---
// 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

```astro
---
// 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
   ```astro
   <Image src={heroImage} alt="Sunset over Cascais beach in Portugal" />
   ```

4. **Image Optimization**: Let Astro handle it
   ```astro
   <!-- 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.

```ts
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.

```ts
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`.